A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
21 kB
534 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! One maildir: a directory holding `tmp`, `new` and `cur`.
5//!
6//! The design is thirty years old and has one idea in it, which is that a
7//! message is a file whose *name* carries everything mutable about it. From
8//! that one idea everything else follows:
9//!
10//! * **Delivery takes no lock.** A message is written into `tmp` under a name
11//! nobody else will invent, and then renamed into `new`. A reader never
12//! sees a partial message because a message only appears in `new` once it
13//! is whole, and `rename` within a filesystem is atomic. Two mail servers,
14//! an IMAP daemon and a `procmail` can all deliver at once, over NFS, with
15//! nothing arbitrating between them.
16//! * **Reading is a directory listing.** There is no index to corrupt, no
17//! lock to hold while a slow client reads its mail, and no way for one
18//! crashed process to leave the mailbox unusable.
19//! * **Changing a flag is a `rename`.** Nothing is rewritten, so a message
20//! file is written exactly once and never modified — which is what lets
21//! `,S=` in the name be trusted, and what makes a maildir safe to back up
22//! while it is in use.
23//!
24//! The cost is that `tmp` accumulates the wreckage of interrupted deliveries,
25//! which is why the specification says to delete anything in there older than
26//! 36 hours, and why `cleanTemp` exists.
27//!
28//! ```zig
29//! var maildir: maildir_mod.Maildir = try .create(.cwd(), io, "Maildir", .{});
30//! defer maildir.close(io);
31//!
32//! _ = try maildir.deliver(io, "From: jeff@example.com\r\n\r\nHello.\r\n", .{});
33//!
34//! var it = maildir.iterate(.new);
35//! while (try it.next(io)) |message| {
36//! var m = message;
37//! try m.setFlags(&maildir, io, .{ .seen = true }); // and into `cur`
38//! }
39//! ```
40
41const std = @import("std");
42const Io = std.Io;
43const Dir = Io.Dir;
44const File = Io.File;
45const Allocator = std.mem.Allocator;
46const testing = std.testing;
47
48const Flags = @import("Flags.zig");
49const Name = @import("Name.zig");
50const unique = @import("unique.zig");
51const Message = @import("Message.zig");
52
53const Maildir = @This();
54
55/// The maildir itself, the directory holding the other three.
56dir: Dir,
57/// Delivered messages that nothing has looked at yet.
58new: Dir,
59/// Messages that have been seen by a reader, whose names carry flags.
60cur: Dir,
61/// Deliveries in progress. Nothing here is a message yet.
62tmp: Dir,
63/// The character between a message's unique part and its flags. See
64/// `Name.default_separator` for why this is not simply a colon.
65separator: u8,
66/// Where the unique part of a delivered message's name comes from.
67generator: unique.Generator,
68
69/// A buffer big enough for any name this library will write or read.
70pub const NameBuffer = [Dir.max_name_bytes]u8;
71
72/// Which of the three directories a message is in.
73pub const Subdir = enum {
74 tmp,
75 new,
76 cur,
77
78 pub fn dirname(self: Subdir) []const u8 {
79 return @tagName(self);
80 }
81};
82
83/// Mail is private, so the directories are created `rwx` for their owner and
84/// nothing for anybody else, rather than being left to whatever the process
85/// umask happens to be. A world-readable maildir is the sort of mistake that
86/// is only noticed afterwards.
87///
88/// Systems with no POSIX mode at all get their platform default, which is the
89/// most that can be said there.
90pub const private_dir: Dir.Permissions = if (@hasDecl(Dir.Permissions, "fromMode"))
91 Dir.Permissions.fromMode(0o700)
92else
93 .default_dir;
94
95/// The same reasoning for the message files themselves. `0o600` rather than
96/// `0o666`-and-umask.
97pub const private_file: File.Permissions = if (@hasDecl(File.Permissions, "fromMode"))
98 File.Permissions.fromMode(0o600)
99else
100 .default_file;
101
102pub const Options = struct {
103 /// The character before the info field. See `Name.default_separator`.
104 separator: u8 = Name.default_separator,
105 /// The host name that goes on the end of a delivered message's name.
106 /// Null asks the system for it, which is what a mail program on the
107 /// machine it delivers for wants.
108 hostname: ?[]const u8 = null,
109 /// The permissions the three directories are created with.
110 permissions: Dir.Permissions = private_dir,
111};
112
113pub const OpenError = Dir.OpenError || error{
114 /// The directory exists but is not a maildir: one or more of `tmp`, `new`
115 /// and `cur` is missing. Deliberately not the same error as a missing
116 /// directory, because delivering into a directory that merely looks like
117 /// a mailbox is how mail gets lost.
118 NotAMaildir,
119};
120
121/// Opens an existing maildir. Fails if `tmp`, `new` or `cur` is missing.
122pub fn open(parent: Dir, io: Io, sub_path: []const u8, options: Options) OpenError!Maildir {
123 const dir = try parent.openDir(io, sub_path, .{ .iterate = true });
124 errdefer dir.close(io);
125 return openDir(dir, io, options);
126}
127
128/// Like `open`, but takes a directory handle this maildir then owns and will
129/// close. The handle must have been opened with `.iterate = true`, since
130/// `Store` lists the folders beside it.
131pub fn openDir(dir: Dir, io: Io, options: Options) OpenError!Maildir {
132 var opened: usize = 0;
133 var handles: [3]Dir = undefined;
134 errdefer Dir.closeMany(io, handles[0..opened]);
135
136 for ([_]Subdir{ .tmp, .new, .cur }, 0..) |which, index| {
137 handles[index] = dir.openDir(io, which.dirname(), .{ .iterate = true }) catch |err| switch (err) {
138 error.FileNotFound, error.NotDir => return error.NotAMaildir,
139 else => |e| return e,
140 };
141 opened += 1;
142 }
143
144 var hostname_buffer: [unique.max_hostname]u8 = undefined;
145 return .{
146 .dir = dir,
147 .tmp = handles[0],
148 .new = handles[1],
149 .cur = handles[2],
150 .separator = options.separator,
151 .generator = .init(options.hostname orelse
152 unique.systemHostname(&hostname_buffer)),
153 };
154}
155
156pub const CreateError = Dir.CreateDirError || OpenError;
157
158/// Creates a maildir, or opens one that is already there.
159///
160/// The order matters and is the specification's: `tmp`, then `new`, then
161/// `cur`. A delivery finding `tmp` but not `new` would write a message it
162/// could not then deliver, so the directory a writer needs last is created
163/// last.
164pub fn create(parent: Dir, io: Io, sub_path: []const u8, options: Options) CreateError!Maildir {
165 parent.createDir(io, sub_path, options.permissions) catch |err| switch (err) {
166 error.PathAlreadyExists => {},
167 else => |e| return e,
168 };
169 const dir = try parent.openDir(io, sub_path, .{ .iterate = true });
170 errdefer dir.close(io);
171 try createSubdirs(dir, io, options.permissions);
172 return openDir(dir, io, options);
173}
174
175fn createSubdirs(dir: Dir, io: Io, permissions: Dir.Permissions) Dir.CreateDirError!void {
176 for ([_]Subdir{ .tmp, .new, .cur }) |which| {
177 dir.createDir(io, which.dirname(), permissions) catch |err| switch (err) {
178 error.PathAlreadyExists => {},
179 else => |e| return e,
180 };
181 }
182}
183
184/// Closes all four directory handles. The `Maildir` must not be used after
185/// this.
186pub fn close(self: *Maildir, io: Io) void {
187 Dir.closeMany(io, &.{ self.tmp, self.new, self.cur, self.dir });
188 self.* = undefined;
189}
190
191/// The handle for one of the three subdirectories.
192pub fn subdir(self: *const Maildir, which: Subdir) Dir {
193 return switch (which) {
194 .tmp => self.tmp,
195 .new => self.new,
196 .cur => self.cur,
197 };
198}
199
200// -- delivery ----------------------------------------------------------------
201
202/// Where a delivered message lands.
203pub const Destination = union(enum) {
204 /// `new`, with no flags. An ordinary delivery: something arrived and
205 /// nobody has looked at it.
206 new,
207 /// `cur`, with flags already set. This is what IMAP's `APPEND` does when
208 /// the client says the message is already read, and what a client does
209 /// when it saves a draft.
210 cur: Flags,
211};
212
213pub const DeliverOptions = struct {
214 to: Destination = .new,
215 /// Append `,S=<bytes>` to the name, so that the size can be had from a
216 /// directory listing. Dovecot and Courier both do this and both trust it,
217 /// which is safe only because a maildir message is never modified in
218 /// place.
219 record_size: bool = true,
220 /// Also append `,W=<bytes>`, the size the message would be with CRLF line
221 /// endings, which is the number IMAP reports. Off by default because it
222 /// means counting the line endings, and because a message that is already
223 /// CRLF makes it equal to `,S=`.
224 record_virtual_size: bool = false,
225 /// Ask the filesystem to put the message on the disk before it is named
226 /// in `new`. Without this a crash can leave a message that exists in the
227 /// directory and is empty on disk, which is worse than a message that
228 /// never arrived. Costs a round trip to the storage on every delivery.
229 sync: bool = true,
230 /// How many times to invent a new name when the one invented collides
231 /// with a file already in `tmp`.
232 attempts: usize = 10,
233};
234
235pub const DeliverError = File.OpenError || File.Writer.Error || File.SyncError ||
236 Dir.RenameError || error{
237 /// The buffered writer failed and could not say why, which should not
238 /// happen: `File.Writer` records the real error and `commit` returns
239 /// that instead wherever it is there.
240 WriteFailed,
241 /// `attempts` names in a row were already taken in `tmp`. Something
242 /// is wrong that retrying will not fix — a clock stuck at a value a
243 /// previous run also used, with `tmp` never cleaned.
244 NameCollision,
245 /// The generated name did not fit in `Dir.max_name_bytes`, which
246 /// means the host name is absurd.
247 NameTooLong,
248};
249
250/// Writes a message into the maildir and returns where it landed.
251///
252/// This is the whole protocol: a unique name, an exclusive create in `tmp`,
253/// the bytes, a sync, and a rename into `new` or `cur`. If anything fails
254/// before the rename the partial file is removed, so a failed delivery leaves
255/// nothing behind and — more to the point — never leaves half a message where
256/// a reader will find it.
257pub fn deliver(
258 self: *Maildir,
259 io: Io,
260 bytes: []const u8,
261 options: DeliverOptions,
262) DeliverError!Message {
263 var delivery = try self.beginDelivery(io, options.attempts);
264 errdefer delivery.abort(io);
265
266 try delivery.file.writeStreamingAll(io, bytes);
267 delivery.size = bytes.len;
268 if (options.record_virtual_size) delivery.virtual_size = virtualSizeOf(bytes);
269
270 return delivery.commit(io, options);
271}
272
273/// The size `bytes` would have if every line ended in CRLF: the length plus
274/// one for every LF that is not already preceded by a CR.
275pub fn virtualSizeOf(bytes: []const u8) u64 {
276 var total: u64 = bytes.len;
277 for (bytes, 0..) |c, index| {
278 if (c == '\n' and (index == 0 or bytes[index - 1] != '\r')) total += 1;
279 }
280 return total;
281}
282
283/// A message being written into `tmp`, not yet delivered.
284///
285/// Use this rather than `deliver` when the message is not already a slice —
286/// when it is being copied from a socket, or written by `mime.Message.write`,
287/// or is large enough that a second copy of it in memory is not wanted.
288///
289/// **The value must not be moved once `writer` has been called**, because
290/// what `writer` returns points into it.
291pub const Delivery = struct {
292 /// Where the temporary file lives, so that `abort` can remove it and
293 /// `commit` can rename it without being handed the maildir again.
294 maildir: *const Maildir,
295 file: File,
296 name_buffer: NameBuffer,
297 name_len: usize,
298 /// The size of the message, if the caller already knows it. `commit` asks
299 /// the file otherwise.
300 size: ?u64 = null,
301 /// The `,W=` size, if the caller knows it. Nothing here can work it out
302 /// for a streamed message without reading every byte a second time, so a
303 /// caller that wants the field recorded has to count as it writes.
304 virtual_size: ?u64 = null,
305 file_writer: ?File.Writer = null,
306 file_open: bool = true,
307 finished: bool = false,
308
309 /// The name the file has in `tmp`. Not the name it will have once it is
310 /// delivered, which gains the size fields and the flags.
311 pub fn tempName(self: *const Delivery) []const u8 {
312 return self.name_buffer[0..self.name_len];
313 }
314
315 /// A writer for the message body. `buffer` must outlive the delivery, and
316 /// the delivery must not be moved afterwards.
317 pub fn writer(self: *Delivery, io: Io, buffer: []u8) *Io.Writer {
318 self.file_writer = self.file.writer(io, buffer);
319 return &self.file_writer.?.interface;
320 }
321
322 /// Flushes, syncs, and renames the message into place; returns where it
323 /// landed. On failure the temporary file is removed, so a delivery either
324 /// produces a whole message or produces nothing.
325 pub fn commit(self: *Delivery, io: Io, options: DeliverOptions) DeliverError!Message {
326 std.debug.assert(!self.finished);
327 errdefer self.abort(io);
328
329 // `Io.Writer.flush` reports only that something failed; the error
330 // itself is kept on the `File.Writer` that produced it.
331 if (self.file_writer) |*fw| fw.interface.flush() catch
332 return fw.err orelse error.WriteFailed;
333 if (options.sync) try self.file.sync(io);
334
335 // A stat that fails costs the `,S=` field and nothing else, so it is
336 // not worth failing a delivery over.
337 const size = self.size orelse (self.file.length(io) catch null);
338
339 var final_buffer: NameBuffer = undefined;
340 var w: Io.Writer = .fixed(&final_buffer);
341 w.writeAll(self.tempName()) catch return error.NameTooLong;
342 if (options.record_size) if (size) |bytes| {
343 w.print(",S={d}", .{bytes}) catch return error.NameTooLong;
344 };
345 if (options.record_virtual_size) if (self.virtual_size) |bytes| {
346 w.print(",W={d}", .{bytes}) catch return error.NameTooLong;
347 };
348 const destination: Subdir = switch (options.to) {
349 .new => .new,
350 .cur => |flags| blk: {
351 w.writeByte(self.maildir.separator) catch return error.NameTooLong;
352 w.writeAll("2,") catch return error.NameTooLong;
353 flags.format(&w) catch return error.NameTooLong;
354 break :blk .cur;
355 },
356 };
357 const final_name = w.buffered();
358
359 // Closed before the rename rather than after, so that the file is on
360 // its way to the disk before anything can see it under its real name.
361 self.file.close(io);
362 self.file_open = false;
363
364 try self.maildir.tmp.rename(
365 self.tempName(),
366 self.maildir.subdir(destination),
367 final_name,
368 io,
369 );
370 self.finished = true;
371 return .init(destination, self.maildir.separator, final_name);
372 }
373
374 /// Throws the delivery away: closes the file and removes it from `tmp`.
375 /// Does nothing to a delivery that has already been committed or aborted,
376 /// so it is safe as an `errdefer` beside a `commit`.
377 pub fn abort(self: *Delivery, io: Io) void {
378 if (self.finished) return;
379 self.finished = true;
380 if (self.file_open) {
381 self.file.close(io);
382 self.file_open = false;
383 }
384 // If this fails the file is wreckage in `tmp`, which is what
385 // `cleanTemp` is for. There is nothing better to do about it here.
386 self.maildir.tmp.deleteFile(io, self.tempName()) catch {};
387 }
388};
389
390/// Creates the temporary file a message will be written into, inventing names
391/// until one of them is free.
392pub fn beginDelivery(self: *Maildir, io: Io, attempts: usize) DeliverError!Delivery {
393 var delivery: Delivery = .{
394 .maildir = self,
395 .file = undefined,
396 .name_buffer = undefined,
397 .name_len = 0,
398 };
399
400 var attempt: usize = 0;
401 while (attempt < @max(attempts, 1)) : (attempt += 1) {
402 const name = self.generator.bufNext(io, &delivery.name_buffer) catch
403 return error.NameTooLong;
404 delivery.name_len = name.len;
405
406 // Exclusive: the kernel refuses rather than truncating, so a name
407 // that has somehow been used before costs an attempt rather than
408 // somebody else's message.
409 delivery.file = self.tmp.createFile(io, name, .{
410 .exclusive = true,
411 .permissions = private_file,
412 }) catch |err| switch (err) {
413 error.PathAlreadyExists => continue,
414 else => |e| return e,
415 };
416 return delivery;
417 }
418 return error.NameCollision;
419}
420
421// -- reading -----------------------------------------------------------------
422
423/// Walks one of the subdirectories, yielding a `Message` for each file in it.
424///
425/// Entries whose names begin with a dot are skipped, which covers `.` and
426/// `..` and the marker files a Maildir++ store keeps beside its messages.
427/// Subdirectories are skipped too.
428pub const Iterator = struct {
429 inner: Dir.Iterator,
430 which: Subdir,
431 separator: u8,
432
433 pub const Error = Dir.Iterator.Error;
434
435 /// The next message, or null at the end. The returned `Message` owns its
436 /// name, so it stays valid after the following call.
437 pub fn next(self: *Iterator, io: Io) Error!?Message {
438 while (try self.inner.next(io)) |entry| {
439 if (entry.name.len == 0 or entry.name[0] == '.') continue;
440 switch (entry.kind) {
441 // `unknown` is what a filesystem that does not report a kind
442 // in its directory entries gives, and refusing those would
443 // make this library useless on them.
444 .file, .sym_link, .unknown => {},
445 else => continue,
446 }
447 if (entry.name.len > Dir.max_name_bytes) continue;
448 return .init(self.which, self.separator, entry.name);
449 }
450 return null;
451 }
452};
453
454pub fn iterate(self: *const Maildir, which: Subdir) Iterator {
455 return .{
456 .inner = self.subdir(which).iterate(),
457 .which = which,
458 .separator = self.separator,
459 };
460}
461
462pub const ListError = Iterator.Error || Allocator.Error;
463
464/// Every message in the given subdirectories, in one allocation-owning list.
465///
466/// Iteration is the cheaper way to walk a maildir and should be preferred,
467/// but a caller that needs to sort the messages, or to know how many there
468/// are before it starts, needs them all at once. The order is the
469/// filesystem's, which is not chronological.
470pub fn list(
471 self: *const Maildir,
472 gpa: Allocator,
473 io: Io,
474 which: []const Subdir,
475) ListError![]Message {
476 var messages: std.ArrayList(Message) = .empty;
477 defer messages.deinit(gpa);
478 for (which) |subdir_kind| {
479 var it = self.iterate(subdir_kind);
480 while (try it.next(io)) |message| try messages.append(gpa, message);
481 }
482 return messages.toOwnedSlice(gpa);
483}
484
485/// Looks for a message by the unchanging part of its name — `Name.base`,
486/// which survives every flag change — in `new` and then in `cur`.
487///
488/// This is how a program that remembered a message finds it again, and it is
489/// a linear scan because a maildir has no index. A caller doing this for
490/// every message of many should list the directory once instead.
491pub fn find(self: *const Maildir, io: Io, base: []const u8) Iterator.Error!?Message {
492 for ([_]Subdir{ .new, .cur }) |which| {
493 var it = self.iterate(which);
494 while (try it.next(io)) |message| {
495 if (std.mem.eql(u8, message.id(), base)) return message;
496 }
497 }
498 return null;
499}
500
501pub const CleanTempError = Iterator.Error || Dir.DeleteFileError;
502
503/// Deletes anything in `tmp` older than `max_age`, which is the housekeeping
504/// the specification asks for: a delivery that died between creating its file
505/// and renaming it leaves that file behind forever otherwise.
506///
507/// Thirty-six hours is the specified threshold, and it is long for a reason —
508/// it must exceed the longest a legitimate delivery could take, since
509/// deleting a file out from under a delivery in progress loses the message.
510///
511/// Returns how many files were removed.
512pub fn cleanTemp(self: *const Maildir, io: Io, max_age: Io.Duration) CleanTempError!usize {
513 const now = Io.Timestamp.now(io, .real);
514 var removed: usize = 0;
515 var it = self.tmp.iterate();
516 while (try it.next(io)) |entry| {
517 if (entry.name.len == 0 or entry.name[0] == '.') continue;
518 const stat = self.tmp.statFile(io, entry.name, .{}) catch continue;
519 if (stat.mtime.durationTo(now).nanoseconds < max_age.nanoseconds) continue;
520 self.tmp.deleteFile(io, entry.name) catch |err| switch (err) {
521 error.FileNotFound => continue,
522 else => |e| return e,
523 };
524 removed += 1;
525 }
526 return removed;
527}
528
529/// The age at which the specification says a file in `tmp` is wreckage.
530pub const temp_max_age: Io.Duration = .{ .nanoseconds = 36 * 60 * 60 * std.time.ns_per_s };
531
532test {
533 _ = Message;
534}