A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-maildir / tests / e2e.zig
29 kB 777 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! What the library does to a real directory. 5//! 6//! Everything in `src` that can be tested without touching a filesystem is 7//! tested beside the code it tests; this is the rest, and the rest is most of 8//! what a maildir *is*. A name parser that round trips proves nothing about 9//! whether a delivery is atomic, whether a flag change moves the file from 10//! `new` to `cur`, or whether the quota ledger adds up — those are claims 11//! about directories, and they need directories. 12//! 13//! These are a module of their own so that `zig build test` runs them while a 14//! consumer of the library never compiles them. 15 16const std = @import("std"); 17const Io = std.Io; 18const Dir = Io.Dir; 19const testing = std.testing; 20const io = testing.io; 21 22const maildir = @import("maildir"); 23const Maildir = maildir.Maildir; 24const Store = maildir.Store; 25const Flags = maildir.Flags; 26 27const message_text = 28 "From: jeff@example.com\r\n" ++ 29 "To: someone@example.net\r\n" ++ 30 "Subject: A test\r\n" ++ 31 "\r\n" ++ 32 "Hello.\r\n"; 33 34/// Counts what is in one of the three subdirectories, skipping the dotfiles 35/// the way the library does. 36fn count(m: *const Maildir, which: Maildir.Subdir) !usize { 37 var total: usize = 0; 38 var it = m.iterate(which); 39 while (try it.next(io)) |_| total += 1; 40 return total; 41} 42 43test "create makes the three directories, and open insists on them" { 44 var tmp = testing.tmpDir(.{ .iterate = true }); 45 defer tmp.cleanup(); 46 47 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 48 m.close(io); 49 50 // Opening it again works, and finds the same three. 51 var reopened = try Maildir.open(tmp.dir, io, "Maildir", .{}); 52 reopened.close(io); 53 54 // A directory that is not a maildir is refused, and with an error that 55 // says which problem it is: delivering into a directory that merely looks 56 // like a mailbox is how mail gets lost. 57 try tmp.dir.createDir(io, "NotAMaildir", .default_dir); 58 try testing.expectError( 59 error.NotAMaildir, 60 Maildir.open(tmp.dir, io, "NotAMaildir", .{}), 61 ); 62 try testing.expectError( 63 error.FileNotFound, 64 Maildir.open(tmp.dir, io, "NoSuchThing", .{}), 65 ); 66} 67 68test "create is idempotent" { 69 var tmp = testing.tmpDir(.{ .iterate = true }); 70 defer tmp.cleanup(); 71 72 var first = try Maildir.create(tmp.dir, io, "Maildir", .{}); 73 _ = try first.deliver(io, message_text, .{}); 74 first.close(io); 75 76 var second = try Maildir.create(tmp.dir, io, "Maildir", .{}); 77 defer second.close(io); 78 // The message that was already there is still there. 79 try testing.expectEqual(@as(usize, 1), try count(&second, .new)); 80} 81 82test "a delivered message lands in new, whole, and leaves tmp empty" { 83 var tmp = testing.tmpDir(.{ .iterate = true }); 84 defer tmp.cleanup(); 85 86 var m = try Maildir.create(tmp.dir, io, "Maildir", .{ .hostname = "test.example" }); 87 defer m.close(io); 88 89 const delivered = try m.deliver(io, message_text, .{}); 90 try testing.expectEqual(Maildir.Subdir.new, delivered.subdir); 91 try testing.expectEqual(Flags.none, delivered.flags()); 92 93 // The whole point of the tmp/new dance: nothing is left behind, and what 94 // arrived in `new` is the whole message. 95 try testing.expectEqual(@as(usize, 0), try count(&m, .tmp)); 96 try testing.expectEqual(@as(usize, 1), try count(&m, .new)); 97 98 const bytes = try delivered.read(&m, testing.allocator, io, .unlimited); 99 defer testing.allocator.free(bytes); 100 try testing.expectEqualStrings(message_text, bytes); 101} 102 103test "the name carries the host, the size, and no flags" { 104 var tmp = testing.tmpDir(.{ .iterate = true }); 105 defer tmp.cleanup(); 106 107 var m = try Maildir.create(tmp.dir, io, "Maildir", .{ .hostname = "test.example" }); 108 defer m.close(io); 109 110 const delivered = try m.deliver(io, message_text, .{}); 111 const name = delivered.name(); 112 // The host is the end of the identifying part. `unique` is longer than 113 // that, because the size field is appended to it. 114 try testing.expect(std.mem.endsWith(u8, name.base(), "test.example")); 115 try testing.expect(std.mem.endsWith(u8, name.unique, ",S=76")); 116 try testing.expectEqual(@as(?u64, message_text.len), name.size()); 117 try testing.expectEqual(maildir.Name.Info.none, name.info); 118 119 // And the size in the name is the size on disk, which is the whole reason 120 // anything is allowed to trust it. 121 try testing.expectEqual( 122 @as(u64, message_text.len), 123 try delivered.size(&m, io), 124 ); 125} 126 127test "record_size off leaves the size out rather than guessing it" { 128 var tmp = testing.tmpDir(.{ .iterate = true }); 129 defer tmp.cleanup(); 130 131 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 132 defer m.close(io); 133 134 const delivered = try m.deliver(io, message_text, .{ .record_size = false }); 135 try testing.expectEqual(@as(?u64, null), delivered.name().size()); 136 // It still knows how big the message is; it just has to ask the 137 // filesystem. 138 try testing.expectEqual(@as(u64, message_text.len), try delivered.size(&m, io)); 139} 140 141test "the virtual size counts the line endings a bare-LF message is missing" { 142 const unix_text = "From: a@b\n\nOne\nTwo\n"; 143 try testing.expectEqual( 144 @as(u64, unix_text.len + 4), 145 Maildir.virtualSizeOf(unix_text), 146 ); 147 // A message that is already CRLF needs nothing added. 148 try testing.expectEqual( 149 @as(u64, message_text.len), 150 Maildir.virtualSizeOf(message_text), 151 ); 152 153 var tmp = testing.tmpDir(.{ .iterate = true }); 154 defer tmp.cleanup(); 155 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 156 defer m.close(io); 157 158 const delivered = try m.deliver(io, unix_text, .{ .record_virtual_size = true }); 159 try testing.expectEqual(@as(?u64, unix_text.len), delivered.name().size()); 160 try testing.expectEqual(@as(?u64, unix_text.len + 4), delivered.name().virtualSize()); 161} 162 163test "delivering into cur with flags is what an IMAP append does" { 164 var tmp = testing.tmpDir(.{ .iterate = true }); 165 defer tmp.cleanup(); 166 167 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 168 defer m.close(io); 169 170 const delivered = try m.deliver(io, message_text, .{ 171 .to = .{ .cur = .{ .seen = true, .draft = true } }, 172 }); 173 try testing.expectEqual(Maildir.Subdir.cur, delivered.subdir); 174 try testing.expect(delivered.flags().seen and delivered.flags().draft); 175 try testing.expectEqual(@as(usize, 0), try count(&m, .new)); 176 try testing.expectEqual(@as(usize, 1), try count(&m, .cur)); 177 178 // `D` sorts before `S`, and the flags come after the size field. 179 try testing.expect(std.mem.endsWith(u8, delivered.filename(), ":2,DS")); 180} 181 182test "two deliveries in a row do not collide" { 183 var tmp = testing.tmpDir(.{ .iterate = true }); 184 defer tmp.cleanup(); 185 186 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 187 defer m.close(io); 188 189 var seen: std.StringHashMapUnmanaged(void) = .empty; 190 defer { 191 var it = seen.keyIterator(); 192 while (it.next()) |key| testing.allocator.free(key.*); 193 seen.deinit(testing.allocator); 194 } 195 196 for (0..200) |_| { 197 const delivered = try m.deliver(io, message_text, .{ .sync = false }); 198 const name = try testing.allocator.dupe(u8, delivered.filename()); 199 errdefer testing.allocator.free(name); 200 try testing.expect(!seen.contains(name)); 201 try seen.put(testing.allocator, name, {}); 202 } 203 try testing.expectEqual(@as(usize, 200), try count(&m, .new)); 204} 205 206test "a streamed delivery writes the same message as a sliced one" { 207 var tmp = testing.tmpDir(.{ .iterate = true }); 208 defer tmp.cleanup(); 209 210 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 211 defer m.close(io); 212 213 var buffer: [64]u8 = undefined; // deliberately smaller than the message 214 var delivery = try m.beginDelivery(io, 10); 215 errdefer delivery.abort(io); 216 const w = delivery.writer(io, &buffer); 217 try w.writeAll(message_text[0..10]); 218 try w.writeAll(message_text[10..]); 219 const delivered = try delivery.commit(io, .{}); 220 221 const bytes = try delivered.read(&m, testing.allocator, io, .unlimited); 222 defer testing.allocator.free(bytes); 223 try testing.expectEqualStrings(message_text, bytes); 224 // The size was not given, so `commit` asked the file. 225 try testing.expectEqual(@as(?u64, message_text.len), delivered.name().size()); 226} 227 228test "an aborted delivery leaves nothing anywhere" { 229 var tmp = testing.tmpDir(.{ .iterate = true }); 230 defer tmp.cleanup(); 231 232 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 233 defer m.close(io); 234 235 var delivery = try m.beginDelivery(io, 10); 236 var buffer: [64]u8 = undefined; 237 const w = delivery.writer(io, &buffer); 238 try w.writeAll("half a mess"); 239 delivery.abort(io); 240 241 try testing.expectEqual(@as(usize, 0), try count(&m, .tmp)); 242 try testing.expectEqual(@as(usize, 0), try count(&m, .new)); 243 244 // Aborting twice, or aborting a committed delivery, is harmless -- which 245 // is what lets it be an errdefer beside a commit. 246 delivery.abort(io); 247} 248 249test "setting a flag moves the message from new to cur" { 250 var tmp = testing.tmpDir(.{ .iterate = true }); 251 defer tmp.cleanup(); 252 253 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 254 defer m.close(io); 255 256 var message = try m.deliver(io, message_text, .{}); 257 const id = try testing.allocator.dupe(u8, message.id()); 258 defer testing.allocator.free(id); 259 260 try message.setFlags(&m, io, .{ .seen = true }); 261 262 // There is nowhere in `new` for a flag to be written, so marking a 263 // message read necessarily moves it. 264 try testing.expectEqual(Maildir.Subdir.cur, message.subdir); 265 try testing.expectEqual(@as(usize, 0), try count(&m, .new)); 266 try testing.expectEqual(@as(usize, 1), try count(&m, .cur)); 267 try testing.expect(message.flags().seen); 268 269 // And the thing that identifies the message did not change, which is what 270 // makes it the same message. 271 try testing.expectEqualStrings(id, message.id()); 272 273 // The file really is under the new name and not the old one. 274 const bytes = try message.read(&m, testing.allocator, io, .unlimited); 275 defer testing.allocator.free(bytes); 276 try testing.expectEqualStrings(message_text, bytes); 277} 278 279test "adding and removing flags leaves the others alone" { 280 var tmp = testing.tmpDir(.{ .iterate = true }); 281 defer tmp.cleanup(); 282 283 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 284 defer m.close(io); 285 286 var message = try m.deliver(io, message_text, .{}); 287 try message.setFlags(&m, io, .{ .seen = true, .flagged = true }); 288 try message.addFlags(&m, io, .{ .replied = true }); 289 try testing.expect(message.flags().seen and message.flags().flagged and message.flags().replied); 290 291 try message.removeFlags(&m, io, .{ .flagged = true }); 292 try testing.expect(message.flags().seen and message.flags().replied); 293 try testing.expect(!message.flags().flagged); 294 try testing.expect(std.mem.endsWith(u8, message.filename(), ":2,RS")); 295} 296 297test "a keyword another program set survives a flag change" { 298 var tmp = testing.tmpDir(.{ .iterate = true }); 299 defer tmp.cleanup(); 300 301 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 302 defer m.close(io); 303 304 // Deliver with a Dovecot keyword already on it, as Dovecot would. 305 var flagged: Flags = .{ .seen = true }; 306 flagged.setLetter('b', true); 307 var message = try m.deliver(io, message_text, .{ .to = .{ .cur = flagged } }); 308 309 try message.addFlags(&m, io, .{ .replied = true }); 310 try testing.expect(message.flags().hasLetter('b')); 311 try testing.expect(std.mem.endsWith(u8, message.filename(), ":2,RSb")); 312} 313 314test "moveToCur files a message without claiming anybody read it" { 315 var tmp = testing.tmpDir(.{ .iterate = true }); 316 defer tmp.cleanup(); 317 318 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 319 defer m.close(io); 320 321 var message = try m.deliver(io, message_text, .{}); 322 try message.moveToCur(&m, io); 323 try testing.expectEqual(Maildir.Subdir.cur, message.subdir); 324 try testing.expectEqual(Flags.none, message.flags()); 325 try testing.expect(std.mem.endsWith(u8, message.filename(), ":2,")); 326 327 // Doing it again is a no-op rather than a second rename. 328 const before = try testing.allocator.dupe(u8, message.filename()); 329 defer testing.allocator.free(before); 330 try message.moveToCur(&m, io); 331 try testing.expectEqualStrings(before, message.filename()); 332} 333 334test "find locates a message again after its flags have changed" { 335 var tmp = testing.tmpDir(.{ .iterate = true }); 336 defer tmp.cleanup(); 337 338 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 339 defer m.close(io); 340 341 var message = try m.deliver(io, message_text, .{}); 342 const id = try testing.allocator.dupe(u8, message.id()); 343 defer testing.allocator.free(id); 344 345 try testing.expect((try m.find(io, id)) != null); 346 try message.setFlags(&m, io, .{ .seen = true, .replied = true }); 347 348 const found = (try m.find(io, id)).?; 349 try testing.expectEqual(Maildir.Subdir.cur, found.subdir); 350 try testing.expect(found.flags().seen); 351 try testing.expectEqual(@as(?maildir.Message, null), try m.find(io, "not-a-message")); 352} 353 354test "remove is the expunge that trashed only asks for" { 355 var tmp = testing.tmpDir(.{ .iterate = true }); 356 defer tmp.cleanup(); 357 358 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 359 defer m.close(io); 360 361 var message = try m.deliver(io, message_text, .{}); 362 try message.addFlags(&m, io, .{ .trashed = true }); 363 // Marked, and still there. 364 try testing.expectEqual(@as(usize, 1), try count(&m, .cur)); 365 366 try message.remove(&m, io); 367 try testing.expectEqual(@as(usize, 0), try count(&m, .cur)); 368} 369 370test "list gathers both subdirectories" { 371 var tmp = testing.tmpDir(.{ .iterate = true }); 372 defer tmp.cleanup(); 373 374 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 375 defer m.close(io); 376 377 _ = try m.deliver(io, message_text, .{}); 378 _ = try m.deliver(io, message_text, .{}); 379 _ = try m.deliver(io, message_text, .{ .to = .{ .cur = .{ .seen = true } } }); 380 381 const all = try m.list(testing.allocator, io, &.{ .new, .cur }); 382 defer testing.allocator.free(all); 383 try testing.expectEqual(@as(usize, 3), all.len); 384 385 const just_new = try m.list(testing.allocator, io, &.{.new}); 386 defer testing.allocator.free(just_new); 387 try testing.expectEqual(@as(usize, 2), just_new.len); 388} 389 390test "cleanTemp removes old wreckage and leaves a delivery in progress alone" { 391 var tmp = testing.tmpDir(.{ .iterate = true }); 392 defer tmp.cleanup(); 393 394 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 395 defer m.close(io); 396 397 // A delivery that died: a file in `tmp` nobody will ever rename. 398 const wreck = try m.tmp.createFile(io, "abandoned", .{}); 399 wreck.close(io); 400 401 // With the specified threshold it is far too young to touch, which is the 402 // property that matters -- deleting a file out from under a delivery in 403 // progress loses the message. 404 try testing.expectEqual(@as(usize, 0), try m.cleanTemp(io, Maildir.temp_max_age)); 405 406 // With no grace period at all it goes. 407 try testing.expectEqual(@as(usize, 1), try m.cleanTemp(io, .{ .nanoseconds = 0 })); 408 try testing.expectEqual(@as(usize, 0), try count(&m, .tmp)); 409} 410 411test "a message parses through zig-mime" { 412 var tmp = testing.tmpDir(.{ .iterate = true }); 413 defer tmp.cleanup(); 414 415 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 416 defer m.close(io); 417 418 const delivered = try m.deliver(io, message_text, .{}); 419 420 var parsed = try delivered.parse(&m, testing.allocator, io, .unlimited, .{}); 421 defer parsed.deinit(); 422 423 try testing.expectEqualStrings("A test", (try parsed.root.subject()).?); 424 425 var from = try parsed.root.addresses(testing.allocator, "from"); 426 defer from.deinit(testing.allocator); 427 try testing.expectEqual(@as(usize, 1), from.addresses.len); 428} 429 430test "a message written by zig-mime is delivered and reads back the same" { 431 var tmp = testing.tmpDir(.{ .iterate = true }); 432 defer tmp.cleanup(); 433 434 var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); 435 defer m.close(io); 436 437 const mime = @import("mime"); 438 var built: mime.Message = try .init(testing.allocator); 439 defer built.deinit(); 440 try built.root.setSubject("Grüße"); 441 try built.setText("Schöne Grüße.\n", .{}); 442 443 // The streaming delivery exists for exactly this: the message is written 444 // straight into `tmp` rather than into a buffer and then into `tmp`. 445 var buffer: [4096]u8 = undefined; 446 var delivery = try m.beginDelivery(io, 10); 447 errdefer delivery.abort(io); 448 try built.write(delivery.writer(io, &buffer)); 449 const delivered = try delivery.commit(io, .{}); 450 451 var parsed = try delivered.parse(&m, testing.allocator, io, .unlimited, .{}); 452 defer parsed.deinit(); 453 try testing.expectEqualStrings("Grüße", (try parsed.root.subject()).?); 454} 455 456// -- Maildir++ ---------------------------------------------------------------- 457 458test "a store has an inbox and folders beside it" { 459 var tmp = testing.tmpDir(.{ .iterate = true }); 460 defer tmp.cleanup(); 461 462 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 463 defer store.close(io); 464 465 var inbox = try store.inbox(io); 466 defer inbox.close(io); 467 _ = try inbox.deliver(io, message_text, .{}); 468 469 var sent = try store.createFolder(io, &.{"Sent"}); 470 defer sent.close(io); 471 _ = try sent.deliver(io, message_text, .{}); 472 473 // Two mailboxes, one message each, and neither can see the other's. 474 try testing.expectEqual(@as(usize, 1), try count(&inbox, .new)); 475 try testing.expectEqual(@as(usize, 1), try count(&sent, .new)); 476 477 // On disk the folder is a hidden directory in the top-level maildir. 478 const stat = try store.dir.statFile(io, ".Sent", .{}); 479 try testing.expectEqual(Io.File.Kind.directory, stat.kind); 480 // And it carries the marker that says it is a folder. 481 _ = try store.dir.statFile(io, ".Sent/maildirfolder", .{}); 482} 483 484test "a nested folder creates the folders above it" { 485 var tmp = testing.tmpDir(.{ .iterate = true }); 486 defer tmp.cleanup(); 487 488 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 489 defer store.close(io); 490 491 var reports = try store.createFolder(io, &.{ "Work", "Reports" }); 492 reports.close(io); 493 494 // `.Work.Reports` is a sibling of `.Work` on disk, not a child of it. 495 try testing.expect(try store.hasFolder(io, &.{"Work"})); 496 try testing.expect(try store.hasFolder(io, &.{ "Work", "Reports" })); 497 try testing.expect(!try store.hasFolder(io, &.{"Nothing"})); 498 499 var list = try store.folders(testing.allocator, io); 500 defer list.deinit(testing.allocator); 501 try testing.expectEqual(@as(usize, 2), list.names.len); 502 // Sorted, so a parent comes before its children. 503 try testing.expectEqualStrings(".Work", list.names[0]); 504 try testing.expectEqualStrings(".Work.Reports", list.names[1]); 505} 506 507test "a folder path with a delimiter of the caller's choosing" { 508 var tmp = testing.tmpDir(.{ .iterate = true }); 509 defer tmp.cleanup(); 510 511 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 512 defer store.close(io); 513 514 var created = try store.createFolderPath(io, "Work/Reports", '/'); 515 created.close(io); 516 517 var opened = try store.openFolderPath(io, "Work/Reports", '/'); 518 opened.close(io); 519 520 // The same folder, reached the other way. 521 var by_components = try store.openFolder(io, &.{ "Work", "Reports" }); 522 by_components.close(io); 523 524 // A name with a dot in it cannot be a Maildir++ folder, and saying so is 525 // better than creating the wrong one. 526 try testing.expectError( 527 error.InvalidComponent, 528 store.createFolder(io, &.{"example.com"}), 529 ); 530} 531 532test "deleting a folder with children needs saying so" { 533 var tmp = testing.tmpDir(.{ .iterate = true }); 534 defer tmp.cleanup(); 535 536 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 537 defer store.close(io); 538 539 var reports = try store.createFolder(io, &.{ "Work", "Reports" }); 540 _ = try reports.deliver(io, message_text, .{}); 541 reports.close(io); 542 543 try testing.expectError( 544 error.FolderNotEmpty, 545 store.deleteFolder(testing.allocator, io, &.{"Work"}, .{}), 546 ); 547 try testing.expect(try store.hasFolder(io, &.{ "Work", "Reports" })); 548 549 try store.deleteFolder(testing.allocator, io, &.{"Work"}, .{ .recursive = true }); 550 try testing.expect(!try store.hasFolder(io, &.{"Work"})); 551 try testing.expect(!try store.hasFolder(io, &.{ "Work", "Reports" })); 552} 553 554test "a folder whose name only looks like a child is not deleted with it" { 555 var tmp = testing.tmpDir(.{ .iterate = true }); 556 defer tmp.cleanup(); 557 558 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 559 defer store.close(io); 560 561 var work = try store.createFolder(io, &.{"Work"}); 562 work.close(io); 563 var workshop = try store.createFolder(io, &.{"Workshop"}); 564 workshop.close(io); 565 566 try store.deleteFolder(testing.allocator, io, &.{"Work"}, .{ .recursive = true }); 567 try testing.expect(!try store.hasFolder(io, &.{"Work"})); 568 try testing.expect(try store.hasFolder(io, &.{"Workshop"})); 569} 570 571test "moving a message between folders keeps its flags and changes its name" { 572 var tmp = testing.tmpDir(.{ .iterate = true }); 573 defer tmp.cleanup(); 574 575 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 576 defer store.close(io); 577 578 var inbox = try store.inbox(io); 579 defer inbox.close(io); 580 var archive = try store.createFolder(io, &.{"Archive"}); 581 defer archive.close(io); 582 583 var message = try inbox.deliver(io, message_text, .{}); 584 try message.setFlags(&inbox, io, .{ .seen = true, .replied = true }); 585 const before = try testing.allocator.dupe(u8, message.id()); 586 defer testing.allocator.free(before); 587 588 try message.moveTo(&inbox, &archive, io); 589 590 try testing.expectEqual(@as(usize, 0), try count(&inbox, .cur)); 591 try testing.expectEqual(@as(usize, 1), try count(&archive, .cur)); 592 // The flags travelled... 593 try testing.expect(message.flags().seen and message.flags().replied); 594 // ...and the size the name recorded is still true... 595 try testing.expectEqual(@as(?u64, message_text.len), message.name().size()); 596 // ...but the identity did not, because nothing coordinates names between 597 // two directories. 598 try testing.expect(!std.mem.eql(u8, before, message.id())); 599 600 const bytes = try message.read(&archive, testing.allocator, io, .unlimited); 601 defer testing.allocator.free(bytes); 602 try testing.expectEqualStrings(message_text, bytes); 603} 604 605test "a message moved out of new stays new" { 606 var tmp = testing.tmpDir(.{ .iterate = true }); 607 defer tmp.cleanup(); 608 609 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 610 defer store.close(io); 611 612 var inbox = try store.inbox(io); 613 defer inbox.close(io); 614 var other = try store.createFolder(io, &.{"Other"}); 615 defer other.close(io); 616 617 var message = try inbox.deliver(io, message_text, .{}); 618 try message.moveTo(&inbox, &other, io); 619 try testing.expectEqual(Maildir.Subdir.new, message.subdir); 620 try testing.expectEqual(@as(usize, 1), try count(&other, .new)); 621} 622 623// -- quota -------------------------------------------------------------------- 624 625test "a store with no quota file says so rather than failing" { 626 var tmp = testing.tmpDir(.{ .iterate = true }); 627 defer tmp.cleanup(); 628 629 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 630 defer store.close(io); 631 632 try testing.expectEqual( 633 @as(?maildir.quota.State, null), 634 try store.quotaState(testing.allocator, io), 635 ); 636 // Recording usage against a store with no quota is a no-op, not an error: 637 // a store nobody set a quota on is not one to start a ledger for. 638 try store.recordUsage(io, .{ .bytes = 100, .messages = 1 }); 639 try testing.expectEqual( 640 @as(?maildir.quota.State, null), 641 try store.quotaState(testing.allocator, io), 642 ); 643} 644 645test "the ledger totals the changes appended to it" { 646 var tmp = testing.tmpDir(.{ .iterate = true }); 647 defer tmp.cleanup(); 648 649 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 650 defer store.close(io); 651 652 try store.setQuota(io, .{ .bytes = 10485760, .messages = 1000 }, .zero); 653 654 try store.recordUsage(io, .{ .bytes = 4211, .messages = 1 }); 655 try store.recordUsage(io, .{ .bytes = 8320, .messages = 1 }); 656 try store.recordUsage(io, .{ .bytes = -4211, .messages = -1 }); 657 658 const state = (try store.quotaState(testing.allocator, io)).?; 659 try testing.expectEqual(@as(i64, 8320), state.usage.bytes); 660 try testing.expectEqual(@as(i64, 1), state.usage.messages); 661 try testing.expectEqual(@as(?u64, 10485760), state.limits.bytes); 662 // One line from `setQuota` and three appended. 663 try testing.expectEqual(@as(usize, 4), state.records); 664 try testing.expect(!state.exceeded()); 665 try testing.expect(!state.damaged); 666} 667 668test "over quota" { 669 var tmp = testing.tmpDir(.{ .iterate = true }); 670 defer tmp.cleanup(); 671 672 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 673 defer store.close(io); 674 675 try store.setQuota(io, .{ .bytes = 100 }, .{ .bytes = 500, .messages = 2 }); 676 const state = (try store.quotaState(testing.allocator, io)).?; 677 try testing.expect(state.exceeded()); 678} 679 680test "recalculating walks every folder and replaces the ledger" { 681 var tmp = testing.tmpDir(.{ .iterate = true }); 682 defer tmp.cleanup(); 683 684 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 685 defer store.close(io); 686 687 try store.setQuota(io, .{ .bytes = 10485760 }, .zero); 688 689 var inbox = try store.inbox(io); 690 defer inbox.close(io); 691 _ = try inbox.deliver(io, message_text, .{}); 692 _ = try inbox.deliver(io, message_text, .{ .to = .{ .cur = .{ .seen = true } } }); 693 694 var work = try store.createFolder(io, &.{"Work"}); 695 defer work.close(io); 696 _ = try work.deliver(io, message_text, .{}); 697 698 // A message in `tmp` is not a message and must not be counted. 699 const wreck = try inbox.tmp.createFile(io, "abandoned", .{}); 700 try wreck.writeStreamingAll(io, "x" ** 1000); 701 wreck.close(io); 702 703 const total = try store.recalculateQuota(testing.allocator, io); 704 try testing.expectEqual(@as(i64, 3), total.messages); 705 try testing.expectEqual(@as(i64, 3 * message_text.len), total.bytes); 706 707 // It was written back, the limits were kept, and the ledger is one line 708 // again. 709 const state = (try store.quotaState(testing.allocator, io)).?; 710 try testing.expectEqual(@as(?u64, 10485760), state.limits.bytes); 711 try testing.expectEqual(@as(i64, 3 * message_text.len), state.usage.bytes); 712 try testing.expectEqual(@as(usize, 1), state.records); 713} 714 715test "recalculating a store that never had a quota records the usage anyway" { 716 var tmp = testing.tmpDir(.{ .iterate = true }); 717 defer tmp.cleanup(); 718 719 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 720 defer store.close(io); 721 722 var inbox = try store.inbox(io); 723 defer inbox.close(io); 724 _ = try inbox.deliver(io, message_text, .{}); 725 726 _ = try store.recalculateQuota(testing.allocator, io); 727 const state = (try store.quotaState(testing.allocator, io)).?; 728 try testing.expect(!state.limits.isSet()); 729 try testing.expectEqual(@as(i64, 1), state.usage.messages); 730} 731 732test "a damaged ledger is reported rather than silently miscounted" { 733 var tmp = testing.tmpDir(.{ .iterate = true }); 734 defer tmp.cleanup(); 735 736 var store = try Store.create(tmp.dir, io, "Maildir", .{}); 737 defer store.close(io); 738 739 try store.dir.writeFile(io, .{ 740 .sub_path = "maildirsize", 741 .data = "1000S\n100 1\nnonsense\n50 1\n", 742 }); 743 744 const state = (try store.quotaState(testing.allocator, io)).?; 745 try testing.expect(state.damaged); 746 // The lines it could read still count. 747 try testing.expectEqual(@as(i64, 150), state.usage.bytes); 748 // And a damaged ledger always asks to be recomputed. 749 try testing.expect(state.isStale(io, .{})); 750} 751 752test "a separator other than a colon is used throughout" { 753 var tmp = testing.tmpDir(.{ .iterate = true }); 754 defer tmp.cleanup(); 755 756 var m = try Maildir.create(tmp.dir, io, "Maildir", .{ .separator = '!' }); 757 defer m.close(io); 758 759 var message = try m.deliver(io, message_text, .{}); 760 try message.setFlags(&m, io, .{ .seen = true }); 761 try testing.expect(std.mem.endsWith(u8, message.filename(), "!2,S")); 762 763 // And reading the maildir back with the same separator finds the flag. 764 var reopened = try Maildir.open(tmp.dir, io, "Maildir", .{ .separator = '!' }); 765 defer reopened.close(io); 766 var it = reopened.iterate(.cur); 767 const found = (try it.next(io)).?; 768 try testing.expect(found.flags().seen); 769 770 // Read with the default separator, the same file looks flagless -- which 771 // is exactly the corruption the option exists to avoid. 772 var misread = try Maildir.open(tmp.dir, io, "Maildir", .{}); 773 defer misread.close(io); 774 var misread_it = misread.iterate(.cur); 775 const misfound = (try misread_it.next(io)).?; 776 try testing.expect(!misfound.flags().seen); 777}