A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-maildir / src / Store.zig
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}