// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! What the library does to a real directory. //! //! Everything in `src` that can be tested without touching a filesystem is //! tested beside the code it tests; this is the rest, and the rest is most of //! what a maildir *is*. A name parser that round trips proves nothing about //! whether a delivery is atomic, whether a flag change moves the file from //! `new` to `cur`, or whether the quota ledger adds up — those are claims //! about directories, and they need directories. //! //! These are a module of their own so that `zig build test` runs them while a //! consumer of the library never compiles them. const std = @import("std"); const Io = std.Io; const Dir = Io.Dir; const testing = std.testing; const io = testing.io; const maildir = @import("maildir"); const Maildir = maildir.Maildir; const Store = maildir.Store; const Flags = maildir.Flags; const message_text = "From: jeff@example.com\r\n" ++ "To: someone@example.net\r\n" ++ "Subject: A test\r\n" ++ "\r\n" ++ "Hello.\r\n"; /// Counts what is in one of the three subdirectories, skipping the dotfiles /// the way the library does. fn count(m: *const Maildir, which: Maildir.Subdir) !usize { var total: usize = 0; var it = m.iterate(which); while (try it.next(io)) |_| total += 1; return total; } test "create makes the three directories, and open insists on them" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); m.close(io); // Opening it again works, and finds the same three. var reopened = try Maildir.open(tmp.dir, io, "Maildir", .{}); reopened.close(io); // A directory that is not a maildir is refused, and with an error that // says which problem it is: delivering into a directory that merely looks // like a mailbox is how mail gets lost. try tmp.dir.createDir(io, "NotAMaildir", .default_dir); try testing.expectError( error.NotAMaildir, Maildir.open(tmp.dir, io, "NotAMaildir", .{}), ); try testing.expectError( error.FileNotFound, Maildir.open(tmp.dir, io, "NoSuchThing", .{}), ); } test "create is idempotent" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var first = try Maildir.create(tmp.dir, io, "Maildir", .{}); _ = try first.deliver(io, message_text, .{}); first.close(io); var second = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer second.close(io); // The message that was already there is still there. try testing.expectEqual(@as(usize, 1), try count(&second, .new)); } test "a delivered message lands in new, whole, and leaves tmp empty" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{ .hostname = "test.example" }); defer m.close(io); const delivered = try m.deliver(io, message_text, .{}); try testing.expectEqual(Maildir.Subdir.new, delivered.subdir); try testing.expectEqual(Flags.none, delivered.flags()); // The whole point of the tmp/new dance: nothing is left behind, and what // arrived in `new` is the whole message. try testing.expectEqual(@as(usize, 0), try count(&m, .tmp)); try testing.expectEqual(@as(usize, 1), try count(&m, .new)); const bytes = try delivered.read(&m, testing.allocator, io, .unlimited); defer testing.allocator.free(bytes); try testing.expectEqualStrings(message_text, bytes); } test "the name carries the host, the size, and no flags" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{ .hostname = "test.example" }); defer m.close(io); const delivered = try m.deliver(io, message_text, .{}); const name = delivered.name(); // The host is the end of the identifying part. `unique` is longer than // that, because the size field is appended to it. try testing.expect(std.mem.endsWith(u8, name.base(), "test.example")); try testing.expect(std.mem.endsWith(u8, name.unique, ",S=76")); try testing.expectEqual(@as(?u64, message_text.len), name.size()); try testing.expectEqual(maildir.Name.Info.none, name.info); // And the size in the name is the size on disk, which is the whole reason // anything is allowed to trust it. try testing.expectEqual( @as(u64, message_text.len), try delivered.size(&m, io), ); } test "record_size off leaves the size out rather than guessing it" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); const delivered = try m.deliver(io, message_text, .{ .record_size = false }); try testing.expectEqual(@as(?u64, null), delivered.name().size()); // It still knows how big the message is; it just has to ask the // filesystem. try testing.expectEqual(@as(u64, message_text.len), try delivered.size(&m, io)); } test "the virtual size counts the line endings a bare-LF message is missing" { const unix_text = "From: a@b\n\nOne\nTwo\n"; try testing.expectEqual( @as(u64, unix_text.len + 4), Maildir.virtualSizeOf(unix_text), ); // A message that is already CRLF needs nothing added. try testing.expectEqual( @as(u64, message_text.len), Maildir.virtualSizeOf(message_text), ); var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); const delivered = try m.deliver(io, unix_text, .{ .record_virtual_size = true }); try testing.expectEqual(@as(?u64, unix_text.len), delivered.name().size()); try testing.expectEqual(@as(?u64, unix_text.len + 4), delivered.name().virtualSize()); } test "delivering into cur with flags is what an IMAP append does" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); const delivered = try m.deliver(io, message_text, .{ .to = .{ .cur = .{ .seen = true, .draft = true } }, }); try testing.expectEqual(Maildir.Subdir.cur, delivered.subdir); try testing.expect(delivered.flags().seen and delivered.flags().draft); try testing.expectEqual(@as(usize, 0), try count(&m, .new)); try testing.expectEqual(@as(usize, 1), try count(&m, .cur)); // `D` sorts before `S`, and the flags come after the size field. try testing.expect(std.mem.endsWith(u8, delivered.filename(), ":2,DS")); } test "two deliveries in a row do not collide" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); var seen: std.StringHashMapUnmanaged(void) = .empty; defer { var it = seen.keyIterator(); while (it.next()) |key| testing.allocator.free(key.*); seen.deinit(testing.allocator); } for (0..200) |_| { const delivered = try m.deliver(io, message_text, .{ .sync = false }); const name = try testing.allocator.dupe(u8, delivered.filename()); errdefer testing.allocator.free(name); try testing.expect(!seen.contains(name)); try seen.put(testing.allocator, name, {}); } try testing.expectEqual(@as(usize, 200), try count(&m, .new)); } test "a streamed delivery writes the same message as a sliced one" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); var buffer: [64]u8 = undefined; // deliberately smaller than the message var delivery = try m.beginDelivery(io, 10); errdefer delivery.abort(io); const w = delivery.writer(io, &buffer); try w.writeAll(message_text[0..10]); try w.writeAll(message_text[10..]); const delivered = try delivery.commit(io, .{}); const bytes = try delivered.read(&m, testing.allocator, io, .unlimited); defer testing.allocator.free(bytes); try testing.expectEqualStrings(message_text, bytes); // The size was not given, so `commit` asked the file. try testing.expectEqual(@as(?u64, message_text.len), delivered.name().size()); } test "an aborted delivery leaves nothing anywhere" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); var delivery = try m.beginDelivery(io, 10); var buffer: [64]u8 = undefined; const w = delivery.writer(io, &buffer); try w.writeAll("half a mess"); delivery.abort(io); try testing.expectEqual(@as(usize, 0), try count(&m, .tmp)); try testing.expectEqual(@as(usize, 0), try count(&m, .new)); // Aborting twice, or aborting a committed delivery, is harmless -- which // is what lets it be an errdefer beside a commit. delivery.abort(io); } test "setting a flag moves the message from new to cur" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); var message = try m.deliver(io, message_text, .{}); const id = try testing.allocator.dupe(u8, message.id()); defer testing.allocator.free(id); try message.setFlags(&m, io, .{ .seen = true }); // There is nowhere in `new` for a flag to be written, so marking a // message read necessarily moves it. try testing.expectEqual(Maildir.Subdir.cur, message.subdir); try testing.expectEqual(@as(usize, 0), try count(&m, .new)); try testing.expectEqual(@as(usize, 1), try count(&m, .cur)); try testing.expect(message.flags().seen); // And the thing that identifies the message did not change, which is what // makes it the same message. try testing.expectEqualStrings(id, message.id()); // The file really is under the new name and not the old one. const bytes = try message.read(&m, testing.allocator, io, .unlimited); defer testing.allocator.free(bytes); try testing.expectEqualStrings(message_text, bytes); } test "adding and removing flags leaves the others alone" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); var message = try m.deliver(io, message_text, .{}); try message.setFlags(&m, io, .{ .seen = true, .flagged = true }); try message.addFlags(&m, io, .{ .replied = true }); try testing.expect(message.flags().seen and message.flags().flagged and message.flags().replied); try message.removeFlags(&m, io, .{ .flagged = true }); try testing.expect(message.flags().seen and message.flags().replied); try testing.expect(!message.flags().flagged); try testing.expect(std.mem.endsWith(u8, message.filename(), ":2,RS")); } test "a keyword another program set survives a flag change" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); // Deliver with a Dovecot keyword already on it, as Dovecot would. var flagged: Flags = .{ .seen = true }; flagged.setLetter('b', true); var message = try m.deliver(io, message_text, .{ .to = .{ .cur = flagged } }); try message.addFlags(&m, io, .{ .replied = true }); try testing.expect(message.flags().hasLetter('b')); try testing.expect(std.mem.endsWith(u8, message.filename(), ":2,RSb")); } test "moveToCur files a message without claiming anybody read it" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); var message = try m.deliver(io, message_text, .{}); try message.moveToCur(&m, io); try testing.expectEqual(Maildir.Subdir.cur, message.subdir); try testing.expectEqual(Flags.none, message.flags()); try testing.expect(std.mem.endsWith(u8, message.filename(), ":2,")); // Doing it again is a no-op rather than a second rename. const before = try testing.allocator.dupe(u8, message.filename()); defer testing.allocator.free(before); try message.moveToCur(&m, io); try testing.expectEqualStrings(before, message.filename()); } test "find locates a message again after its flags have changed" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); var message = try m.deliver(io, message_text, .{}); const id = try testing.allocator.dupe(u8, message.id()); defer testing.allocator.free(id); try testing.expect((try m.find(io, id)) != null); try message.setFlags(&m, io, .{ .seen = true, .replied = true }); const found = (try m.find(io, id)).?; try testing.expectEqual(Maildir.Subdir.cur, found.subdir); try testing.expect(found.flags().seen); try testing.expectEqual(@as(?maildir.Message, null), try m.find(io, "not-a-message")); } test "remove is the expunge that trashed only asks for" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); var message = try m.deliver(io, message_text, .{}); try message.addFlags(&m, io, .{ .trashed = true }); // Marked, and still there. try testing.expectEqual(@as(usize, 1), try count(&m, .cur)); try message.remove(&m, io); try testing.expectEqual(@as(usize, 0), try count(&m, .cur)); } test "list gathers both subdirectories" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); _ = try m.deliver(io, message_text, .{}); _ = try m.deliver(io, message_text, .{}); _ = try m.deliver(io, message_text, .{ .to = .{ .cur = .{ .seen = true } } }); const all = try m.list(testing.allocator, io, &.{ .new, .cur }); defer testing.allocator.free(all); try testing.expectEqual(@as(usize, 3), all.len); const just_new = try m.list(testing.allocator, io, &.{.new}); defer testing.allocator.free(just_new); try testing.expectEqual(@as(usize, 2), just_new.len); } test "cleanTemp removes old wreckage and leaves a delivery in progress alone" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); // A delivery that died: a file in `tmp` nobody will ever rename. const wreck = try m.tmp.createFile(io, "abandoned", .{}); wreck.close(io); // With the specified threshold it is far too young to touch, which is the // property that matters -- deleting a file out from under a delivery in // progress loses the message. try testing.expectEqual(@as(usize, 0), try m.cleanTemp(io, Maildir.temp_max_age)); // With no grace period at all it goes. try testing.expectEqual(@as(usize, 1), try m.cleanTemp(io, .{ .nanoseconds = 0 })); try testing.expectEqual(@as(usize, 0), try count(&m, .tmp)); } test "a message parses through zig-mime" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); const delivered = try m.deliver(io, message_text, .{}); var parsed = try delivered.parse(&m, testing.allocator, io, .unlimited, .{}); defer parsed.deinit(); try testing.expectEqualStrings("A test", (try parsed.root.subject()).?); var from = try parsed.root.addresses(testing.allocator, "from"); defer from.deinit(testing.allocator); try testing.expectEqual(@as(usize, 1), from.addresses.len); } test "a message written by zig-mime is delivered and reads back the same" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{}); defer m.close(io); const mime = @import("mime"); var built: mime.Message = try .init(testing.allocator); defer built.deinit(); try built.root.setSubject("Grüße"); try built.setText("Schöne Grüße.\n", .{}); // The streaming delivery exists for exactly this: the message is written // straight into `tmp` rather than into a buffer and then into `tmp`. var buffer: [4096]u8 = undefined; var delivery = try m.beginDelivery(io, 10); errdefer delivery.abort(io); try built.write(delivery.writer(io, &buffer)); const delivered = try delivery.commit(io, .{}); var parsed = try delivered.parse(&m, testing.allocator, io, .unlimited, .{}); defer parsed.deinit(); try testing.expectEqualStrings("Grüße", (try parsed.root.subject()).?); } // -- Maildir++ ---------------------------------------------------------------- test "a store has an inbox and folders beside it" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); var inbox = try store.inbox(io); defer inbox.close(io); _ = try inbox.deliver(io, message_text, .{}); var sent = try store.createFolder(io, &.{"Sent"}); defer sent.close(io); _ = try sent.deliver(io, message_text, .{}); // Two mailboxes, one message each, and neither can see the other's. try testing.expectEqual(@as(usize, 1), try count(&inbox, .new)); try testing.expectEqual(@as(usize, 1), try count(&sent, .new)); // On disk the folder is a hidden directory in the top-level maildir. const stat = try store.dir.statFile(io, ".Sent", .{}); try testing.expectEqual(Io.File.Kind.directory, stat.kind); // And it carries the marker that says it is a folder. _ = try store.dir.statFile(io, ".Sent/maildirfolder", .{}); } test "a nested folder creates the folders above it" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); var reports = try store.createFolder(io, &.{ "Work", "Reports" }); reports.close(io); // `.Work.Reports` is a sibling of `.Work` on disk, not a child of it. try testing.expect(try store.hasFolder(io, &.{"Work"})); try testing.expect(try store.hasFolder(io, &.{ "Work", "Reports" })); try testing.expect(!try store.hasFolder(io, &.{"Nothing"})); var list = try store.folders(testing.allocator, io); defer list.deinit(testing.allocator); try testing.expectEqual(@as(usize, 2), list.names.len); // Sorted, so a parent comes before its children. try testing.expectEqualStrings(".Work", list.names[0]); try testing.expectEqualStrings(".Work.Reports", list.names[1]); } test "a folder path with a delimiter of the caller's choosing" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); var created = try store.createFolderPath(io, "Work/Reports", '/'); created.close(io); var opened = try store.openFolderPath(io, "Work/Reports", '/'); opened.close(io); // The same folder, reached the other way. var by_components = try store.openFolder(io, &.{ "Work", "Reports" }); by_components.close(io); // A name with a dot in it cannot be a Maildir++ folder, and saying so is // better than creating the wrong one. try testing.expectError( error.InvalidComponent, store.createFolder(io, &.{"example.com"}), ); } test "deleting a folder with children needs saying so" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); var reports = try store.createFolder(io, &.{ "Work", "Reports" }); _ = try reports.deliver(io, message_text, .{}); reports.close(io); try testing.expectError( error.FolderNotEmpty, store.deleteFolder(testing.allocator, io, &.{"Work"}, .{}), ); try testing.expect(try store.hasFolder(io, &.{ "Work", "Reports" })); try store.deleteFolder(testing.allocator, io, &.{"Work"}, .{ .recursive = true }); try testing.expect(!try store.hasFolder(io, &.{"Work"})); try testing.expect(!try store.hasFolder(io, &.{ "Work", "Reports" })); } test "a folder whose name only looks like a child is not deleted with it" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); var work = try store.createFolder(io, &.{"Work"}); work.close(io); var workshop = try store.createFolder(io, &.{"Workshop"}); workshop.close(io); try store.deleteFolder(testing.allocator, io, &.{"Work"}, .{ .recursive = true }); try testing.expect(!try store.hasFolder(io, &.{"Work"})); try testing.expect(try store.hasFolder(io, &.{"Workshop"})); } test "moving a message between folders keeps its flags and changes its name" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); var inbox = try store.inbox(io); defer inbox.close(io); var archive = try store.createFolder(io, &.{"Archive"}); defer archive.close(io); var message = try inbox.deliver(io, message_text, .{}); try message.setFlags(&inbox, io, .{ .seen = true, .replied = true }); const before = try testing.allocator.dupe(u8, message.id()); defer testing.allocator.free(before); try message.moveTo(&inbox, &archive, io); try testing.expectEqual(@as(usize, 0), try count(&inbox, .cur)); try testing.expectEqual(@as(usize, 1), try count(&archive, .cur)); // The flags travelled... try testing.expect(message.flags().seen and message.flags().replied); // ...and the size the name recorded is still true... try testing.expectEqual(@as(?u64, message_text.len), message.name().size()); // ...but the identity did not, because nothing coordinates names between // two directories. try testing.expect(!std.mem.eql(u8, before, message.id())); const bytes = try message.read(&archive, testing.allocator, io, .unlimited); defer testing.allocator.free(bytes); try testing.expectEqualStrings(message_text, bytes); } test "a message moved out of new stays new" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); var inbox = try store.inbox(io); defer inbox.close(io); var other = try store.createFolder(io, &.{"Other"}); defer other.close(io); var message = try inbox.deliver(io, message_text, .{}); try message.moveTo(&inbox, &other, io); try testing.expectEqual(Maildir.Subdir.new, message.subdir); try testing.expectEqual(@as(usize, 1), try count(&other, .new)); } // -- quota -------------------------------------------------------------------- test "a store with no quota file says so rather than failing" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); try testing.expectEqual( @as(?maildir.quota.State, null), try store.quotaState(testing.allocator, io), ); // Recording usage against a store with no quota is a no-op, not an error: // a store nobody set a quota on is not one to start a ledger for. try store.recordUsage(io, .{ .bytes = 100, .messages = 1 }); try testing.expectEqual( @as(?maildir.quota.State, null), try store.quotaState(testing.allocator, io), ); } test "the ledger totals the changes appended to it" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); try store.setQuota(io, .{ .bytes = 10485760, .messages = 1000 }, .zero); try store.recordUsage(io, .{ .bytes = 4211, .messages = 1 }); try store.recordUsage(io, .{ .bytes = 8320, .messages = 1 }); try store.recordUsage(io, .{ .bytes = -4211, .messages = -1 }); const state = (try store.quotaState(testing.allocator, io)).?; try testing.expectEqual(@as(i64, 8320), state.usage.bytes); try testing.expectEqual(@as(i64, 1), state.usage.messages); try testing.expectEqual(@as(?u64, 10485760), state.limits.bytes); // One line from `setQuota` and three appended. try testing.expectEqual(@as(usize, 4), state.records); try testing.expect(!state.exceeded()); try testing.expect(!state.damaged); } test "over quota" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); try store.setQuota(io, .{ .bytes = 100 }, .{ .bytes = 500, .messages = 2 }); const state = (try store.quotaState(testing.allocator, io)).?; try testing.expect(state.exceeded()); } test "recalculating walks every folder and replaces the ledger" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); try store.setQuota(io, .{ .bytes = 10485760 }, .zero); var inbox = try store.inbox(io); defer inbox.close(io); _ = try inbox.deliver(io, message_text, .{}); _ = try inbox.deliver(io, message_text, .{ .to = .{ .cur = .{ .seen = true } } }); var work = try store.createFolder(io, &.{"Work"}); defer work.close(io); _ = try work.deliver(io, message_text, .{}); // A message in `tmp` is not a message and must not be counted. const wreck = try inbox.tmp.createFile(io, "abandoned", .{}); try wreck.writeStreamingAll(io, "x" ** 1000); wreck.close(io); const total = try store.recalculateQuota(testing.allocator, io); try testing.expectEqual(@as(i64, 3), total.messages); try testing.expectEqual(@as(i64, 3 * message_text.len), total.bytes); // It was written back, the limits were kept, and the ledger is one line // again. const state = (try store.quotaState(testing.allocator, io)).?; try testing.expectEqual(@as(?u64, 10485760), state.limits.bytes); try testing.expectEqual(@as(i64, 3 * message_text.len), state.usage.bytes); try testing.expectEqual(@as(usize, 1), state.records); } test "recalculating a store that never had a quota records the usage anyway" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); var inbox = try store.inbox(io); defer inbox.close(io); _ = try inbox.deliver(io, message_text, .{}); _ = try store.recalculateQuota(testing.allocator, io); const state = (try store.quotaState(testing.allocator, io)).?; try testing.expect(!state.limits.isSet()); try testing.expectEqual(@as(i64, 1), state.usage.messages); } test "a damaged ledger is reported rather than silently miscounted" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var store = try Store.create(tmp.dir, io, "Maildir", .{}); defer store.close(io); try store.dir.writeFile(io, .{ .sub_path = "maildirsize", .data = "1000S\n100 1\nnonsense\n50 1\n", }); const state = (try store.quotaState(testing.allocator, io)).?; try testing.expect(state.damaged); // The lines it could read still count. try testing.expectEqual(@as(i64, 150), state.usage.bytes); // And a damaged ledger always asks to be recomputed. try testing.expect(state.isStale(io, .{})); } test "a separator other than a colon is used throughout" { var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var m = try Maildir.create(tmp.dir, io, "Maildir", .{ .separator = '!' }); defer m.close(io); var message = try m.deliver(io, message_text, .{}); try message.setFlags(&m, io, .{ .seen = true }); try testing.expect(std.mem.endsWith(u8, message.filename(), "!2,S")); // And reading the maildir back with the same separator finds the flag. var reopened = try Maildir.open(tmp.dir, io, "Maildir", .{ .separator = '!' }); defer reopened.close(io); var it = reopened.iterate(.cur); const found = (try it.next(io)).?; try testing.expect(found.flags().seen); // Read with the default separator, the same file looks flagless -- which // is exactly the corruption the option exists to avoid. var misread = try Maildir.open(tmp.dir, io, "Maildir", .{}); defer misread.close(io); var misread_it = misread.iterate(.cur); const misfound = (try misread_it.next(io)).?; try testing.expect(!misfound.flags().seen); }