// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The flags on a message, which in a maildir are part of its file name. //! //! A message in `cur` is named `:2,`, where `` is a //! string of letters. Six of them were defined by the original maildir and //! mean the same thing everywhere: //! //! | | | | //! | --- | --- | --- | //! | `D` | `draft` | a message still being composed | //! | `F` | `flagged` | marked by the user; IMAP's `\Flagged` | //! | `P` | `passed` | resent, forwarded or bounced onwards | //! | `R` | `replied` | IMAP's `\Answered` | //! | `S` | `seen` | IMAP's `\Seen` | //! | `T` | `trashed` | IMAP's `\Deleted`: to be removed at the next expunge | //! //! The letters **must be written in ASCII order**, which is what `format` //! does, and which is why `other` is a set rather than a string. //! //! Everything else that turns up in that position lands in `other`, and the //! reason it is kept rather than discarded is that dropping it corrupts //! somebody else's state. Dovecot stores IMAP keywords — arbitrary //! user-defined labels — as the letters `a` through `z`, mapped to their names //! by a `dovecot-keywords` file beside the maildir, so a program that reads //! `:2,Sb`, marks the message replied and writes back `:2,RS` has silently //! deleted a label the user applied. Reading flags, changing one and writing //! them back is the single most common thing done to a maildir, and it has to //! be lossless. const std = @import("std"); const Io = std.Io; const testing = std.testing; const Flags = @This(); /// `D`. A message still being composed; IMAP's `\Draft`. draft: bool = false, /// `F`. Marked by the user for their own reasons; IMAP's `\Flagged`. flagged: bool = false, /// `P`. Resent, forwarded or bounced onwards. IMAP has no equivalent. passed: bool = false, /// `R`. Replied to; IMAP's `\Answered`. replied: bool = false, /// `S`. Read; IMAP's `\Seen`. seen: bool = false, /// `T`. Marked for deletion; IMAP's `\Deleted`. A trashed message is still /// there — removing it is a separate act, which IMAP calls an expunge. trashed: bool = false, /// Every letter that is not one of the six above, kept so that changing a /// flag does not discard one this library does not know about. `a` through /// `z` are Dovecot's IMAP keywords; the twenty remaining uppercase letters /// have no agreed meaning, which is not a reason to throw them away. /// /// Setting one of the six here as well as in its own field is harmless — /// `format` writes each letter once — but `setLetter` routes them to the /// fields, and that is the way to set a flag whose letter is only known at /// run time. other: Letters = .empty, /// No flags at all: what a message delivered to `new` has, and what `:2,` /// with nothing after it means. pub const none: Flags = .{}; /// One of the six flags the maildir defines, named by the letter that stands /// for it. pub const Flag = enum(u8) { draft = 'D', flagged = 'F', passed = 'P', replied = 'R', seen = 'S', trashed = 'T', /// The letter this flag is written as. pub fn letter(flag: Flag) u8 { return @intFromEnum(flag); } /// The flag a letter stands for, or null if it is not one of the six. pub fn fromLetter(c: u8) ?Flag { return switch (c) { 'D' => .draft, 'F' => .flagged, 'P' => .passed, 'R' => .replied, 'S' => .seen, 'T' => .trashed, else => null, }; } }; /// A set of ASCII letters, held in the order they must be written in: bit 0 /// is `A`, bit 25 is `Z`, bit 26 is `a`, bit 51 is `z`. Uppercase before /// lowercase is ASCII order, so iterating the bits upwards produces a valid /// flag string without a sort. pub const Letters = struct { bits: u52 = 0, pub const empty: Letters = .{}; /// The bit a letter occupies, or null if `c` is not an ASCII letter. pub fn indexOf(c: u8) ?u6 { return switch (c) { 'A'...'Z' => @intCast(c - 'A'), 'a'...'z' => @intCast(c - 'a' + 26), else => null, }; } /// The letter a bit stands for. Asserts the index is in range. pub fn letterAt(index: u6) u8 { std.debug.assert(index < 52); return if (index < 26) 'A' + @as(u8, index) else 'a' + @as(u8, index - 26); } pub fn has(self: Letters, c: u8) bool { const index = indexOf(c) orelse return false; return self.bits & (@as(u52, 1) << index) != 0; } /// Adds or removes a letter. Asserts that `c` is an ASCII letter, since /// nothing else can be a flag. pub fn set(self: *Letters, c: u8, present: bool) void { const index = indexOf(c) orelse unreachable; const bit = @as(u52, 1) << index; if (present) self.bits |= bit else self.bits &= ~bit; } pub fn unionWith(a: Letters, b: Letters) Letters { return .{ .bits = a.bits | b.bits }; } pub fn subtract(a: Letters, b: Letters) Letters { return .{ .bits = a.bits & ~b.bits }; } pub fn count(self: Letters) usize { return @popCount(self.bits); } pub fn eql(a: Letters, b: Letters) bool { return a.bits == b.bits; } /// The letters in the set, in the order they must be written. pub fn iterator(self: Letters) Iterator { return .{ .remaining = self.bits }; } pub const Iterator = struct { remaining: u52, pub fn next(it: *Iterator) ?u8 { if (it.remaining == 0) return null; const index: u6 = @intCast(@ctz(it.remaining)); it.remaining &= it.remaining - 1; return letterAt(index); } }; }; /// Every letter set, as one `Letters`, so that the six named fields and the /// `other` set can be reasoned about together. fn letters(self: Flags) Letters { var result = self.other; if (self.draft) result.set('D', true); if (self.flagged) result.set('F', true); if (self.passed) result.set('P', true); if (self.replied) result.set('R', true); if (self.seen) result.set('S', true); if (self.trashed) result.set('T', true); return result; } pub fn has(self: Flags, flag: Flag) bool { return switch (flag) { .draft => self.draft, .flagged => self.flagged, .passed => self.passed, .replied => self.replied, .seen => self.seen, .trashed => self.trashed, }; } pub fn set(self: *Flags, flag: Flag, present: bool) void { switch (flag) { .draft => self.draft = present, .flagged => self.flagged = present, .passed => self.passed = present, .replied => self.replied = present, .seen => self.seen = present, .trashed => self.trashed = present, } } /// `self` with one flag set, for building a value in an expression: /// `Flags.none.with(.seen).with(.replied)`. pub fn with(self: Flags, flag: Flag) Flags { var result = self; result.set(flag, true); return result; } /// `self` with one flag cleared. pub fn without(self: Flags, flag: Flag) Flags { var result = self; result.set(flag, false); return result; } /// Whether a letter is set, whichever of the six or of `other` it belongs to. /// This is the way to ask about a Dovecot keyword. pub fn hasLetter(self: Flags, c: u8) bool { if (Flag.fromLetter(c)) |flag| return self.has(flag); return self.other.has(c); } /// Sets or clears a letter, routing the six to their own fields so that /// `setLetter('S', true)` and `set(.seen, true)` cannot disagree. Asserts /// that `c` is an ASCII letter. pub fn setLetter(self: *Flags, c: u8, present: bool) void { if (Flag.fromLetter(c)) |flag| return self.set(flag, present); self.other.set(c, present); } /// Everything set in either. pub fn unionWith(a: Flags, b: Flags) Flags { return .{ .draft = a.draft or b.draft, .flagged = a.flagged or b.flagged, .passed = a.passed or b.passed, .replied = a.replied or b.replied, .seen = a.seen or b.seen, .trashed = a.trashed or b.trashed, .other = a.other.unionWith(b.other), }; } /// Everything set in `a` and not in `b`. pub fn subtract(a: Flags, b: Flags) Flags { return .{ .draft = a.draft and !b.draft, .flagged = a.flagged and !b.flagged, .passed = a.passed and !b.passed, .replied = a.replied and !b.replied, .seen = a.seen and !b.seen, .trashed = a.trashed and !b.trashed, .other = a.other.subtract(b.other), }; } /// Whether two sets of flags name the same letters. Not `std.meta.eql`, /// because a flag set in `other` as well as in its own field is the same set /// of letters as one set only in its field. pub fn eql(a: Flags, b: Flags) bool { return a.letters().eql(b.letters()); } pub fn count(self: Flags) usize { return self.letters().count(); } pub const ParseError = error{ /// Something that is not an ASCII letter appeared where a flag was /// expected. The caller has the whole name and can keep it verbatim, /// which is what `maildir.Name` does. InvalidFlag, }; /// Reads the letters after `:2,`. /// /// The order they arrive in is not checked. Software that writes them /// unsorted is out there, the set is what the flags mean, and `format` writes /// a sorted one back — so accepting `SR` and writing `RS` repairs the name /// rather than rejecting a message. pub fn parse(text: []const u8) ParseError!Flags { var result: Flags = .none; for (text) |c| { if (Letters.indexOf(c) == null) return error.InvalidFlag; result.setLetter(c, true); } return result; } /// Writes the letters, in ASCII order, with nothing around them: the caller /// supplies the `:2,`. Writes nothing at all when no flag is set, which is /// what `:2,` on its own means. pub fn format(self: Flags, w: *Io.Writer) Io.Writer.Error!void { var it = self.letters().iterator(); while (it.next()) |c| try w.writeByte(c); } test "the six, in ASCII order whatever order they were written in" { const flags = try Flags.parse("TSRPFD"); try testing.expect(flags.draft and flags.flagged and flags.passed); try testing.expect(flags.replied and flags.seen and flags.trashed); try testing.expectFmt("DFPRST", "{f}", .{flags}); } test "no flags is the empty string, not an error" { try testing.expectEqual(Flags.none, try Flags.parse("")); try testing.expectFmt("", "{f}", .{Flags.none}); } test "a keyword survives a flag being changed" { // Dovecot wrote this: seen, plus the keyword it calls `a`. var flags = try Flags.parse("Sa"); flags.set(.replied, true); // `R` sorts before `S`, and `a` after both, because that is ASCII. try testing.expectFmt("RSa", "{f}", .{flags}); try testing.expect(flags.hasLetter('a')); } test "an uppercase letter nobody has defined is kept too" { const flags = try Flags.parse("SZ"); try testing.expect(flags.seen); try testing.expect(flags.hasLetter('Z')); try testing.expectFmt("SZ", "{f}", .{flags}); } test "a flag that is not a letter is refused" { try testing.expectError(error.InvalidFlag, Flags.parse("S,")); try testing.expectError(error.InvalidFlag, Flags.parse("2,S")); try testing.expectError(error.InvalidFlag, Flags.parse("S1")); } test "set operations" { const a = try Flags.parse("RSa"); const b = try Flags.parse("Sb"); try testing.expectFmt("RSab", "{f}", .{a.unionWith(b)}); try testing.expectFmt("Ra", "{f}", .{a.subtract(b)}); try testing.expectEqual(@as(usize, 3), a.count()); } test "eql ignores where a letter is recorded" { var odd: Flags = .none; odd.other.set('S', true); // deliberately in the wrong place const tidy: Flags = Flags.none.with(.seen); try testing.expect(odd.eql(tidy)); try testing.expectFmt("S", "{f}", .{odd}); } test "with and without build a value in an expression" { const flags: Flags = Flags.none.with(.seen).with(.replied).without(.seen); try testing.expectFmt("R", "{f}", .{flags}); } test "every letter round trips" { var all: Flags = .none; for ('A'..'Z' + 1) |c| all.setLetter(@intCast(c), true); for ('a'..'z' + 1) |c| all.setLetter(@intCast(c), true); try testing.expectEqual(@as(usize, 52), all.count()); var buffer: [64]u8 = undefined; const text = try std.fmt.bufPrint(&buffer, "{f}", .{all}); try testing.expectEqualStrings("ABCDEFGHIJKLMNOPQRSTUVWXYZ" ++ "abcdefghijklmnopqrstuvwxyz", text); try testing.expect(all.eql(try Flags.parse(text))); }