A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
15 kB
418 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! A Maildir++ store: a top-level maildir, the tree of folders beside it, and
5//! the quota file covering both.
6//!
7//! Use this rather than `Maildir` when there is more than one mailbox — an
8//! IMAP account, a mail client's local store, anything with an Inbox and a
9//! Sent and an Archive. A single maildir with nothing beside it needs nothing
10//! from here.
11//!
12//! ```zig
13//! var store: maildir.Store = try .create(.cwd(), io, "Maildir", .{});
14//! defer store.close(io);
15//!
16//! var inbox = try store.inbox(io);
17//! defer inbox.close(io);
18//!
19//! var sent = try store.createFolder(io, &.{"Sent"});
20//! defer sent.close(io);
21//! ```
22//!
23//! Every `Maildir` handed out here owns its own directory handles and has to
24//! be closed by the caller. The store does not keep track of them, on purpose:
25//! a mail client holds one folder open for a long time and touches forty
26//! others briefly, and a store that cached handles would either hold forty
27//! file descriptors or make the caller say which.
28//!
29//! See `folder` for what a folder name may contain, which is the one place
30//! Maildir++ is genuinely restrictive: a folder name cannot contain a dot.
31
32const std = @import("std");
33const Io = std.Io;
34const Dir = Io.Dir;
35const File = Io.File;
36const Allocator = std.mem.Allocator;
37const testing = std.testing;
38
39const Maildir = @import("Maildir.zig");
40const Message = @import("Message.zig");
41const folder = @import("folder.zig");
42const quota = @import("quota.zig");
43const unique = @import("unique.zig");
44
45const Store = @This();
46
47/// The top-level maildir, which is both a mailbox in its own right — IMAP
48/// calls it INBOX — and the directory the folders live in.
49dir: Dir,
50separator: u8,
51permissions: Dir.Permissions,
52/// Held inline so that a store needs no allocator and outlives nothing.
53hostname_buffer: [unique.max_hostname]u8,
54hostname_len: usize,
55
56pub const Options = Maildir.Options;
57
58pub const OpenError = Maildir.OpenError;
59pub const CreateError = Maildir.CreateError;
60
61/// Opens an existing store. The top-level maildir must already be one.
62pub fn open(parent: Dir, io: Io, sub_path: []const u8, options: Options) OpenError!Store {
63 const dir = try parent.openDir(io, sub_path, .{ .iterate = true });
64 errdefer dir.close(io);
65
66 // Opening it as a maildir and closing it again is the cheapest way to
67 // insist that `tmp`, `new` and `cur` are all there. A store whose top
68 // level is not a maildir is not a store, and finding that out now beats
69 // finding it out on the first delivery.
70 const probe = try Maildir.openDir(dir, io, options);
71 Dir.closeMany(io, &.{ probe.tmp, probe.new, probe.cur });
72
73 return init(dir, options);
74}
75
76/// Creates the store, or opens one that is already there.
77pub fn create(parent: Dir, io: Io, sub_path: []const u8, options: Options) CreateError!Store {
78 const maildir = try Maildir.create(parent, io, sub_path, options);
79 // The maildir's own handle becomes the store's; only the three
80 // subdirectories are let go.
81 Dir.closeMany(io, &.{ maildir.tmp, maildir.new, maildir.cur });
82 return init(maildir.dir, options);
83}
84
85fn init(dir: Dir, options: Options) Store {
86 var self: Store = .{
87 .dir = dir,
88 .separator = options.separator,
89 .permissions = options.permissions,
90 .hostname_buffer = undefined,
91 .hostname_len = 0,
92 };
93 const host = options.hostname orelse unique.systemHostname(&self.hostname_buffer);
94 self.hostname_len = @min(host.len, unique.max_hostname);
95 // `host` may already be `hostname_buffer`, which `@memcpy` forbids
96 // overlapping; copying it to itself is not needed either way.
97 if (host.ptr != &self.hostname_buffer) {
98 @memcpy(self.hostname_buffer[0..self.hostname_len], host[0..self.hostname_len]);
99 }
100 return self;
101}
102
103pub fn close(self: *Store, io: Io) void {
104 self.dir.close(io);
105 self.* = undefined;
106}
107
108pub fn hostname(self: *const Store) []const u8 {
109 return self.hostname_buffer[0..self.hostname_len];
110}
111
112fn maildirOptions(self: *const Store) Maildir.Options {
113 return .{
114 .separator = self.separator,
115 .hostname = self.hostname(),
116 .permissions = self.permissions,
117 };
118}
119
120/// The top-level maildir as a mailbox: IMAP's INBOX.
121///
122/// The returned `Maildir` has directory handles of its own and must be closed
123/// separately from the store.
124pub fn inbox(self: *const Store, io: Io) OpenError!Maildir {
125 return Maildir.open(self.dir, io, ".", self.maildirOptions());
126}
127
128pub const FolderError = folder.Error;
129
130/// Opens a folder by its components: `&.{"Work", "Reports"}` is the folder
131/// Maildir++ stores in `.Work.Reports`.
132pub fn openFolder(
133 self: *const Store,
134 io: Io,
135 path: []const []const u8,
136) (OpenError || FolderError)!Maildir {
137 var buffer: Maildir.NameBuffer = undefined;
138 const dirname = try folder.bufComponents(&buffer, path);
139 return Maildir.open(self.dir, io, dirname, self.maildirOptions());
140}
141
142/// `openFolder`, for a caller holding the path as one delimited string —
143/// `"Work/Reports"` with `/`, which is the shape an IMAP server has it in.
144pub fn openFolderPath(
145 self: *const Store,
146 io: Io,
147 path: []const u8,
148 path_delimiter: u8,
149) (OpenError || FolderError)!Maildir {
150 var buffer: Maildir.NameBuffer = undefined;
151 const dirname = try folder.bufPath(&buffer, path, path_delimiter);
152 return Maildir.open(self.dir, io, dirname, self.maildirOptions());
153}
154
155/// Opens a folder by the directory name it has on disk, `.Work.Reports`. This
156/// is what `folders` hands back, so it is what to use when walking the tree.
157pub fn openFolderDirname(
158 self: *const Store,
159 io: Io,
160 dirname: []const u8,
161) OpenError!Maildir {
162 return Maildir.open(self.dir, io, dirname, self.maildirOptions());
163}
164
165pub const CreateFolderError = CreateError || FolderError || File.OpenError;
166
167/// Creates a folder, and every folder above it that is not there yet.
168///
169/// Creating the ancestors is not in the specification, which leaves a folder
170/// whose parent is missing undefined. It is what Dovecot does, and the
171/// alternative — a store where `.Work.Reports` exists and `.Work` does not —
172/// is one that IMAP cannot describe, since `LIST` would report a folder with
173/// no parent.
174pub fn createFolder(
175 self: *const Store,
176 io: Io,
177 path: []const []const u8,
178) CreateFolderError!Maildir {
179 if (path.len == 0) return error.EmptyPath;
180
181 var buffer: Maildir.NameBuffer = undefined;
182 // Each prefix in turn, so `.Work` is made before `.Work.Reports`.
183 var depth: usize = 1;
184 while (depth < path.len) : (depth += 1) {
185 const dirname = try folder.bufComponents(&buffer, path[0..depth]);
186 var ancestor = try self.createFolderDirname(io, dirname);
187 ancestor.close(io);
188 }
189 const dirname = try folder.bufComponents(&buffer, path);
190 return self.createFolderDirname(io, dirname);
191}
192
193/// `createFolder`, for a delimited path.
194pub fn createFolderPath(
195 self: *const Store,
196 io: Io,
197 path: []const u8,
198 path_delimiter: u8,
199) CreateFolderError!Maildir {
200 var components: [max_depth][]const u8 = undefined;
201 var count: usize = 0;
202 var it = std.mem.splitScalar(u8, path, path_delimiter);
203 while (it.next()) |component| {
204 if (count == components.len) return error.NameTooLong;
205 components[count] = component;
206 count += 1;
207 }
208 return self.createFolder(io, components[0..count]);
209}
210
211/// As deep as a folder path may be. A name only has room for so many
212/// components, and this is well past what a mail store has ever needed.
213pub const max_depth = 32;
214
215fn createFolderDirname(
216 self: *const Store,
217 io: Io,
218 dirname: []const u8,
219) CreateFolderError!Maildir {
220 const maildir = try Maildir.create(self.dir, io, dirname, self.maildirOptions());
221
222 // The marker that says this is a folder. Courier will not treat a
223 // directory without one as a mailbox.
224 if (maildir.dir.createFile(io, folder.marker, .{ .exclusive = true })) |file| {
225 file.close(io);
226 } else |err| switch (err) {
227 error.PathAlreadyExists => {},
228 else => |e| {
229 var open_maildir = maildir;
230 open_maildir.close(io);
231 return e;
232 },
233 }
234 return maildir;
235}
236
237pub const DeleteFolderError = Dir.DeleteTreeError || FolderError || ListError ||
238 error{
239 /// The folder has folders below it and `recursive` was not set.
240 /// Deleting it anyway would leave them unreachable through IMAP while
241 /// still occupying the store.
242 FolderNotEmpty,
243 };
244
245pub const DeleteFolderOptions = struct {
246 /// Delete every folder below this one as well.
247 recursive: bool = false,
248};
249
250/// Deletes a folder and the messages in it.
251pub fn deleteFolder(
252 self: *const Store,
253 gpa: Allocator,
254 io: Io,
255 path: []const []const u8,
256 options: DeleteFolderOptions,
257) DeleteFolderError!void {
258 var buffer: Maildir.NameBuffer = undefined;
259 const dirname = try folder.bufComponents(&buffer, path);
260
261 var list = try self.folders(gpa, io);
262 defer list.deinit(gpa);
263
264 for (list.names) |name| {
265 if (!folder.isBelow(name, dirname)) continue;
266 if (!options.recursive) return error.FolderNotEmpty;
267 try self.dir.deleteTree(io, name);
268 }
269 try self.dir.deleteTree(io, dirname);
270}
271
272pub const ListError = Dir.Iterator.Error || Allocator.Error;
273
274/// Every folder in the store, by the directory name it has on disk.
275///
276/// Sorted, which makes the parent of a folder come before it and lets a
277/// caller build a tree in one pass. The top-level maildir is not in the list:
278/// it is not a folder.
279pub const Folders = struct {
280 names: [][]u8,
281
282 pub fn deinit(self: *Folders, gpa: Allocator) void {
283 for (self.names) |name| gpa.free(name);
284 gpa.free(self.names);
285 self.* = undefined;
286 }
287};
288
289pub fn folders(self: *const Store, gpa: Allocator, io: Io) ListError!Folders {
290 var names: std.ArrayList([]u8) = .empty;
291 errdefer {
292 for (names.items) |name| gpa.free(name);
293 names.deinit(gpa);
294 }
295
296 var it = self.dir.iterate();
297 while (try it.next(io)) |entry| {
298 switch (entry.kind) {
299 .directory, .sym_link, .unknown => {},
300 else => continue,
301 }
302 if (!folder.isFolder(entry.name)) continue;
303 try names.append(gpa, try gpa.dupe(u8, entry.name));
304 }
305
306 const owned = try names.toOwnedSlice(gpa);
307 std.mem.sort([]u8, owned, {}, struct {
308 fn lessThan(_: void, a: []u8, b: []u8) bool {
309 return std.mem.order(u8, a, b) == .lt;
310 }
311 }.lessThan);
312 return .{ .names = owned };
313}
314
315/// Whether a folder exists.
316pub fn hasFolder(
317 self: *const Store,
318 io: Io,
319 path: []const []const u8,
320) (FolderError || Dir.StatFileError)!bool {
321 var buffer: Maildir.NameBuffer = undefined;
322 const dirname = try folder.bufComponents(&buffer, path);
323 const stat = self.dir.statFile(io, dirname, .{}) catch |err| switch (err) {
324 error.FileNotFound, error.NotDir => return false,
325 else => |e| return e,
326 };
327 return stat.kind == .directory;
328}
329
330// -- quota -------------------------------------------------------------------
331
332/// What `maildirsize` says, or null if the store has no quota file — which is
333/// how a store with no quota configured looks, and is not an error.
334///
335/// The number is the ledger's, not the filesystem's: see `quota` for why that
336/// is a running total that drifts, and use `isStale` on the result to decide
337/// whether to spend a `recalculateQuota` on it.
338pub fn quotaState(self: *const Store, gpa: Allocator, io: Io) quota.ReadError!?quota.State {
339 return quota.read(self.dir, io, gpa);
340}
341
342/// Notes a change to the store's usage in `maildirsize`: positive for a
343/// delivery, negative for a message removed. Does nothing if the store has no
344/// quota file.
345pub fn recordUsage(self: *const Store, io: Io, delta: quota.Usage) quota.RecordError!void {
346 return quota.record(self.dir, io, delta);
347}
348
349/// Sets the quota, replacing `maildirsize` with a ledger holding the given
350/// total. Use `recalculateQuota` to have the total worked out.
351pub fn setQuota(
352 self: *const Store,
353 io: Io,
354 limits: quota.Limits,
355 usage: quota.Usage,
356) quota.WriteError!void {
357 return quota.write(self.dir, io, limits, usage);
358}
359
360pub const RecalculateError = ListError || OpenError || Dir.StatFileError ||
361 quota.ReadError || quota.WriteError;
362
363/// Adds up every message in the store and writes the total to `maildirsize`.
364///
365/// This is the slow path the ledger exists to avoid: it opens every folder
366/// and lists `new` and `cur` in each. Messages in `tmp` are not counted,
367/// since nothing there is a message yet.
368///
369/// A message whose name carries `,S=` is counted from its name; the rest are
370/// `stat`ed. Keeping `record_size` on at delivery is therefore what makes
371/// this a directory walk rather than a `stat` of every message in the store.
372///
373/// The limits already in `maildirsize` are kept. If there is no quota file
374/// yet, one is written with no limits in it, which records the usage without
375/// imposing anything.
376pub fn recalculateQuota(self: *const Store, gpa: Allocator, io: Io) RecalculateError!quota.Usage {
377 const existing = try quota.read(self.dir, io, gpa);
378 const limits: quota.Limits = if (existing) |state| state.limits else .none;
379
380 var total: quota.Usage = .zero;
381
382 var root = try self.inbox(io);
383 defer root.close(io);
384 total = total.plus(try measure(&root, io));
385
386 var list = try self.folders(gpa, io);
387 defer list.deinit(gpa);
388 for (list.names) |name| {
389 var maildir = self.openFolderDirname(io, name) catch |err| switch (err) {
390 // A directory that looks like a folder and is not one is not a
391 // reason to abandon the count.
392 error.NotAMaildir, error.FileNotFound => continue,
393 else => |e| return e,
394 };
395 defer maildir.close(io);
396 total = total.plus(try measure(&maildir, io));
397 }
398
399 try quota.write(self.dir, io, limits, total);
400 return total;
401}
402
403fn measure(maildir: *const Maildir, io: Io) (Dir.Iterator.Error || Dir.StatFileError)!quota.Usage {
404 var total: quota.Usage = .zero;
405 for ([_]Maildir.Subdir{ .new, .cur }) |which| {
406 var it = maildir.iterate(which);
407 while (try it.next(io)) |message| {
408 const bytes = message.size(maildir, io) catch |err| switch (err) {
409 // Removed between the listing and the stat: it is not in the
410 // mailbox any more, so it does not count.
411 error.FileNotFound => continue,
412 else => |e| return e,
413 };
414 total = total.plus(.{ .bytes = @intCast(bytes), .messages = 1 });
415 }
416 }
417 return total;
418}