A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
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}