A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
17 kB
451 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! `maildirsize`: how much mail is in a Maildir++ store, and how much is
5//! allowed.
6//!
7//! Totalling a mail store means listing every folder and adding up every
8//! message, which is fine once and ruinous on every delivery. Courier's
9//! answer is a small file in the top-level maildir that holds the running
10//! total, written like a ledger rather than a balance:
11//!
12//! ```text
13//! 10485760S,1000C
14//! 4211 1
15//! 8320 1
16//! -4211 -1
17//! ```
18//!
19//! The first line is the quota — so many bytes (`S`), so many messages (`C`)
20//! — and every line after it is a **change**, appended by whoever made it.
21//! The usage is their sum. Appending a line is a short write at the end of a
22//! file, which is cheap and needs no coordination beyond not interleaving two
23//! of them; recomputing the balance from scratch is the slow path, taken when
24//! the ledger has grown long or is not to be trusted.
25//!
26//! That design means the file is **advisory and self-healing rather than
27//! authoritative**. It drifts — a message deleted by something that does not
28//! know about the file is never subtracted — and it is meant to, because
29//! `isStale` eventually says so and the total is recomputed. Treat a number
30//! from here as "what the store believed last time somebody checked", and do
31//! not build anything on it that a few kilobytes of drift would break.
32//!
33//! This file knows the format and nothing about the store. Walking the
34//! folders to recompute the total is `Store.recalculateQuota`.
35
36const std = @import("std");
37const Io = std.Io;
38const Dir = Io.Dir;
39const File = Io.File;
40const Allocator = std.mem.Allocator;
41const testing = std.testing;
42
43/// The name of the file, in the top-level maildir. There is exactly one for a
44/// whole Maildir++ store: a folder does not have a quota of its own.
45pub const filename = "maildirsize";
46
47/// What the store is allowed to hold. Absent limits are no limit.
48pub const Limits = struct {
49 /// The `S` limit: total bytes of message.
50 bytes: ?u64 = null,
51 /// The `C` limit: number of messages.
52 messages: ?u64 = null,
53
54 pub const none: Limits = .{};
55
56 /// Reads the first line of a `maildirsize` file: comma-separated items,
57 /// each a number followed by the letter saying what it counts.
58 ///
59 /// Never fails. An item in a letter nobody has defined is ignored, which
60 /// is what Courier does and what keeps a store readable when something
61 /// has written a limit this library has not heard of.
62 pub fn parse(text: []const u8) Limits {
63 var result: Limits = .none;
64 var it = std.mem.splitScalar(u8, std.mem.trim(u8, text, " \t\r"), ',');
65 while (it.next()) |raw| {
66 const item = std.mem.trim(u8, raw, " \t\r");
67 if (item.len < 2) continue;
68 const value = std.fmt.parseInt(u64, item[0 .. item.len - 1], 10) catch continue;
69 switch (item[item.len - 1]) {
70 'S', 's' => result.bytes = value,
71 'C', 'c' => result.messages = value,
72 else => {},
73 }
74 }
75 return result;
76 }
77
78 pub fn format(self: Limits, w: *Io.Writer) Io.Writer.Error!void {
79 var written = false;
80 if (self.bytes) |value| {
81 try w.print("{d}S", .{value});
82 written = true;
83 }
84 if (self.messages) |value| {
85 if (written) try w.writeByte(',');
86 try w.print("{d}C", .{value});
87 }
88 }
89
90 pub fn isSet(self: Limits) bool {
91 return self.bytes != null or self.messages != null;
92 }
93};
94
95/// A total, or a change to one. Signed, because the lines in the file are
96/// changes and a deletion is a negative one.
97pub const Usage = struct {
98 bytes: i64 = 0,
99 messages: i64 = 0,
100
101 pub const zero: Usage = .{};
102
103 pub fn plus(a: Usage, b: Usage) Usage {
104 return .{
105 .bytes = a.bytes +| b.bytes,
106 .messages = a.messages +| b.messages,
107 };
108 }
109
110 pub fn minus(a: Usage, b: Usage) Usage {
111 return .{
112 .bytes = a.bytes -| b.bytes,
113 .messages = a.messages -| b.messages,
114 };
115 }
116
117 /// The same total with negative components raised to zero. A negative
118 /// total is not a mailbox owing mail; it is the ledger having drifted,
119 /// and it is what a caller about to display a number wants.
120 pub fn clamped(self: Usage) Usage {
121 return .{
122 .bytes = @max(self.bytes, 0),
123 .messages = @max(self.messages, 0),
124 };
125 }
126
127 /// One line of the ledger.
128 pub fn format(self: Usage, w: *Io.Writer) Io.Writer.Error!void {
129 try w.print("{d} {d}", .{ self.bytes, self.messages });
130 }
131};
132
133/// Everything the text of a `maildirsize` file says. Separate from `State`
134/// because it is a pure function of the bytes, which is what makes it
135/// testable without a filesystem and fuzzable at all.
136pub const Ledger = struct {
137 limits: Limits,
138 /// The sum of every change in the file.
139 usage: Usage,
140 /// How many change lines there were.
141 records: usize,
142 /// A line that could not be read.
143 damaged: bool,
144
145 pub const empty: Ledger = .{
146 .limits = .none,
147 .usage = .zero,
148 .records = 0,
149 .damaged = false,
150 };
151};
152
153/// Reads the text of a `maildirsize` file: a quota on the first line and a
154/// change on every line after it.
155///
156/// Never fails. A line that cannot be read is counted and skipped, and
157/// `damaged` says so — which `State.isStale` then turns into a request to
158/// recompute the total, since a total missing some of its changes is worse
159/// than no total at all.
160pub fn parseLedger(text: []const u8) Ledger {
161 var ledger: Ledger = .empty;
162 var lines = std.mem.splitScalar(u8, text, '\n');
163 ledger.limits = .parse(lines.first());
164 while (lines.next()) |raw| {
165 const line = std.mem.trim(u8, raw, " \t\r");
166 if (line.len == 0) continue;
167 ledger.records += 1;
168 const change = parseRecord(line) orelse {
169 ledger.damaged = true;
170 continue;
171 };
172 ledger.usage = ledger.usage.plus(change);
173 }
174 return ledger;
175}
176
177/// Everything `maildirsize` says, plus what is needed to decide whether to
178/// believe it.
179pub const State = struct {
180 limits: Limits,
181 /// The sum of every change in the file.
182 usage: Usage,
183 /// How many change lines there were. One means the file was written by a
184 /// recalculation and nothing has been appended since.
185 records: usize,
186 /// How big `maildirsize` itself is. Courier's cue to recalculate: a long
187 /// ledger is a slow read on every delivery.
188 file_size: u64,
189 mtime: Io.Timestamp,
190 /// A line that could not be read. The file has been damaged, or written
191 /// by something with its own ideas, and the total is missing whatever
192 /// those lines said.
193 damaged: bool,
194
195 /// Whether the store is over one of its limits. False when no limit is
196 /// set, whatever the usage.
197 pub fn exceeded(self: State) bool {
198 if (self.limits.bytes) |limit| {
199 if (self.usage.bytes > 0 and @as(u64, @intCast(self.usage.bytes)) > limit) return true;
200 }
201 if (self.limits.messages) |limit| {
202 if (self.usage.messages > 0 and @as(u64, @intCast(self.usage.messages)) > limit) return true;
203 }
204 return false;
205 }
206
207 /// Whether the total should be recomputed rather than trusted.
208 ///
209 /// The age test applies only when the store is over quota, and that
210 /// asymmetry is deliberate: being wrongly under quota costs a little
211 /// unfairness, while being wrongly *over* it bounces mail, so the
212 /// expensive check is spent only on the answer that would refuse a
213 /// delivery.
214 pub fn isStale(self: State, io: Io, options: StaleOptions) bool {
215 if (self.damaged) return true;
216 if (self.file_size > options.max_file_size) return true;
217 if (self.records > options.max_records) return true;
218 if (self.exceeded()) {
219 const age = self.mtime.durationTo(Io.Timestamp.now(io, .real));
220 if (age.nanoseconds > options.max_age.nanoseconds) return true;
221 }
222 return false;
223 }
224};
225
226pub const StaleOptions = struct {
227 /// Courier's threshold, and there is no reason to differ from it.
228 max_file_size: u64 = 5120,
229 max_records: usize = 128,
230 max_age: Io.Duration = .{ .nanoseconds = 15 * 60 * std.time.ns_per_s },
231};
232
233/// The most of a `maildirsize` file that will be read. Anything longer is a
234/// ledger that should have been recalculated long ago.
235pub const read_limit: Io.Limit = .limited(1024 * 1024);
236
237pub const ReadError = Dir.ReadFileAllocError || Dir.StatFileError;
238
239/// Reads `maildirsize` from the top-level maildir, or returns null if there
240/// is none — which is how a store with no quota configured looks, and is not
241/// an error.
242pub fn read(dir: Dir, io: Io, gpa: Allocator) ReadError!?State {
243 const stat = dir.statFile(io, filename, .{}) catch |err| switch (err) {
244 error.FileNotFound => return null,
245 else => |e| return e,
246 };
247 const text = dir.readFileAlloc(io, filename, gpa, read_limit) catch |err| switch (err) {
248 error.FileNotFound => return null,
249 else => |e| return e,
250 };
251 defer gpa.free(text);
252
253 const ledger = parseLedger(text);
254 return .{
255 .limits = ledger.limits,
256 .usage = ledger.usage,
257 .records = ledger.records,
258 .damaged = ledger.damaged,
259 .file_size = stat.size,
260 .mtime = stat.mtime,
261 };
262}
263
264fn writeLedger(w: *Io.Writer, limits: Limits, usage: Usage) Io.Writer.Error!void {
265 try limits.format(w);
266 try w.writeByte('\n');
267 try usage.format(w);
268 try w.writeByte('\n');
269 try w.flush();
270}
271
272fn parseRecord(line: []const u8) ?Usage {
273 var fields = std.mem.tokenizeScalar(u8, line, ' ');
274 const bytes_text = fields.next() orelse return null;
275 const messages_text = fields.next() orelse return null;
276 if (fields.next() != null) return null;
277 return .{
278 .bytes = std.fmt.parseInt(i64, bytes_text, 10) catch return null,
279 .messages = std.fmt.parseInt(i64, messages_text, 10) catch return null,
280 };
281}
282
283pub const RecordError = File.OpenError || File.WritePositionalError || File.LengthError;
284
285/// Appends one change to the ledger: positive for a delivery, negative for a
286/// message removed.
287///
288/// Does nothing if there is no `maildirsize`, since a store with no quota
289/// configured is not one to start keeping a ledger for.
290///
291/// The write is made under an exclusive advisory lock, which the original
292/// design does not take — it relies on `O_APPEND` making a short write
293/// indivisible. Zig 0.16 has no way to ask for `O_APPEND`, so the position to
294/// write at has to be read first, and between reading it and writing there is
295/// a race that would have one process overwrite the other's line. The lock
296/// closes it for every process that also takes it, which is every process
297/// using this library; one that does not can still lose a line, and the
298/// ledger is self-healing for exactly that sort of reason.
299pub fn record(dir: Dir, io: Io, delta: Usage) RecordError!void {
300 var file = dir.openFile(io, filename, .{
301 .mode = .read_write,
302 .allow_directory = false,
303 .lock = .exclusive,
304 }) catch |err| switch (err) {
305 error.FileNotFound => return,
306 else => |e| return e,
307 };
308 defer file.close(io);
309
310 var buffer: [64]u8 = undefined;
311 var w: Io.Writer = .fixed(&buffer);
312 // Cannot overflow: two 64-bit integers and a space in a 64-byte buffer.
313 delta.format(&w) catch unreachable;
314 w.writeByte('\n') catch unreachable;
315
316 try file.writePositionalAll(io, w.buffered(), try file.length(io));
317}
318
319pub const WriteError = Dir.CreateFileAtomicError || File.Writer.Error ||
320 File.Atomic.ReplaceError || error{
321 /// See `Maildir.DeliverError.WriteFailed`.
322 WriteFailed,
323};
324
325/// Replaces `maildirsize` with a fresh ledger: the quota, and one line
326/// holding the whole total. This is what a recalculation writes.
327///
328/// Written to an unnamed file and renamed into place, so a reader either sees
329/// the old total or the new one and never a half-written file.
330pub fn write(dir: Dir, io: Io, limits: Limits, usage: Usage) WriteError!void {
331 var atomic = try dir.createFileAtomic(io, filename, .{
332 .replace = true,
333 .permissions = if (@hasDecl(File.Permissions, "fromMode"))
334 File.Permissions.fromMode(0o600)
335 else
336 .default_file,
337 });
338 defer atomic.deinit(io);
339
340 var buffer: [128]u8 = undefined;
341 var file_writer = atomic.file.writer(io, &buffer);
342 const w = &file_writer.interface;
343 writeLedger(w, limits, usage) catch return file_writer.err orelse error.WriteFailed;
344
345 try atomic.replace(io);
346}
347
348test "a quota line" {
349 const limits: Limits = .parse("10485760S,1000C");
350 try testing.expectEqual(@as(?u64, 10485760), limits.bytes);
351 try testing.expectEqual(@as(?u64, 1000), limits.messages);
352 try testing.expectFmt("10485760S,1000C", "{f}", .{limits});
353}
354
355test "a quota with only one kind of limit" {
356 try testing.expectEqual(@as(?u64, null), Limits.parse("1000C").bytes);
357 try testing.expectFmt("5S", "{f}", .{Limits{ .bytes = 5 }});
358 try testing.expectFmt("", "{f}", .{Limits.none});
359 try testing.expect(!Limits.none.isSet());
360}
361
362test "an unknown limit letter is ignored rather than fatal" {
363 const limits: Limits = .parse("100S,50X,20C");
364 try testing.expectEqual(@as(?u64, 100), limits.bytes);
365 try testing.expectEqual(@as(?u64, 20), limits.messages);
366}
367
368test "an empty quota line is no quota" {
369 try testing.expect(!Limits.parse("").isSet());
370}
371
372test "usage adds up and clamps" {
373 const total = Usage.zero
374 .plus(.{ .bytes = 4211, .messages = 1 })
375 .plus(.{ .bytes = -4211, .messages = -1 })
376 .plus(.{ .bytes = -100, .messages = -1 });
377 try testing.expectEqual(@as(i64, -100), total.bytes);
378 try testing.expectEqual(@as(i64, 0), total.clamped().bytes);
379 try testing.expectFmt("-100 -1", "{f}", .{total});
380}
381
382test "over quota only when a limit is set" {
383 const over: State = .{
384 .limits = .{ .bytes = 100 },
385 .usage = .{ .bytes = 200, .messages = 1 },
386 .records = 1,
387 .file_size = 32,
388 .mtime = .zero,
389 .damaged = false,
390 };
391 try testing.expect(over.exceeded());
392
393 var unlimited = over;
394 unlimited.limits = .none;
395 try testing.expect(!unlimited.exceeded());
396}
397
398test "a damaged ledger is always stale" {
399 const state: State = .{
400 .limits = .none,
401 .usage = .zero,
402 .records = 1,
403 .file_size = 32,
404 .mtime = .zero,
405 .damaged = true,
406 };
407 try testing.expect(state.isStale(std.Io.failing, .{}));
408}
409
410test "a long ledger is stale even when it is under quota" {
411 const state: State = .{
412 .limits = .none,
413 .usage = .zero,
414 .records = 1,
415 .file_size = 8192,
416 .mtime = .zero,
417 .damaged = false,
418 };
419 try testing.expect(state.isStale(std.Io.failing, .{}));
420}
421
422test "a record line" {
423 try testing.expectEqual(Usage{ .bytes = 4211, .messages = 1 }, parseRecord("4211 1").?);
424 try testing.expectEqual(Usage{ .bytes = -4211, .messages = -1 }, parseRecord("-4211 -1").?);
425 try testing.expectEqual(@as(?Usage, null), parseRecord("4211"));
426 try testing.expectEqual(@as(?Usage, null), parseRecord("4211 1 extra"));
427 try testing.expectEqual(@as(?Usage, null), parseRecord("lots 1"));
428}
429
430test "a whole ledger" {
431 const ledger = parseLedger("1000S,10C\n400 1\n300 1\n-400 -1\n");
432 try testing.expectEqual(@as(?u64, 1000), ledger.limits.bytes);
433 try testing.expectEqual(@as(i64, 300), ledger.usage.bytes);
434 try testing.expectEqual(@as(i64, 1), ledger.usage.messages);
435 try testing.expectEqual(@as(usize, 3), ledger.records);
436 try testing.expect(!ledger.damaged);
437}
438
439test "an empty file is an empty ledger, not an error" {
440 const ledger = parseLedger("");
441 try testing.expectEqual(Ledger.empty, ledger);
442}
443
444test "a ledger with a line nobody can read" {
445 const ledger = parseLedger("1000S\n400 1\n???\n");
446 try testing.expect(ledger.damaged);
447 try testing.expectEqual(@as(i64, 400), ledger.usage.bytes);
448 // The unreadable line is still counted, because it is still making the
449 // file longer and still a reason to recompute.
450 try testing.expectEqual(@as(usize, 2), ledger.records);
451}