// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! A Maildir++ store: a top-level maildir, the tree of folders beside it, and //! the quota file covering both. //! //! Use this rather than `Maildir` when there is more than one mailbox — an //! IMAP account, a mail client's local store, anything with an Inbox and a //! Sent and an Archive. A single maildir with nothing beside it needs nothing //! from here. //! //! ```zig //! var store: maildir.Store = try .create(.cwd(), io, "Maildir", .{}); //! defer store.close(io); //! //! var inbox = try store.inbox(io); //! defer inbox.close(io); //! //! var sent = try store.createFolder(io, &.{"Sent"}); //! defer sent.close(io); //! ``` //! //! Every `Maildir` handed out here owns its own directory handles and has to //! be closed by the caller. The store does not keep track of them, on purpose: //! a mail client holds one folder open for a long time and touches forty //! others briefly, and a store that cached handles would either hold forty //! file descriptors or make the caller say which. //! //! See `folder` for what a folder name may contain, which is the one place //! Maildir++ is genuinely restrictive: a folder name cannot contain a dot. 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; const Maildir = @import("Maildir.zig"); const Message = @import("Message.zig"); const folder = @import("folder.zig"); const quota = @import("quota.zig"); const unique = @import("unique.zig"); const Store = @This(); /// The top-level maildir, which is both a mailbox in its own right — IMAP /// calls it INBOX — and the directory the folders live in. dir: Dir, separator: u8, permissions: Dir.Permissions, /// Held inline so that a store needs no allocator and outlives nothing. hostname_buffer: [unique.max_hostname]u8, hostname_len: usize, pub const Options = Maildir.Options; pub const OpenError = Maildir.OpenError; pub const CreateError = Maildir.CreateError; /// Opens an existing store. The top-level maildir must already be one. pub fn open(parent: Dir, io: Io, sub_path: []const u8, options: Options) OpenError!Store { const dir = try parent.openDir(io, sub_path, .{ .iterate = true }); errdefer dir.close(io); // Opening it as a maildir and closing it again is the cheapest way to // insist that `tmp`, `new` and `cur` are all there. A store whose top // level is not a maildir is not a store, and finding that out now beats // finding it out on the first delivery. const probe = try Maildir.openDir(dir, io, options); Dir.closeMany(io, &.{ probe.tmp, probe.new, probe.cur }); return init(dir, options); } /// Creates the store, or opens one that is already there. pub fn create(parent: Dir, io: Io, sub_path: []const u8, options: Options) CreateError!Store { const maildir = try Maildir.create(parent, io, sub_path, options); // The maildir's own handle becomes the store's; only the three // subdirectories are let go. Dir.closeMany(io, &.{ maildir.tmp, maildir.new, maildir.cur }); return init(maildir.dir, options); } fn init(dir: Dir, options: Options) Store { var self: Store = .{ .dir = dir, .separator = options.separator, .permissions = options.permissions, .hostname_buffer = undefined, .hostname_len = 0, }; const host = options.hostname orelse unique.systemHostname(&self.hostname_buffer); self.hostname_len = @min(host.len, unique.max_hostname); // `host` may already be `hostname_buffer`, which `@memcpy` forbids // overlapping; copying it to itself is not needed either way. if (host.ptr != &self.hostname_buffer) { @memcpy(self.hostname_buffer[0..self.hostname_len], host[0..self.hostname_len]); } return self; } pub fn close(self: *Store, io: Io) void { self.dir.close(io); self.* = undefined; } pub fn hostname(self: *const Store) []const u8 { return self.hostname_buffer[0..self.hostname_len]; } fn maildirOptions(self: *const Store) Maildir.Options { return .{ .separator = self.separator, .hostname = self.hostname(), .permissions = self.permissions, }; } /// The top-level maildir as a mailbox: IMAP's INBOX. /// /// The returned `Maildir` has directory handles of its own and must be closed /// separately from the store. pub fn inbox(self: *const Store, io: Io) OpenError!Maildir { return Maildir.open(self.dir, io, ".", self.maildirOptions()); } pub const FolderError = folder.Error; /// Opens a folder by its components: `&.{"Work", "Reports"}` is the folder /// Maildir++ stores in `.Work.Reports`. pub fn openFolder( self: *const Store, io: Io, path: []const []const u8, ) (OpenError || FolderError)!Maildir { var buffer: Maildir.NameBuffer = undefined; const dirname = try folder.bufComponents(&buffer, path); return Maildir.open(self.dir, io, dirname, self.maildirOptions()); } /// `openFolder`, for a caller holding the path as one delimited string — /// `"Work/Reports"` with `/`, which is the shape an IMAP server has it in. pub fn openFolderPath( self: *const Store, io: Io, path: []const u8, path_delimiter: u8, ) (OpenError || FolderError)!Maildir { var buffer: Maildir.NameBuffer = undefined; const dirname = try folder.bufPath(&buffer, path, path_delimiter); return Maildir.open(self.dir, io, dirname, self.maildirOptions()); } /// Opens a folder by the directory name it has on disk, `.Work.Reports`. This /// is what `folders` hands back, so it is what to use when walking the tree. pub fn openFolderDirname( self: *const Store, io: Io, dirname: []const u8, ) OpenError!Maildir { return Maildir.open(self.dir, io, dirname, self.maildirOptions()); } pub const CreateFolderError = CreateError || FolderError || File.OpenError; /// Creates a folder, and every folder above it that is not there yet. /// /// Creating the ancestors is not in the specification, which leaves a folder /// whose parent is missing undefined. It is what Dovecot does, and the /// alternative — a store where `.Work.Reports` exists and `.Work` does not — /// is one that IMAP cannot describe, since `LIST` would report a folder with /// no parent. pub fn createFolder( self: *const Store, io: Io, path: []const []const u8, ) CreateFolderError!Maildir { if (path.len == 0) return error.EmptyPath; var buffer: Maildir.NameBuffer = undefined; // Each prefix in turn, so `.Work` is made before `.Work.Reports`. var depth: usize = 1; while (depth < path.len) : (depth += 1) { const dirname = try folder.bufComponents(&buffer, path[0..depth]); var ancestor = try self.createFolderDirname(io, dirname); ancestor.close(io); } const dirname = try folder.bufComponents(&buffer, path); return self.createFolderDirname(io, dirname); } /// `createFolder`, for a delimited path. pub fn createFolderPath( self: *const Store, io: Io, path: []const u8, path_delimiter: u8, ) CreateFolderError!Maildir { var components: [max_depth][]const u8 = undefined; var count: usize = 0; var it = std.mem.splitScalar(u8, path, path_delimiter); while (it.next()) |component| { if (count == components.len) return error.NameTooLong; components[count] = component; count += 1; } return self.createFolder(io, components[0..count]); } /// As deep as a folder path may be. A name only has room for so many /// components, and this is well past what a mail store has ever needed. pub const max_depth = 32; fn createFolderDirname( self: *const Store, io: Io, dirname: []const u8, ) CreateFolderError!Maildir { const maildir = try Maildir.create(self.dir, io, dirname, self.maildirOptions()); // The marker that says this is a folder. Courier will not treat a // directory without one as a mailbox. if (maildir.dir.createFile(io, folder.marker, .{ .exclusive = true })) |file| { file.close(io); } else |err| switch (err) { error.PathAlreadyExists => {}, else => |e| { var open_maildir = maildir; open_maildir.close(io); return e; }, } return maildir; } pub const DeleteFolderError = Dir.DeleteTreeError || FolderError || ListError || error{ /// The folder has folders below it and `recursive` was not set. /// Deleting it anyway would leave them unreachable through IMAP while /// still occupying the store. FolderNotEmpty, }; pub const DeleteFolderOptions = struct { /// Delete every folder below this one as well. recursive: bool = false, }; /// Deletes a folder and the messages in it. pub fn deleteFolder( self: *const Store, gpa: Allocator, io: Io, path: []const []const u8, options: DeleteFolderOptions, ) DeleteFolderError!void { var buffer: Maildir.NameBuffer = undefined; const dirname = try folder.bufComponents(&buffer, path); var list = try self.folders(gpa, io); defer list.deinit(gpa); for (list.names) |name| { if (!folder.isBelow(name, dirname)) continue; if (!options.recursive) return error.FolderNotEmpty; try self.dir.deleteTree(io, name); } try self.dir.deleteTree(io, dirname); } pub const ListError = Dir.Iterator.Error || Allocator.Error; /// Every folder in the store, by the directory name it has on disk. /// /// Sorted, which makes the parent of a folder come before it and lets a /// caller build a tree in one pass. The top-level maildir is not in the list: /// it is not a folder. pub const Folders = struct { names: [][]u8, pub fn deinit(self: *Folders, gpa: Allocator) void { for (self.names) |name| gpa.free(name); gpa.free(self.names); self.* = undefined; } }; pub fn folders(self: *const Store, gpa: Allocator, io: Io) ListError!Folders { var names: std.ArrayList([]u8) = .empty; errdefer { for (names.items) |name| gpa.free(name); names.deinit(gpa); } var it = self.dir.iterate(); while (try it.next(io)) |entry| { switch (entry.kind) { .directory, .sym_link, .unknown => {}, else => continue, } if (!folder.isFolder(entry.name)) continue; try names.append(gpa, try gpa.dupe(u8, entry.name)); } const owned = try names.toOwnedSlice(gpa); std.mem.sort([]u8, owned, {}, struct { fn lessThan(_: void, a: []u8, b: []u8) bool { return std.mem.order(u8, a, b) == .lt; } }.lessThan); return .{ .names = owned }; } /// Whether a folder exists. pub fn hasFolder( self: *const Store, io: Io, path: []const []const u8, ) (FolderError || Dir.StatFileError)!bool { var buffer: Maildir.NameBuffer = undefined; const dirname = try folder.bufComponents(&buffer, path); const stat = self.dir.statFile(io, dirname, .{}) catch |err| switch (err) { error.FileNotFound, error.NotDir => return false, else => |e| return e, }; return stat.kind == .directory; } // -- quota ------------------------------------------------------------------- /// What `maildirsize` says, or null if the store has no quota file — which is /// how a store with no quota configured looks, and is not an error. /// /// The number is the ledger's, not the filesystem's: see `quota` for why that /// is a running total that drifts, and use `isStale` on the result to decide /// whether to spend a `recalculateQuota` on it. pub fn quotaState(self: *const Store, gpa: Allocator, io: Io) quota.ReadError!?quota.State { return quota.read(self.dir, io, gpa); } /// Notes a change to the store's usage in `maildirsize`: positive for a /// delivery, negative for a message removed. Does nothing if the store has no /// quota file. pub fn recordUsage(self: *const Store, io: Io, delta: quota.Usage) quota.RecordError!void { return quota.record(self.dir, io, delta); } /// Sets the quota, replacing `maildirsize` with a ledger holding the given /// total. Use `recalculateQuota` to have the total worked out. pub fn setQuota( self: *const Store, io: Io, limits: quota.Limits, usage: quota.Usage, ) quota.WriteError!void { return quota.write(self.dir, io, limits, usage); } pub const RecalculateError = ListError || OpenError || Dir.StatFileError || quota.ReadError || quota.WriteError; /// Adds up every message in the store and writes the total to `maildirsize`. /// /// This is the slow path the ledger exists to avoid: it opens every folder /// and lists `new` and `cur` in each. Messages in `tmp` are not counted, /// since nothing there is a message yet. /// /// A message whose name carries `,S=` is counted from its name; the rest are /// `stat`ed. Keeping `record_size` on at delivery is therefore what makes /// this a directory walk rather than a `stat` of every message in the store. /// /// The limits already in `maildirsize` are kept. If there is no quota file /// yet, one is written with no limits in it, which records the usage without /// imposing anything. pub fn recalculateQuota(self: *const Store, gpa: Allocator, io: Io) RecalculateError!quota.Usage { const existing = try quota.read(self.dir, io, gpa); const limits: quota.Limits = if (existing) |state| state.limits else .none; var total: quota.Usage = .zero; var root = try self.inbox(io); defer root.close(io); total = total.plus(try measure(&root, io)); var list = try self.folders(gpa, io); defer list.deinit(gpa); for (list.names) |name| { var maildir = self.openFolderDirname(io, name) catch |err| switch (err) { // A directory that looks like a folder and is not one is not a // reason to abandon the count. error.NotAMaildir, error.FileNotFound => continue, else => |e| return e, }; defer maildir.close(io); total = total.plus(try measure(&maildir, io)); } try quota.write(self.dir, io, limits, total); return total; } fn measure(maildir: *const Maildir, io: Io) (Dir.Iterator.Error || Dir.StatFileError)!quota.Usage { var total: quota.Usage = .zero; for ([_]Maildir.Subdir{ .new, .cur }) |which| { var it = maildir.iterate(which); while (try it.next(io)) |message| { const bytes = message.size(maildir, io) catch |err| switch (err) { // Removed between the listing and the stat: it is not in the // mailbox any more, so it does not count. error.FileNotFound => continue, else => |e| return e, }; total = total.plus(.{ .bytes = @intCast(bytes), .messages = 1 }); } } return total; }