A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
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}