// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! One maildir: a directory holding `tmp`, `new` and `cur`. //! //! The design is thirty years old and has one idea in it, which is that a //! message is a file whose *name* carries everything mutable about it. From //! that one idea everything else follows: //! //! * **Delivery takes no lock.** A message is written into `tmp` under a name //! nobody else will invent, and then renamed into `new`. A reader never //! sees a partial message because a message only appears in `new` once it //! is whole, and `rename` within a filesystem is atomic. Two mail servers, //! an IMAP daemon and a `procmail` can all deliver at once, over NFS, with //! nothing arbitrating between them. //! * **Reading is a directory listing.** There is no index to corrupt, no //! lock to hold while a slow client reads its mail, and no way for one //! crashed process to leave the mailbox unusable. //! * **Changing a flag is a `rename`.** Nothing is rewritten, so a message //! file is written exactly once and never modified — which is what lets //! `,S=` in the name be trusted, and what makes a maildir safe to back up //! while it is in use. //! //! The cost is that `tmp` accumulates the wreckage of interrupted deliveries, //! which is why the specification says to delete anything in there older than //! 36 hours, and why `cleanTemp` exists. //! //! ```zig //! var maildir: maildir_mod.Maildir = try .create(.cwd(), io, "Maildir", .{}); //! defer maildir.close(io); //! //! _ = try maildir.deliver(io, "From: jeff@example.com\r\n\r\nHello.\r\n", .{}); //! //! var it = maildir.iterate(.new); //! while (try it.next(io)) |message| { //! var m = message; //! try m.setFlags(&maildir, io, .{ .seen = true }); // and into `cur` //! } //! ``` const std = @import("std"); const Io = std.Io; const Dir = Io.Dir; const File = Io.File; const Allocator = std.mem.Allocator; const testing = std.testing; const Flags = @import("Flags.zig"); const Name = @import("Name.zig"); const unique = @import("unique.zig"); const Message = @import("Message.zig"); const Maildir = @This(); /// The maildir itself, the directory holding the other three. dir: Dir, /// Delivered messages that nothing has looked at yet. new: Dir, /// Messages that have been seen by a reader, whose names carry flags. cur: Dir, /// Deliveries in progress. Nothing here is a message yet. tmp: Dir, /// The character between a message's unique part and its flags. See /// `Name.default_separator` for why this is not simply a colon. separator: u8, /// Where the unique part of a delivered message's name comes from. generator: unique.Generator, /// A buffer big enough for any name this library will write or read. pub const NameBuffer = [Dir.max_name_bytes]u8; /// Which of the three directories a message is in. pub const Subdir = enum { tmp, new, cur, pub fn dirname(self: Subdir) []const u8 { return @tagName(self); } }; /// Mail is private, so the directories are created `rwx` for their owner and /// nothing for anybody else, rather than being left to whatever the process /// umask happens to be. A world-readable maildir is the sort of mistake that /// is only noticed afterwards. /// /// Systems with no POSIX mode at all get their platform default, which is the /// most that can be said there. pub const private_dir: Dir.Permissions = if (@hasDecl(Dir.Permissions, "fromMode")) Dir.Permissions.fromMode(0o700) else .default_dir; /// The same reasoning for the message files themselves. `0o600` rather than /// `0o666`-and-umask. pub const private_file: File.Permissions = if (@hasDecl(File.Permissions, "fromMode")) File.Permissions.fromMode(0o600) else .default_file; pub const Options = struct { /// The character before the info field. See `Name.default_separator`. separator: u8 = Name.default_separator, /// The host name that goes on the end of a delivered message's name. /// Null asks the system for it, which is what a mail program on the /// machine it delivers for wants. hostname: ?[]const u8 = null, /// The permissions the three directories are created with. permissions: Dir.Permissions = private_dir, }; pub const OpenError = Dir.OpenError || error{ /// The directory exists but is not a maildir: one or more of `tmp`, `new` /// and `cur` is missing. Deliberately not the same error as a missing /// directory, because delivering into a directory that merely looks like /// a mailbox is how mail gets lost. NotAMaildir, }; /// Opens an existing maildir. Fails if `tmp`, `new` or `cur` is missing. pub fn open(parent: Dir, io: Io, sub_path: []const u8, options: Options) OpenError!Maildir { const dir = try parent.openDir(io, sub_path, .{ .iterate = true }); errdefer dir.close(io); return openDir(dir, io, options); } /// Like `open`, but takes a directory handle this maildir then owns and will /// close. The handle must have been opened with `.iterate = true`, since /// `Store` lists the folders beside it. pub fn openDir(dir: Dir, io: Io, options: Options) OpenError!Maildir { var opened: usize = 0; var handles: [3]Dir = undefined; errdefer Dir.closeMany(io, handles[0..opened]); for ([_]Subdir{ .tmp, .new, .cur }, 0..) |which, index| { handles[index] = dir.openDir(io, which.dirname(), .{ .iterate = true }) catch |err| switch (err) { error.FileNotFound, error.NotDir => return error.NotAMaildir, else => |e| return e, }; opened += 1; } var hostname_buffer: [unique.max_hostname]u8 = undefined; return .{ .dir = dir, .tmp = handles[0], .new = handles[1], .cur = handles[2], .separator = options.separator, .generator = .init(options.hostname orelse unique.systemHostname(&hostname_buffer)), }; } pub const CreateError = Dir.CreateDirError || OpenError; /// Creates a maildir, or opens one that is already there. /// /// The order matters and is the specification's: `tmp`, then `new`, then /// `cur`. A delivery finding `tmp` but not `new` would write a message it /// could not then deliver, so the directory a writer needs last is created /// last. pub fn create(parent: Dir, io: Io, sub_path: []const u8, options: Options) CreateError!Maildir { parent.createDir(io, sub_path, options.permissions) catch |err| switch (err) { error.PathAlreadyExists => {}, else => |e| return e, }; const dir = try parent.openDir(io, sub_path, .{ .iterate = true }); errdefer dir.close(io); try createSubdirs(dir, io, options.permissions); return openDir(dir, io, options); } fn createSubdirs(dir: Dir, io: Io, permissions: Dir.Permissions) Dir.CreateDirError!void { for ([_]Subdir{ .tmp, .new, .cur }) |which| { dir.createDir(io, which.dirname(), permissions) catch |err| switch (err) { error.PathAlreadyExists => {}, else => |e| return e, }; } } /// Closes all four directory handles. The `Maildir` must not be used after /// this. pub fn close(self: *Maildir, io: Io) void { Dir.closeMany(io, &.{ self.tmp, self.new, self.cur, self.dir }); self.* = undefined; } /// The handle for one of the three subdirectories. pub fn subdir(self: *const Maildir, which: Subdir) Dir { return switch (which) { .tmp => self.tmp, .new => self.new, .cur => self.cur, }; } // -- delivery ---------------------------------------------------------------- /// Where a delivered message lands. pub const Destination = union(enum) { /// `new`, with no flags. An ordinary delivery: something arrived and /// nobody has looked at it. new, /// `cur`, with flags already set. This is what IMAP's `APPEND` does when /// the client says the message is already read, and what a client does /// when it saves a draft. cur: Flags, }; pub const DeliverOptions = struct { to: Destination = .new, /// Append `,S=` to the name, so that the size can be had from a /// directory listing. Dovecot and Courier both do this and both trust it, /// which is safe only because a maildir message is never modified in /// place. record_size: bool = true, /// Also append `,W=`, the size the message would be with CRLF line /// endings, which is the number IMAP reports. Off by default because it /// means counting the line endings, and because a message that is already /// CRLF makes it equal to `,S=`. record_virtual_size: bool = false, /// Ask the filesystem to put the message on the disk before it is named /// in `new`. Without this a crash can leave a message that exists in the /// directory and is empty on disk, which is worse than a message that /// never arrived. Costs a round trip to the storage on every delivery. sync: bool = true, /// How many times to invent a new name when the one invented collides /// with a file already in `tmp`. attempts: usize = 10, }; pub const DeliverError = File.OpenError || File.Writer.Error || File.SyncError || Dir.RenameError || error{ /// The buffered writer failed and could not say why, which should not /// happen: `File.Writer` records the real error and `commit` returns /// that instead wherever it is there. WriteFailed, /// `attempts` names in a row were already taken in `tmp`. Something /// is wrong that retrying will not fix — a clock stuck at a value a /// previous run also used, with `tmp` never cleaned. NameCollision, /// The generated name did not fit in `Dir.max_name_bytes`, which /// means the host name is absurd. NameTooLong, }; /// Writes a message into the maildir and returns where it landed. /// /// This is the whole protocol: a unique name, an exclusive create in `tmp`, /// the bytes, a sync, and a rename into `new` or `cur`. If anything fails /// before the rename the partial file is removed, so a failed delivery leaves /// nothing behind and — more to the point — never leaves half a message where /// a reader will find it. pub fn deliver( self: *Maildir, io: Io, bytes: []const u8, options: DeliverOptions, ) DeliverError!Message { var delivery = try self.beginDelivery(io, options.attempts); errdefer delivery.abort(io); try delivery.file.writeStreamingAll(io, bytes); delivery.size = bytes.len; if (options.record_virtual_size) delivery.virtual_size = virtualSizeOf(bytes); return delivery.commit(io, options); } /// The size `bytes` would have if every line ended in CRLF: the length plus /// one for every LF that is not already preceded by a CR. pub fn virtualSizeOf(bytes: []const u8) u64 { var total: u64 = bytes.len; for (bytes, 0..) |c, index| { if (c == '\n' and (index == 0 or bytes[index - 1] != '\r')) total += 1; } return total; } /// A message being written into `tmp`, not yet delivered. /// /// Use this rather than `deliver` when the message is not already a slice — /// when it is being copied from a socket, or written by `mime.Message.write`, /// or is large enough that a second copy of it in memory is not wanted. /// /// **The value must not be moved once `writer` has been called**, because /// what `writer` returns points into it. pub const Delivery = struct { /// Where the temporary file lives, so that `abort` can remove it and /// `commit` can rename it without being handed the maildir again. maildir: *const Maildir, file: File, name_buffer: NameBuffer, name_len: usize, /// The size of the message, if the caller already knows it. `commit` asks /// the file otherwise. size: ?u64 = null, /// The `,W=` size, if the caller knows it. Nothing here can work it out /// for a streamed message without reading every byte a second time, so a /// caller that wants the field recorded has to count as it writes. virtual_size: ?u64 = null, file_writer: ?File.Writer = null, file_open: bool = true, finished: bool = false, /// The name the file has in `tmp`. Not the name it will have once it is /// delivered, which gains the size fields and the flags. pub fn tempName(self: *const Delivery) []const u8 { return self.name_buffer[0..self.name_len]; } /// A writer for the message body. `buffer` must outlive the delivery, and /// the delivery must not be moved afterwards. pub fn writer(self: *Delivery, io: Io, buffer: []u8) *Io.Writer { self.file_writer = self.file.writer(io, buffer); return &self.file_writer.?.interface; } /// Flushes, syncs, and renames the message into place; returns where it /// landed. On failure the temporary file is removed, so a delivery either /// produces a whole message or produces nothing. pub fn commit(self: *Delivery, io: Io, options: DeliverOptions) DeliverError!Message { std.debug.assert(!self.finished); errdefer self.abort(io); // `Io.Writer.flush` reports only that something failed; the error // itself is kept on the `File.Writer` that produced it. if (self.file_writer) |*fw| fw.interface.flush() catch return fw.err orelse error.WriteFailed; if (options.sync) try self.file.sync(io); // A stat that fails costs the `,S=` field and nothing else, so it is // not worth failing a delivery over. const size = self.size orelse (self.file.length(io) catch null); var final_buffer: NameBuffer = undefined; var w: Io.Writer = .fixed(&final_buffer); w.writeAll(self.tempName()) catch return error.NameTooLong; if (options.record_size) if (size) |bytes| { w.print(",S={d}", .{bytes}) catch return error.NameTooLong; }; if (options.record_virtual_size) if (self.virtual_size) |bytes| { w.print(",W={d}", .{bytes}) catch return error.NameTooLong; }; const destination: Subdir = switch (options.to) { .new => .new, .cur => |flags| blk: { w.writeByte(self.maildir.separator) catch return error.NameTooLong; w.writeAll("2,") catch return error.NameTooLong; flags.format(&w) catch return error.NameTooLong; break :blk .cur; }, }; const final_name = w.buffered(); // Closed before the rename rather than after, so that the file is on // its way to the disk before anything can see it under its real name. self.file.close(io); self.file_open = false; try self.maildir.tmp.rename( self.tempName(), self.maildir.subdir(destination), final_name, io, ); self.finished = true; return .init(destination, self.maildir.separator, final_name); } /// Throws the delivery away: closes the file and removes it from `tmp`. /// Does nothing to a delivery that has already been committed or aborted, /// so it is safe as an `errdefer` beside a `commit`. pub fn abort(self: *Delivery, io: Io) void { if (self.finished) return; self.finished = true; if (self.file_open) { self.file.close(io); self.file_open = false; } // If this fails the file is wreckage in `tmp`, which is what // `cleanTemp` is for. There is nothing better to do about it here. self.maildir.tmp.deleteFile(io, self.tempName()) catch {}; } }; /// Creates the temporary file a message will be written into, inventing names /// until one of them is free. pub fn beginDelivery(self: *Maildir, io: Io, attempts: usize) DeliverError!Delivery { var delivery: Delivery = .{ .maildir = self, .file = undefined, .name_buffer = undefined, .name_len = 0, }; var attempt: usize = 0; while (attempt < @max(attempts, 1)) : (attempt += 1) { const name = self.generator.bufNext(io, &delivery.name_buffer) catch return error.NameTooLong; delivery.name_len = name.len; // Exclusive: the kernel refuses rather than truncating, so a name // that has somehow been used before costs an attempt rather than // somebody else's message. delivery.file = self.tmp.createFile(io, name, .{ .exclusive = true, .permissions = private_file, }) catch |err| switch (err) { error.PathAlreadyExists => continue, else => |e| return e, }; return delivery; } return error.NameCollision; } // -- reading ----------------------------------------------------------------- /// Walks one of the subdirectories, yielding a `Message` for each file in it. /// /// Entries whose names begin with a dot are skipped, which covers `.` and /// `..` and the marker files a Maildir++ store keeps beside its messages. /// Subdirectories are skipped too. pub const Iterator = struct { inner: Dir.Iterator, which: Subdir, separator: u8, pub const Error = Dir.Iterator.Error; /// The next message, or null at the end. The returned `Message` owns its /// name, so it stays valid after the following call. pub fn next(self: *Iterator, io: Io) Error!?Message { while (try self.inner.next(io)) |entry| { if (entry.name.len == 0 or entry.name[0] == '.') continue; switch (entry.kind) { // `unknown` is what a filesystem that does not report a kind // in its directory entries gives, and refusing those would // make this library useless on them. .file, .sym_link, .unknown => {}, else => continue, } if (entry.name.len > Dir.max_name_bytes) continue; return .init(self.which, self.separator, entry.name); } return null; } }; pub fn iterate(self: *const Maildir, which: Subdir) Iterator { return .{ .inner = self.subdir(which).iterate(), .which = which, .separator = self.separator, }; } pub const ListError = Iterator.Error || Allocator.Error; /// Every message in the given subdirectories, in one allocation-owning list. /// /// Iteration is the cheaper way to walk a maildir and should be preferred, /// but a caller that needs to sort the messages, or to know how many there /// are before it starts, needs them all at once. The order is the /// filesystem's, which is not chronological. pub fn list( self: *const Maildir, gpa: Allocator, io: Io, which: []const Subdir, ) ListError![]Message { var messages: std.ArrayList(Message) = .empty; defer messages.deinit(gpa); for (which) |subdir_kind| { var it = self.iterate(subdir_kind); while (try it.next(io)) |message| try messages.append(gpa, message); } return messages.toOwnedSlice(gpa); } /// Looks for a message by the unchanging part of its name — `Name.base`, /// which survives every flag change — in `new` and then in `cur`. /// /// This is how a program that remembered a message finds it again, and it is /// a linear scan because a maildir has no index. A caller doing this for /// every message of many should list the directory once instead. pub fn find(self: *const Maildir, io: Io, base: []const u8) Iterator.Error!?Message { for ([_]Subdir{ .new, .cur }) |which| { var it = self.iterate(which); while (try it.next(io)) |message| { if (std.mem.eql(u8, message.id(), base)) return message; } } return null; } pub const CleanTempError = Iterator.Error || Dir.DeleteFileError; /// Deletes anything in `tmp` older than `max_age`, which is the housekeeping /// the specification asks for: a delivery that died between creating its file /// and renaming it leaves that file behind forever otherwise. /// /// Thirty-six hours is the specified threshold, and it is long for a reason — /// it must exceed the longest a legitimate delivery could take, since /// deleting a file out from under a delivery in progress loses the message. /// /// Returns how many files were removed. pub fn cleanTemp(self: *const Maildir, io: Io, max_age: Io.Duration) CleanTempError!usize { const now = Io.Timestamp.now(io, .real); var removed: usize = 0; var it = self.tmp.iterate(); while (try it.next(io)) |entry| { if (entry.name.len == 0 or entry.name[0] == '.') continue; const stat = self.tmp.statFile(io, entry.name, .{}) catch continue; if (stat.mtime.durationTo(now).nanoseconds < max_age.nanoseconds) continue; self.tmp.deleteFile(io, entry.name) catch |err| switch (err) { error.FileNotFound => continue, else => |e| return e, }; removed += 1; } return removed; } /// The age at which the specification says a file in `tmp` is wreckage. pub const temp_max_age: Io.Duration = .{ .nanoseconds = 36 * 60 * 60 * std.time.ns_per_s }; test { _ = Message; }