// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! One message in a maildir: which of the three directories it is in, and //! what it is called. //! //! That is genuinely all a message is. There is no file handle here and no //! content — a `Message` is a name, and every operation on it either reads //! the file that name points at or renames it. It owns a copy of the name //! rather than borrowing one, so it stays valid after the iterator that //! produced it has moved on, and it is small enough to copy freely. //! //! The operations that change a flag take the message by pointer, because //! changing a flag changes the name and the caller's `Message` has to follow //! it. A `Message` whose name no longer exists — because another process //! moved or expunged it — is not detected until the next operation on it //! fails, which is the price of a mailbox with no lock in it. 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 mime = @import("mime"); const Flags = @import("Flags.zig"); const Name = @import("Name.zig"); const Maildir = @import("Maildir.zig"); const Message = @This(); /// Which directory the file is in. A message in `new` has no flags, because /// that is what makes it new. subdir: Maildir.Subdir, /// The separator this message's name was read with, so that its flags can be /// parsed and rewritten without the maildir being passed in to do it. separator: u8, name_buffer: Maildir.NameBuffer, name_len: usize, /// Asserts the name fits in `Dir.max_name_bytes`, which every name a /// filesystem handed us does by construction. pub fn init(subdir: Maildir.Subdir, separator: u8, basename: []const u8) Message { std.debug.assert(basename.len <= Dir.max_name_bytes); var self: Message = .{ .subdir = subdir, .separator = separator, .name_buffer = undefined, .name_len = basename.len, }; @memcpy(self.name_buffer[0..basename.len], basename); return self; } /// The file name, as it is on disk. pub fn filename(self: *const Message) []const u8 { return self.name_buffer[0..self.name_len]; } /// The name taken apart. Borrows from `self`, so it must not outlive it. pub fn name(self: *const Message) Name { return .parse(self.filename(), self.separator); } /// The flags on the message. A message in `new` has none. pub fn flags(self: *const Message) Flags { return self.name().flags(); } /// The part of the name that identifies the message and survives every flag /// change — what to remember a message by, and what `Maildir.find` looks for. pub fn id(self: *const Message) []const u8 { return self.name().base(); } fn dir(self: *const Message, maildir: *const Maildir) Dir { return maildir.subdir(self.subdir); } // -- reading ----------------------------------------------------------------- /// Opens the message file for reading. pub fn open(self: *const Message, maildir: *const Maildir, io: Io) File.OpenError!File { return self.dir(maildir).openFile(io, self.filename(), .{ .allow_directory = false }); } pub fn stat(self: *const Message, maildir: *const Maildir, io: Io) Dir.StatFileError!File.Stat { return self.dir(maildir).statFile(io, self.filename(), .{}); } /// How big the message is, from the `,S=` field in its name if it has one and /// from the filesystem otherwise. /// /// Trusting the name is safe because a maildir message is written once and /// never modified, and it is what makes totalling a mailbox's size a /// directory listing rather than one `stat` per message. pub fn size(self: *const Message, maildir: *const Maildir, io: Io) Dir.StatFileError!u64 { if (self.name().size()) |bytes| return bytes; return (try self.stat(maildir, io)).size; } /// The largest message this will read into memory by default. Mail is not /// supposed to be bigger than this, and a maildir that has been handed /// something enormous should not take the reader down with it. pub const default_read_limit: Io.Limit = .limited(64 * 1024 * 1024); pub const ReadError = Dir.ReadFileAllocError; /// The whole message, headers and body, exactly as it is on disk. The caller /// owns the result. pub fn read( self: *const Message, maildir: *const Maildir, gpa: Allocator, io: Io, limit: Io.Limit, ) ReadError![]u8 { return self.dir(maildir).readFileAlloc(io, self.filename(), gpa, limit); } /// `mime.Message.parse` only ever fails to allocate, so the errors here /// are the ones reading the file can produce. pub const ParseError = ReadError; /// The message, parsed: headers, addresses, dates, and the MIME tree. /// /// This is where takes over. It parses /// from a slice rather than streaming, so the message is read into memory /// first and the returned `mime.Message` owns that copy — `deinit` frees /// both. A message written back out with `mime.Message.write` is byte for /// byte the one that was read, which is what makes it safe to open a signed /// message and forward it. /// /// ```zig /// var parsed = try message.parse(&maildir, gpa, io, .unlimited, .{}); /// defer parsed.deinit(); /// std.debug.print("{s}\n", .{(try parsed.root.subject()) orelse "(none)"}); /// ``` pub fn parse( self: *const Message, maildir: *const Maildir, gpa: Allocator, io: Io, limit: Io.Limit, options: mime.Message.ParseOptions, ) ParseError!mime.Message { const bytes = try self.read(maildir, gpa, io, limit); defer gpa.free(bytes); return mime.Message.parse(gpa, bytes, options); } // -- changing the name ------------------------------------------------------- pub const RenameError = Dir.RenameError || error{ /// The new name did not fit in `Dir.max_name_bytes`. NameTooLong, }; /// Renames the message so that it has exactly these flags, moving it from /// `new` to `cur` if it is still in `new`. /// /// Moving it is not a convenience: a name in `new` has no info field, so /// there is nowhere in `new` for a flag to be written. Marking a new message /// read and leaving it in `new` is not a thing a maildir can express, and /// every other implementation does the same move. /// /// On success `self` is updated to the new name. On failure it is untouched /// and still names the file that is still there. pub fn setFlags( self: *Message, maildir: *const Maildir, io: Io, new_flags: Flags, ) RenameError!void { var buffer: Maildir.NameBuffer = undefined; const renamed = self.name().withFlags(new_flags).bufWrite(&buffer, maildir.separator) catch return error.NameTooLong; // A message already in `cur` whose flags are unchanged would be a rename // onto itself, which is a no-op on POSIX but still a syscall and still a // change of mtime on the directory. if (self.subdir == .cur and std.mem.eql(u8, renamed, self.filename())) return; try self.dir(maildir).rename(self.filename(), maildir.cur, renamed, io); self.* = .init(.cur, maildir.separator, renamed); } /// Adds flags, leaving the others as they are. pub fn addFlags( self: *Message, maildir: *const Maildir, io: Io, to_add: Flags, ) RenameError!void { return self.setFlags(maildir, io, self.flags().unionWith(to_add)); } /// Removes flags, leaving the others as they are. pub fn removeFlags( self: *Message, maildir: *const Maildir, io: Io, to_remove: Flags, ) RenameError!void { return self.setFlags(maildir, io, self.flags().subtract(to_remove)); } /// Moves a message from `new` into `cur` without changing what it means — /// that is, with no flags — which is what a reader does when it has listed a /// mailbox and taken note of what was in it. /// /// A message already in `cur` is left exactly as it is, flags and all. pub fn moveToCur(self: *Message, maildir: *const Maildir, io: Io) RenameError!void { if (self.subdir == .cur) return; return self.setFlags(maildir, io, self.flags()); } pub const MoveError = RenameError || Maildir.DeliverError; /// Moves the message into another maildir, keeping its flags and giving it a /// name that is unique there. /// /// The name changes, and it has to. Two maildirs are two directories and /// nothing coordinates the names in them, so a message carrying its name into /// a folder that already has one like it would overwrite a message — and /// `rename` would do it silently. This is what IMAP's `MOVE` does, and it is /// why an IMAP server cannot promise that a moved message keeps its UID. /// /// `self` is updated to name the message in its new home. The two maildirs /// must be on the same filesystem, since this is a `rename` and not a copy. /// /// One sharp edge, and it is not this library's to fix: a **keyword does not /// survive the move with its meaning intact**. The letters in `Flags.other` /// are carried across unchanged, but what a letter *means* is recorded in a /// `dovecot-keywords` file inside each mailbox, so a message labelled /// "Important" in the inbox arrives in the archive carrying a letter that /// mailbox has never assigned. Dovecot's own `MOVE` updates the destination's /// mapping; nothing outside Dovecot can, because the mapping is Dovecot's /// rather than the maildir's. The six standard flags have no such problem — /// they mean the same thing everywhere. pub fn moveTo( self: *Message, from: *const Maildir, to: *Maildir, io: Io, ) MoveError!void { var unique_buffer: Maildir.NameBuffer = undefined; const fresh = to.generator.bufNext(io, &unique_buffer) catch return error.NameTooLong; // The flags travel; the unique part does not. Whatever `,S=` said is // still true, so it is carried across rather than recomputed. var buffer: Maildir.NameBuffer = undefined; var w: Io.Writer = .fixed(&buffer); w.writeAll(fresh) catch return error.NameTooLong; const old = self.name(); if (old.size()) |bytes| w.print(",S={d}", .{bytes}) catch return error.NameTooLong; if (old.virtualSize()) |bytes| w.print(",W={d}", .{bytes}) catch return error.NameTooLong; const destination: Maildir.Subdir = switch (self.subdir) { .new => .new, .cur, .tmp => blk: { w.writeByte(to.separator) catch return error.NameTooLong; w.writeAll("2,") catch return error.NameTooLong; old.flags().format(&w) catch return error.NameTooLong; break :blk .cur; }, }; const renamed = w.buffered(); try self.dir(from).rename(self.filename(), to.subdir(destination), renamed, io); self.* = .init(destination, to.separator, renamed); } /// Deletes the message. This is IMAP's expunge, not its `\Deleted`: setting /// `trashed` marks a message, and this is what actually removes it. pub fn remove(self: *const Message, maildir: *const Maildir, io: Io) Dir.DeleteFileError!void { return self.dir(maildir).deleteFile(io, self.filename()); } test "a message knows its own name" { const message: Message = .init(.cur, ':', "1757700000.M1R2Q3.host,S=42:2,RS"); try testing.expectEqualStrings("1757700000.M1R2Q3.host,S=42:2,RS", message.filename()); try testing.expectEqualStrings("1757700000.M1R2Q3.host", message.id()); try testing.expect(message.flags().seen and message.flags().replied); try testing.expectEqual(@as(?u64, 42), message.name().size()); } test "a message in new has no flags" { const message: Message = .init(.new, ':', "1757700000.M1R2Q3.host"); try testing.expectEqual(Flags.none, message.flags()); try testing.expectEqualStrings("1757700000.M1R2Q3.host", message.id()); }