// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The name of a message file, taken apart. //! //! A maildir keeps a message's metadata in its file name, which is why //! changing a flag is a `rename` and why the name has to be parsed and //! rebuilt rather than treated as opaque. The shape is //! //! ```text //! 1757700000.M492817R3f0a1c2b4d5e6f70Q1.mail.example.com,S=4211:2,RS //! \_________________ unique ________________________/\____/ \_____/ //! fields info //! ``` //! //! * The **unique** part is everything before the separator, and this library //! never invents meaning for it beyond the fields below. It is produced at //! delivery and then left exactly alone, because two programs sharing a //! maildir agree on a message's identity by its unique part and nothing //! else — change it and every IMAP UID, every read/unread record and every //! synchronisation state keyed to it is lost. //! * The **fields** are `,`-separated `letter=value` pairs that Dovecot and //! Courier append to the unique part. `S` is the size of the file in bytes //! and `W` is its size once every line ends in CRLF, and both are there so //! that a quota can be totalled and an IMAP `RFC822.SIZE` answered from a //! directory listing rather than from a `stat` of every message. They are //! part of the unique part as far as everything else is concerned. //! * The **info** is `2,` followed by the flags. A message in `new` has no //! info at all, which is exactly what makes it new. //! //! `parse` cannot fail. A name it does not understand keeps its info verbatim //! in `Info.other` and is written back byte for byte, because the alternative //! — refusing to list a message because something else wrote its name in a //! dialect this library has not heard of — loses mail that is sitting right //! there. const std = @import("std"); const Io = std.Io; const testing = std.testing; const Flags = @import("Flags.zig"); const Name = @This(); /// Everything before the separator, including any `,S=` and `,W=` fields. /// Borrowed from whatever the name was parsed out of. unique: []const u8, /// What followed the separator. info: Info, /// The character between the unique part and the info. /// /// A colon is what the maildir defines and what every Unix mail program /// expects. It is also illegal in a FAT, exFAT or NTFS file name, so a /// maildir on a memory stick or a Windows share is written by isync and /// Dovecot with some other character — usually `!` or `;` — and a reader that /// insists on a colon sees every message in it as new and flagless. That is /// why the separator is a parameter of every function here rather than a /// constant. pub const default_separator: u8 = ':'; /// What follows the separator in a message's name. pub const Info = union(enum) { /// There was no separator. A message in `new` has no info, and that is /// the whole of what "new" means. none, /// `2,` followed by flags: the only info semantics ever defined. flags: Flags, /// A separator followed by something else — the experimental `1,` /// semantics, or a name written by software that has its own ideas. /// Kept as written, and written back unchanged. other: []const u8, }; /// Takes a file name apart. Never fails: a name that makes no sense is a name /// with no flags and an `Info.other` that reproduces it. /// /// The result borrows from `basename`, which must outlive it. When that is a /// directory entry, "outlive it" means "until the next call to `next`". pub fn parse(basename: []const u8, separator: u8) Name { const index = std.mem.findScalarLast(u8, basename, separator) orelse return .{ .unique = basename, .info = .none, }; const unique = basename[0..index]; const rest = basename[index + 1 ..]; // Only `2,` was ever defined. `1,` was reserved for experiments that // never happened, and anything else is somebody's extension. if (std.mem.startsWith(u8, rest, "2,")) { if (Flags.parse(rest[2..])) |parsed| { return .{ .unique = unique, .info = .{ .flags = parsed } }; } else |_| {} } return .{ .unique = unique, .info = .{ .other = rest } }; } /// The flags on the message, treating a name with no info or an info this /// library does not understand as having none — which is the truth as far as /// anything can tell. pub fn flags(self: Name) Flags { return switch (self.info) { .flags => |f| f, .none, .other => .none, }; } /// `self` with different flags, and the same unique part. pub fn withFlags(self: Name, new_flags: Flags) Name { return .{ .unique = self.unique, .info = .{ .flags = new_flags } }; } /// The unique part with the `,`-separated fields removed: the part that /// identifies the message and never changes, even when its size is recorded /// or its flags are set. pub fn base(self: Name) []const u8 { const index = std.mem.findScalar(u8, self.unique, ',') orelse return self.unique; return self.unique[0..index]; } /// The value of a `,=` field appended to the unique part, or /// null if the name does not carry one. See `size` and `virtualSize` for the /// two that are defined. pub fn field(self: Name, letter: u8) ?[]const u8 { var rest = self.unique; while (std.mem.findScalar(u8, rest, ',')) |comma| { rest = rest[comma + 1 ..]; const end = std.mem.findScalar(u8, rest, ',') orelse rest.len; const item = rest[0..end]; if (item.len >= 2 and item[0] == letter and item[1] == '=') return item[2..]; } return null; } fn fieldInt(self: Name, letter: u8) ?u64 { const text = self.field(letter) orelse return null; return std.fmt.parseInt(u64, text, 10) catch null; } /// The size of the message in bytes, from the `,S=` field, or null if the /// name does not carry one — in which case the only way to know is to `stat` /// the file, which is what `Message.size` does. /// /// It is not checked against the file. A name that disagrees with its content /// was written by something that got it wrong, or the file was modified in /// place, which a maildir forbids. pub fn size(self: Name) ?u64 { return self.fieldInt('S'); } /// The size the message would have if every line ended in CRLF, from the /// `,W=` field. This is the number IMAP's `RFC822.SIZE` wants, and it differs /// from `size` for a message stored with bare newlines. pub fn virtualSize(self: Name) ?u64 { return self.fieldInt('W'); } /// Writes the name back out. pub fn write(self: Name, w: *Io.Writer, separator: u8) Io.Writer.Error!void { try w.writeAll(self.unique); switch (self.info) { .none => {}, .flags => |f| { try w.writeByte(separator); try w.writeAll("2,"); try f.format(w); }, .other => |text| { try w.writeByte(separator); try w.writeAll(text); }, } } /// Writes the name with the default separator, for `{f}`. pub fn format(self: Name, w: *Io.Writer) Io.Writer.Error!void { return self.write(w, default_separator); } /// Writes the name into `buffer` and returns the part used. pub fn bufWrite(self: Name, buffer: []u8, separator: u8) error{NoSpaceLeft}![]u8 { var w: Io.Writer = .fixed(buffer); self.write(&w, separator) catch return error.NoSpaceLeft; return w.buffered(); } test "a message in new has no info" { const name: Name = .parse("1757700000.M1R2Q3.host", ':'); try testing.expectEqualStrings("1757700000.M1R2Q3.host", name.unique); try testing.expectEqual(Name.Info.none, name.info); try testing.expectEqual(Flags.none, name.flags()); try testing.expectFmt("1757700000.M1R2Q3.host", "{f}", .{name}); } test "a message in cur has flags" { const name: Name = .parse("1757700000.M1R2Q3.host:2,RS", ':'); try testing.expectEqualStrings("1757700000.M1R2Q3.host", name.unique); try testing.expect(name.flags().seen and name.flags().replied); try testing.expectFmt("1757700000.M1R2Q3.host:2,RS", "{f}", .{name}); } test "an empty flag list is a message in cur with nothing set" { const name: Name = .parse("x:2,", ':'); try testing.expectEqual(Flags.none, name.flags()); try testing.expectFmt("x:2,", "{f}", .{name}); } test "the size fields are part of the unique name and readable from it" { const name: Name = .parse("1757700000.M1R2Q3.host,S=4211,W=4300:2,S", ':'); try testing.expectEqualStrings("1757700000.M1R2Q3.host", name.base()); try testing.expectEqual(@as(?u64, 4211), name.size()); try testing.expectEqual(@as(?u64, 4300), name.virtualSize()); try testing.expect(name.flags().seen); } test "a name with no size field says so rather than guessing" { const name: Name = .parse("1757700000.M1R2Q3.host:2,S", ':'); try testing.expectEqual(@as(?u64, null), name.size()); try testing.expectEqual(@as(?u64, null), name.virtualSize()); } test "a size field that is not a number is not a number" { const name: Name = .parse("x,S=beef:2,", ':'); try testing.expectEqual(@as(?u64, null), name.size()); try testing.expectEqualStrings("beef", name.field('S').?); } test "changing a flag leaves the unique part alone" { const name: Name = .parse("1757700000.M1R2Q3.host,S=4211:2,S", ':'); const replied = name.withFlags(name.flags().with(.replied)); try testing.expectFmt("1757700000.M1R2Q3.host,S=4211:2,RS", "{f}", .{replied}); } test "the experimental info semantics are kept rather than understood" { const name: Name = .parse("x:1,whatever", ':'); try testing.expectEqualStrings("1,whatever", name.info.other); try testing.expectEqual(Flags.none, name.flags()); try testing.expectFmt("x:1,whatever", "{f}", .{name}); } test "an info that is not flags at all round trips byte for byte" { // `2,` followed by something that cannot be a flag: kept verbatim rather // than parsed into nothing, so that whatever wrote it can read it back. const name: Name = .parse("x:2,S=1", ':'); try testing.expectEqualStrings("2,S=1", name.info.other); try testing.expectFmt("x:2,S=1", "{f}", .{name}); } test "a separator that is not a colon" { // What isync writes on a filesystem that will not take a colon. Read with // the wrong separator, the flags vanish and the message looks new -- which // is the whole reason this is a parameter. const name: Name = .parse("1757700000.M1R2Q3.host!2,S", '!'); try testing.expect(name.flags().seen); try testing.expectFmt("1757700000.M1R2Q3.host!2,S", "{f}", .{ struct { n: Name, pub fn format(s: @This(), w: *Io.Writer) Io.Writer.Error!void { return s.n.write(w, '!'); } }{ .n = name }, }); const misread: Name = .parse("1757700000.M1R2Q3.host!2,S", ':'); try testing.expectEqual(Name.Info.none, misread.info); } test "bufWrite" { const name: Name = .parse("x:2,S", ':'); var buffer: [64]u8 = undefined; try testing.expectEqualStrings("x:2,RS", try name.withFlags( name.flags().with(.replied), ).bufWrite(&buffer, ':')); var tiny: [3]u8 = undefined; try testing.expectError(error.NoSpaceLeft, name.bufWrite(&tiny, ':')); }