// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! `maildirsize`: how much mail is in a Maildir++ store, and how much is //! allowed. //! //! Totalling a mail store means listing every folder and adding up every //! message, which is fine once and ruinous on every delivery. Courier's //! answer is a small file in the top-level maildir that holds the running //! total, written like a ledger rather than a balance: //! //! ```text //! 10485760S,1000C //! 4211 1 //! 8320 1 //! -4211 -1 //! ``` //! //! The first line is the quota — so many bytes (`S`), so many messages (`C`) //! — and every line after it is a **change**, appended by whoever made it. //! The usage is their sum. Appending a line is a short write at the end of a //! file, which is cheap and needs no coordination beyond not interleaving two //! of them; recomputing the balance from scratch is the slow path, taken when //! the ledger has grown long or is not to be trusted. //! //! That design means the file is **advisory and self-healing rather than //! authoritative**. It drifts — a message deleted by something that does not //! know about the file is never subtracted — and it is meant to, because //! `isStale` eventually says so and the total is recomputed. Treat a number //! from here as "what the store believed last time somebody checked", and do //! not build anything on it that a few kilobytes of drift would break. //! //! This file knows the format and nothing about the store. Walking the //! folders to recompute the total is `Store.recalculateQuota`. const std = @import("std"); const Io = std.Io; const Dir = Io.Dir; const File = Io.File; const Allocator = std.mem.Allocator; const testing = std.testing; /// The name of the file, in the top-level maildir. There is exactly one for a /// whole Maildir++ store: a folder does not have a quota of its own. pub const filename = "maildirsize"; /// What the store is allowed to hold. Absent limits are no limit. pub const Limits = struct { /// The `S` limit: total bytes of message. bytes: ?u64 = null, /// The `C` limit: number of messages. messages: ?u64 = null, pub const none: Limits = .{}; /// Reads the first line of a `maildirsize` file: comma-separated items, /// each a number followed by the letter saying what it counts. /// /// Never fails. An item in a letter nobody has defined is ignored, which /// is what Courier does and what keeps a store readable when something /// has written a limit this library has not heard of. pub fn parse(text: []const u8) Limits { var result: Limits = .none; var it = std.mem.splitScalar(u8, std.mem.trim(u8, text, " \t\r"), ','); while (it.next()) |raw| { const item = std.mem.trim(u8, raw, " \t\r"); if (item.len < 2) continue; const value = std.fmt.parseInt(u64, item[0 .. item.len - 1], 10) catch continue; switch (item[item.len - 1]) { 'S', 's' => result.bytes = value, 'C', 'c' => result.messages = value, else => {}, } } return result; } pub fn format(self: Limits, w: *Io.Writer) Io.Writer.Error!void { var written = false; if (self.bytes) |value| { try w.print("{d}S", .{value}); written = true; } if (self.messages) |value| { if (written) try w.writeByte(','); try w.print("{d}C", .{value}); } } pub fn isSet(self: Limits) bool { return self.bytes != null or self.messages != null; } }; /// A total, or a change to one. Signed, because the lines in the file are /// changes and a deletion is a negative one. pub const Usage = struct { bytes: i64 = 0, messages: i64 = 0, pub const zero: Usage = .{}; pub fn plus(a: Usage, b: Usage) Usage { return .{ .bytes = a.bytes +| b.bytes, .messages = a.messages +| b.messages, }; } pub fn minus(a: Usage, b: Usage) Usage { return .{ .bytes = a.bytes -| b.bytes, .messages = a.messages -| b.messages, }; } /// The same total with negative components raised to zero. A negative /// total is not a mailbox owing mail; it is the ledger having drifted, /// and it is what a caller about to display a number wants. pub fn clamped(self: Usage) Usage { return .{ .bytes = @max(self.bytes, 0), .messages = @max(self.messages, 0), }; } /// One line of the ledger. pub fn format(self: Usage, w: *Io.Writer) Io.Writer.Error!void { try w.print("{d} {d}", .{ self.bytes, self.messages }); } }; /// Everything the text of a `maildirsize` file says. Separate from `State` /// because it is a pure function of the bytes, which is what makes it /// testable without a filesystem and fuzzable at all. pub const Ledger = struct { limits: Limits, /// The sum of every change in the file. usage: Usage, /// How many change lines there were. records: usize, /// A line that could not be read. damaged: bool, pub const empty: Ledger = .{ .limits = .none, .usage = .zero, .records = 0, .damaged = false, }; }; /// Reads the text of a `maildirsize` file: a quota on the first line and a /// change on every line after it. /// /// Never fails. A line that cannot be read is counted and skipped, and /// `damaged` says so — which `State.isStale` then turns into a request to /// recompute the total, since a total missing some of its changes is worse /// than no total at all. pub fn parseLedger(text: []const u8) Ledger { var ledger: Ledger = .empty; var lines = std.mem.splitScalar(u8, text, '\n'); ledger.limits = .parse(lines.first()); while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; ledger.records += 1; const change = parseRecord(line) orelse { ledger.damaged = true; continue; }; ledger.usage = ledger.usage.plus(change); } return ledger; } /// Everything `maildirsize` says, plus what is needed to decide whether to /// believe it. pub const State = struct { limits: Limits, /// The sum of every change in the file. usage: Usage, /// How many change lines there were. One means the file was written by a /// recalculation and nothing has been appended since. records: usize, /// How big `maildirsize` itself is. Courier's cue to recalculate: a long /// ledger is a slow read on every delivery. file_size: u64, mtime: Io.Timestamp, /// A line that could not be read. The file has been damaged, or written /// by something with its own ideas, and the total is missing whatever /// those lines said. damaged: bool, /// Whether the store is over one of its limits. False when no limit is /// set, whatever the usage. pub fn exceeded(self: State) bool { if (self.limits.bytes) |limit| { if (self.usage.bytes > 0 and @as(u64, @intCast(self.usage.bytes)) > limit) return true; } if (self.limits.messages) |limit| { if (self.usage.messages > 0 and @as(u64, @intCast(self.usage.messages)) > limit) return true; } return false; } /// Whether the total should be recomputed rather than trusted. /// /// The age test applies only when the store is over quota, and that /// asymmetry is deliberate: being wrongly under quota costs a little /// unfairness, while being wrongly *over* it bounces mail, so the /// expensive check is spent only on the answer that would refuse a /// delivery. pub fn isStale(self: State, io: Io, options: StaleOptions) bool { if (self.damaged) return true; if (self.file_size > options.max_file_size) return true; if (self.records > options.max_records) return true; if (self.exceeded()) { const age = self.mtime.durationTo(Io.Timestamp.now(io, .real)); if (age.nanoseconds > options.max_age.nanoseconds) return true; } return false; } }; pub const StaleOptions = struct { /// Courier's threshold, and there is no reason to differ from it. max_file_size: u64 = 5120, max_records: usize = 128, max_age: Io.Duration = .{ .nanoseconds = 15 * 60 * std.time.ns_per_s }, }; /// The most of a `maildirsize` file that will be read. Anything longer is a /// ledger that should have been recalculated long ago. pub const read_limit: Io.Limit = .limited(1024 * 1024); pub const ReadError = Dir.ReadFileAllocError || Dir.StatFileError; /// Reads `maildirsize` from the top-level maildir, or returns null if there /// is none — which is how a store with no quota configured looks, and is not /// an error. pub fn read(dir: Dir, io: Io, gpa: Allocator) ReadError!?State { const stat = dir.statFile(io, filename, .{}) catch |err| switch (err) { error.FileNotFound => return null, else => |e| return e, }; const text = dir.readFileAlloc(io, filename, gpa, read_limit) catch |err| switch (err) { error.FileNotFound => return null, else => |e| return e, }; defer gpa.free(text); const ledger = parseLedger(text); return .{ .limits = ledger.limits, .usage = ledger.usage, .records = ledger.records, .damaged = ledger.damaged, .file_size = stat.size, .mtime = stat.mtime, }; } fn writeLedger(w: *Io.Writer, limits: Limits, usage: Usage) Io.Writer.Error!void { try limits.format(w); try w.writeByte('\n'); try usage.format(w); try w.writeByte('\n'); try w.flush(); } fn parseRecord(line: []const u8) ?Usage { var fields = std.mem.tokenizeScalar(u8, line, ' '); const bytes_text = fields.next() orelse return null; const messages_text = fields.next() orelse return null; if (fields.next() != null) return null; return .{ .bytes = std.fmt.parseInt(i64, bytes_text, 10) catch return null, .messages = std.fmt.parseInt(i64, messages_text, 10) catch return null, }; } pub const RecordError = File.OpenError || File.WritePositionalError || File.LengthError; /// Appends one change to the ledger: positive for a delivery, negative for a /// message removed. /// /// Does nothing if there is no `maildirsize`, since a store with no quota /// configured is not one to start keeping a ledger for. /// /// The write is made under an exclusive advisory lock, which the original /// design does not take — it relies on `O_APPEND` making a short write /// indivisible. Zig 0.16 has no way to ask for `O_APPEND`, so the position to /// write at has to be read first, and between reading it and writing there is /// a race that would have one process overwrite the other's line. The lock /// closes it for every process that also takes it, which is every process /// using this library; one that does not can still lose a line, and the /// ledger is self-healing for exactly that sort of reason. pub fn record(dir: Dir, io: Io, delta: Usage) RecordError!void { var file = dir.openFile(io, filename, .{ .mode = .read_write, .allow_directory = false, .lock = .exclusive, }) catch |err| switch (err) { error.FileNotFound => return, else => |e| return e, }; defer file.close(io); var buffer: [64]u8 = undefined; var w: Io.Writer = .fixed(&buffer); // Cannot overflow: two 64-bit integers and a space in a 64-byte buffer. delta.format(&w) catch unreachable; w.writeByte('\n') catch unreachable; try file.writePositionalAll(io, w.buffered(), try file.length(io)); } pub const WriteError = Dir.CreateFileAtomicError || File.Writer.Error || File.Atomic.ReplaceError || error{ /// See `Maildir.DeliverError.WriteFailed`. WriteFailed, }; /// Replaces `maildirsize` with a fresh ledger: the quota, and one line /// holding the whole total. This is what a recalculation writes. /// /// Written to an unnamed file and renamed into place, so a reader either sees /// the old total or the new one and never a half-written file. pub fn write(dir: Dir, io: Io, limits: Limits, usage: Usage) WriteError!void { var atomic = try dir.createFileAtomic(io, filename, .{ .replace = true, .permissions = if (@hasDecl(File.Permissions, "fromMode")) File.Permissions.fromMode(0o600) else .default_file, }); defer atomic.deinit(io); var buffer: [128]u8 = undefined; var file_writer = atomic.file.writer(io, &buffer); const w = &file_writer.interface; writeLedger(w, limits, usage) catch return file_writer.err orelse error.WriteFailed; try atomic.replace(io); } test "a quota line" { const limits: Limits = .parse("10485760S,1000C"); try testing.expectEqual(@as(?u64, 10485760), limits.bytes); try testing.expectEqual(@as(?u64, 1000), limits.messages); try testing.expectFmt("10485760S,1000C", "{f}", .{limits}); } test "a quota with only one kind of limit" { try testing.expectEqual(@as(?u64, null), Limits.parse("1000C").bytes); try testing.expectFmt("5S", "{f}", .{Limits{ .bytes = 5 }}); try testing.expectFmt("", "{f}", .{Limits.none}); try testing.expect(!Limits.none.isSet()); } test "an unknown limit letter is ignored rather than fatal" { const limits: Limits = .parse("100S,50X,20C"); try testing.expectEqual(@as(?u64, 100), limits.bytes); try testing.expectEqual(@as(?u64, 20), limits.messages); } test "an empty quota line is no quota" { try testing.expect(!Limits.parse("").isSet()); } test "usage adds up and clamps" { const total = Usage.zero .plus(.{ .bytes = 4211, .messages = 1 }) .plus(.{ .bytes = -4211, .messages = -1 }) .plus(.{ .bytes = -100, .messages = -1 }); try testing.expectEqual(@as(i64, -100), total.bytes); try testing.expectEqual(@as(i64, 0), total.clamped().bytes); try testing.expectFmt("-100 -1", "{f}", .{total}); } test "over quota only when a limit is set" { const over: State = .{ .limits = .{ .bytes = 100 }, .usage = .{ .bytes = 200, .messages = 1 }, .records = 1, .file_size = 32, .mtime = .zero, .damaged = false, }; try testing.expect(over.exceeded()); var unlimited = over; unlimited.limits = .none; try testing.expect(!unlimited.exceeded()); } test "a damaged ledger is always stale" { const state: State = .{ .limits = .none, .usage = .zero, .records = 1, .file_size = 32, .mtime = .zero, .damaged = true, }; try testing.expect(state.isStale(std.Io.failing, .{})); } test "a long ledger is stale even when it is under quota" { const state: State = .{ .limits = .none, .usage = .zero, .records = 1, .file_size = 8192, .mtime = .zero, .damaged = false, }; try testing.expect(state.isStale(std.Io.failing, .{})); } test "a record line" { try testing.expectEqual(Usage{ .bytes = 4211, .messages = 1 }, parseRecord("4211 1").?); try testing.expectEqual(Usage{ .bytes = -4211, .messages = -1 }, parseRecord("-4211 -1").?); try testing.expectEqual(@as(?Usage, null), parseRecord("4211")); try testing.expectEqual(@as(?Usage, null), parseRecord("4211 1 extra")); try testing.expectEqual(@as(?Usage, null), parseRecord("lots 1")); } test "a whole ledger" { const ledger = parseLedger("1000S,10C\n400 1\n300 1\n-400 -1\n"); try testing.expectEqual(@as(?u64, 1000), ledger.limits.bytes); try testing.expectEqual(@as(i64, 300), ledger.usage.bytes); try testing.expectEqual(@as(i64, 1), ledger.usage.messages); try testing.expectEqual(@as(usize, 3), ledger.records); try testing.expect(!ledger.damaged); } test "an empty file is an empty ledger, not an error" { const ledger = parseLedger(""); try testing.expectEqual(Ledger.empty, ledger); } test "a ledger with a line nobody can read" { const ledger = parseLedger("1000S\n400 1\n???\n"); try testing.expect(ledger.damaged); try testing.expectEqual(@as(i64, 400), ledger.usage.bytes); // The unreadable line is still counted, because it is still making the // file longer and still a reason to recompute. try testing.expectEqual(@as(usize, 2), ledger.records); }