A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-maildir / src / Flags.zig
13 kB 363 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! The flags on a message, which in a maildir are part of its file name. 5//! 6//! A message in `cur` is named `<unique>:2,<flags>`, where `<flags>` is a 7//! string of letters. Six of them were defined by the original maildir and 8//! mean the same thing everywhere: 9//! 10//! | | | | 11//! | --- | --- | --- | 12//! | `D` | `draft` | a message still being composed | 13//! | `F` | `flagged` | marked by the user; IMAP's `\Flagged` | 14//! | `P` | `passed` | resent, forwarded or bounced onwards | 15//! | `R` | `replied` | IMAP's `\Answered` | 16//! | `S` | `seen` | IMAP's `\Seen` | 17//! | `T` | `trashed` | IMAP's `\Deleted`: to be removed at the next expunge | 18//! 19//! The letters **must be written in ASCII order**, which is what `format` 20//! does, and which is why `other` is a set rather than a string. 21//! 22//! Everything else that turns up in that position lands in `other`, and the 23//! reason it is kept rather than discarded is that dropping it corrupts 24//! somebody else's state. Dovecot stores IMAP keywords — arbitrary 25//! user-defined labels — as the letters `a` through `z`, mapped to their names 26//! by a `dovecot-keywords` file beside the maildir, so a program that reads 27//! `:2,Sb`, marks the message replied and writes back `:2,RS` has silently 28//! deleted a label the user applied. Reading flags, changing one and writing 29//! them back is the single most common thing done to a maildir, and it has to 30//! be lossless. 31 32const std = @import("std"); 33const Io = std.Io; 34const testing = std.testing; 35 36const Flags = @This(); 37 38/// `D`. A message still being composed; IMAP's `\Draft`. 39draft: bool = false, 40/// `F`. Marked by the user for their own reasons; IMAP's `\Flagged`. 41flagged: bool = false, 42/// `P`. Resent, forwarded or bounced onwards. IMAP has no equivalent. 43passed: bool = false, 44/// `R`. Replied to; IMAP's `\Answered`. 45replied: bool = false, 46/// `S`. Read; IMAP's `\Seen`. 47seen: bool = false, 48/// `T`. Marked for deletion; IMAP's `\Deleted`. A trashed message is still 49/// there — removing it is a separate act, which IMAP calls an expunge. 50trashed: bool = false, 51/// Every letter that is not one of the six above, kept so that changing a 52/// flag does not discard one this library does not know about. `a` through 53/// `z` are Dovecot's IMAP keywords; the twenty remaining uppercase letters 54/// have no agreed meaning, which is not a reason to throw them away. 55/// 56/// Setting one of the six here as well as in its own field is harmless — 57/// `format` writes each letter once — but `setLetter` routes them to the 58/// fields, and that is the way to set a flag whose letter is only known at 59/// run time. 60other: Letters = .empty, 61 62/// No flags at all: what a message delivered to `new` has, and what `:2,` 63/// with nothing after it means. 64pub const none: Flags = .{}; 65 66/// One of the six flags the maildir defines, named by the letter that stands 67/// for it. 68pub const Flag = enum(u8) { 69 draft = 'D', 70 flagged = 'F', 71 passed = 'P', 72 replied = 'R', 73 seen = 'S', 74 trashed = 'T', 75 76 /// The letter this flag is written as. 77 pub fn letter(flag: Flag) u8 { 78 return @intFromEnum(flag); 79 } 80 81 /// The flag a letter stands for, or null if it is not one of the six. 82 pub fn fromLetter(c: u8) ?Flag { 83 return switch (c) { 84 'D' => .draft, 85 'F' => .flagged, 86 'P' => .passed, 87 'R' => .replied, 88 'S' => .seen, 89 'T' => .trashed, 90 else => null, 91 }; 92 } 93}; 94 95/// A set of ASCII letters, held in the order they must be written in: bit 0 96/// is `A`, bit 25 is `Z`, bit 26 is `a`, bit 51 is `z`. Uppercase before 97/// lowercase is ASCII order, so iterating the bits upwards produces a valid 98/// flag string without a sort. 99pub const Letters = struct { 100 bits: u52 = 0, 101 102 pub const empty: Letters = .{}; 103 104 /// The bit a letter occupies, or null if `c` is not an ASCII letter. 105 pub fn indexOf(c: u8) ?u6 { 106 return switch (c) { 107 'A'...'Z' => @intCast(c - 'A'), 108 'a'...'z' => @intCast(c - 'a' + 26), 109 else => null, 110 }; 111 } 112 113 /// The letter a bit stands for. Asserts the index is in range. 114 pub fn letterAt(index: u6) u8 { 115 std.debug.assert(index < 52); 116 return if (index < 26) 'A' + @as(u8, index) else 'a' + @as(u8, index - 26); 117 } 118 119 pub fn has(self: Letters, c: u8) bool { 120 const index = indexOf(c) orelse return false; 121 return self.bits & (@as(u52, 1) << index) != 0; 122 } 123 124 /// Adds or removes a letter. Asserts that `c` is an ASCII letter, since 125 /// nothing else can be a flag. 126 pub fn set(self: *Letters, c: u8, present: bool) void { 127 const index = indexOf(c) orelse unreachable; 128 const bit = @as(u52, 1) << index; 129 if (present) self.bits |= bit else self.bits &= ~bit; 130 } 131 132 pub fn unionWith(a: Letters, b: Letters) Letters { 133 return .{ .bits = a.bits | b.bits }; 134 } 135 136 pub fn subtract(a: Letters, b: Letters) Letters { 137 return .{ .bits = a.bits & ~b.bits }; 138 } 139 140 pub fn count(self: Letters) usize { 141 return @popCount(self.bits); 142 } 143 144 pub fn eql(a: Letters, b: Letters) bool { 145 return a.bits == b.bits; 146 } 147 148 /// The letters in the set, in the order they must be written. 149 pub fn iterator(self: Letters) Iterator { 150 return .{ .remaining = self.bits }; 151 } 152 153 pub const Iterator = struct { 154 remaining: u52, 155 156 pub fn next(it: *Iterator) ?u8 { 157 if (it.remaining == 0) return null; 158 const index: u6 = @intCast(@ctz(it.remaining)); 159 it.remaining &= it.remaining - 1; 160 return letterAt(index); 161 } 162 }; 163}; 164 165/// Every letter set, as one `Letters`, so that the six named fields and the 166/// `other` set can be reasoned about together. 167fn letters(self: Flags) Letters { 168 var result = self.other; 169 if (self.draft) result.set('D', true); 170 if (self.flagged) result.set('F', true); 171 if (self.passed) result.set('P', true); 172 if (self.replied) result.set('R', true); 173 if (self.seen) result.set('S', true); 174 if (self.trashed) result.set('T', true); 175 return result; 176} 177 178pub fn has(self: Flags, flag: Flag) bool { 179 return switch (flag) { 180 .draft => self.draft, 181 .flagged => self.flagged, 182 .passed => self.passed, 183 .replied => self.replied, 184 .seen => self.seen, 185 .trashed => self.trashed, 186 }; 187} 188 189pub fn set(self: *Flags, flag: Flag, present: bool) void { 190 switch (flag) { 191 .draft => self.draft = present, 192 .flagged => self.flagged = present, 193 .passed => self.passed = present, 194 .replied => self.replied = present, 195 .seen => self.seen = present, 196 .trashed => self.trashed = present, 197 } 198} 199 200/// `self` with one flag set, for building a value in an expression: 201/// `Flags.none.with(.seen).with(.replied)`. 202pub fn with(self: Flags, flag: Flag) Flags { 203 var result = self; 204 result.set(flag, true); 205 return result; 206} 207 208/// `self` with one flag cleared. 209pub fn without(self: Flags, flag: Flag) Flags { 210 var result = self; 211 result.set(flag, false); 212 return result; 213} 214 215/// Whether a letter is set, whichever of the six or of `other` it belongs to. 216/// This is the way to ask about a Dovecot keyword. 217pub fn hasLetter(self: Flags, c: u8) bool { 218 if (Flag.fromLetter(c)) |flag| return self.has(flag); 219 return self.other.has(c); 220} 221 222/// Sets or clears a letter, routing the six to their own fields so that 223/// `setLetter('S', true)` and `set(.seen, true)` cannot disagree. Asserts 224/// that `c` is an ASCII letter. 225pub fn setLetter(self: *Flags, c: u8, present: bool) void { 226 if (Flag.fromLetter(c)) |flag| return self.set(flag, present); 227 self.other.set(c, present); 228} 229 230/// Everything set in either. 231pub fn unionWith(a: Flags, b: Flags) Flags { 232 return .{ 233 .draft = a.draft or b.draft, 234 .flagged = a.flagged or b.flagged, 235 .passed = a.passed or b.passed, 236 .replied = a.replied or b.replied, 237 .seen = a.seen or b.seen, 238 .trashed = a.trashed or b.trashed, 239 .other = a.other.unionWith(b.other), 240 }; 241} 242 243/// Everything set in `a` and not in `b`. 244pub fn subtract(a: Flags, b: Flags) Flags { 245 return .{ 246 .draft = a.draft and !b.draft, 247 .flagged = a.flagged and !b.flagged, 248 .passed = a.passed and !b.passed, 249 .replied = a.replied and !b.replied, 250 .seen = a.seen and !b.seen, 251 .trashed = a.trashed and !b.trashed, 252 .other = a.other.subtract(b.other), 253 }; 254} 255 256/// Whether two sets of flags name the same letters. Not `std.meta.eql`, 257/// because a flag set in `other` as well as in its own field is the same set 258/// of letters as one set only in its field. 259pub fn eql(a: Flags, b: Flags) bool { 260 return a.letters().eql(b.letters()); 261} 262 263pub fn count(self: Flags) usize { 264 return self.letters().count(); 265} 266 267pub const ParseError = error{ 268 /// Something that is not an ASCII letter appeared where a flag was 269 /// expected. The caller has the whole name and can keep it verbatim, 270 /// which is what `maildir.Name` does. 271 InvalidFlag, 272}; 273 274/// Reads the letters after `:2,`. 275/// 276/// The order they arrive in is not checked. Software that writes them 277/// unsorted is out there, the set is what the flags mean, and `format` writes 278/// a sorted one back — so accepting `SR` and writing `RS` repairs the name 279/// rather than rejecting a message. 280pub fn parse(text: []const u8) ParseError!Flags { 281 var result: Flags = .none; 282 for (text) |c| { 283 if (Letters.indexOf(c) == null) return error.InvalidFlag; 284 result.setLetter(c, true); 285 } 286 return result; 287} 288 289/// Writes the letters, in ASCII order, with nothing around them: the caller 290/// supplies the `:2,`. Writes nothing at all when no flag is set, which is 291/// what `:2,` on its own means. 292pub fn format(self: Flags, w: *Io.Writer) Io.Writer.Error!void { 293 var it = self.letters().iterator(); 294 while (it.next()) |c| try w.writeByte(c); 295} 296 297test "the six, in ASCII order whatever order they were written in" { 298 const flags = try Flags.parse("TSRPFD"); 299 try testing.expect(flags.draft and flags.flagged and flags.passed); 300 try testing.expect(flags.replied and flags.seen and flags.trashed); 301 try testing.expectFmt("DFPRST", "{f}", .{flags}); 302} 303 304test "no flags is the empty string, not an error" { 305 try testing.expectEqual(Flags.none, try Flags.parse("")); 306 try testing.expectFmt("", "{f}", .{Flags.none}); 307} 308 309test "a keyword survives a flag being changed" { 310 // Dovecot wrote this: seen, plus the keyword it calls `a`. 311 var flags = try Flags.parse("Sa"); 312 flags.set(.replied, true); 313 // `R` sorts before `S`, and `a` after both, because that is ASCII. 314 try testing.expectFmt("RSa", "{f}", .{flags}); 315 try testing.expect(flags.hasLetter('a')); 316} 317 318test "an uppercase letter nobody has defined is kept too" { 319 const flags = try Flags.parse("SZ"); 320 try testing.expect(flags.seen); 321 try testing.expect(flags.hasLetter('Z')); 322 try testing.expectFmt("SZ", "{f}", .{flags}); 323} 324 325test "a flag that is not a letter is refused" { 326 try testing.expectError(error.InvalidFlag, Flags.parse("S,")); 327 try testing.expectError(error.InvalidFlag, Flags.parse("2,S")); 328 try testing.expectError(error.InvalidFlag, Flags.parse("S1")); 329} 330 331test "set operations" { 332 const a = try Flags.parse("RSa"); 333 const b = try Flags.parse("Sb"); 334 try testing.expectFmt("RSab", "{f}", .{a.unionWith(b)}); 335 try testing.expectFmt("Ra", "{f}", .{a.subtract(b)}); 336 try testing.expectEqual(@as(usize, 3), a.count()); 337} 338 339test "eql ignores where a letter is recorded" { 340 var odd: Flags = .none; 341 odd.other.set('S', true); // deliberately in the wrong place 342 const tidy: Flags = Flags.none.with(.seen); 343 try testing.expect(odd.eql(tidy)); 344 try testing.expectFmt("S", "{f}", .{odd}); 345} 346 347test "with and without build a value in an expression" { 348 const flags: Flags = Flags.none.with(.seen).with(.replied).without(.seen); 349 try testing.expectFmt("R", "{f}", .{flags}); 350} 351 352test "every letter round trips" { 353 var all: Flags = .none; 354 for ('A'..'Z' + 1) |c| all.setLetter(@intCast(c), true); 355 for ('a'..'z' + 1) |c| all.setLetter(@intCast(c), true); 356 try testing.expectEqual(@as(usize, 52), all.count()); 357 358 var buffer: [64]u8 = undefined; 359 const text = try std.fmt.bufPrint(&buffer, "{f}", .{all}); 360 try testing.expectEqualStrings("ABCDEFGHIJKLMNOPQRSTUVWXYZ" ++ 361 "abcdefghijklmnopqrstuvwxyz", text); 362 try testing.expect(all.eql(try Flags.parse(text))); 363}