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 / Maildir.zig
21 kB 534 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! One maildir: a directory holding `tmp`, `new` and `cur`. 5//! 6//! The design is thirty years old and has one idea in it, which is that a 7//! message is a file whose *name* carries everything mutable about it. From 8//! that one idea everything else follows: 9//! 10//! * **Delivery takes no lock.** A message is written into `tmp` under a name 11//! nobody else will invent, and then renamed into `new`. A reader never 12//! sees a partial message because a message only appears in `new` once it 13//! is whole, and `rename` within a filesystem is atomic. Two mail servers, 14//! an IMAP daemon and a `procmail` can all deliver at once, over NFS, with 15//! nothing arbitrating between them. 16//! * **Reading is a directory listing.** There is no index to corrupt, no 17//! lock to hold while a slow client reads its mail, and no way for one 18//! crashed process to leave the mailbox unusable. 19//! * **Changing a flag is a `rename`.** Nothing is rewritten, so a message 20//! file is written exactly once and never modified — which is what lets 21//! `,S=` in the name be trusted, and what makes a maildir safe to back up 22//! while it is in use. 23//! 24//! The cost is that `tmp` accumulates the wreckage of interrupted deliveries, 25//! which is why the specification says to delete anything in there older than 26//! 36 hours, and why `cleanTemp` exists. 27//! 28//! ```zig 29//! var maildir: maildir_mod.Maildir = try .create(.cwd(), io, "Maildir", .{}); 30//! defer maildir.close(io); 31//! 32//! _ = try maildir.deliver(io, "From: jeff@example.com\r\n\r\nHello.\r\n", .{}); 33//! 34//! var it = maildir.iterate(.new); 35//! while (try it.next(io)) |message| { 36//! var m = message; 37//! try m.setFlags(&maildir, io, .{ .seen = true }); // and into `cur` 38//! } 39//! ``` 40 41const std = @import("std"); 42const Io = std.Io; 43const Dir = Io.Dir; 44const File = Io.File; 45const Allocator = std.mem.Allocator; 46const testing = std.testing; 47 48const Flags = @import("Flags.zig"); 49const Name = @import("Name.zig"); 50const unique = @import("unique.zig"); 51const Message = @import("Message.zig"); 52 53const Maildir = @This(); 54 55/// The maildir itself, the directory holding the other three. 56dir: Dir, 57/// Delivered messages that nothing has looked at yet. 58new: Dir, 59/// Messages that have been seen by a reader, whose names carry flags. 60cur: Dir, 61/// Deliveries in progress. Nothing here is a message yet. 62tmp: Dir, 63/// The character between a message's unique part and its flags. See 64/// `Name.default_separator` for why this is not simply a colon. 65separator: u8, 66/// Where the unique part of a delivered message's name comes from. 67generator: unique.Generator, 68 69/// A buffer big enough for any name this library will write or read. 70pub const NameBuffer = [Dir.max_name_bytes]u8; 71 72/// Which of the three directories a message is in. 73pub const Subdir = enum { 74 tmp, 75 new, 76 cur, 77 78 pub fn dirname(self: Subdir) []const u8 { 79 return @tagName(self); 80 } 81}; 82 83/// Mail is private, so the directories are created `rwx` for their owner and 84/// nothing for anybody else, rather than being left to whatever the process 85/// umask happens to be. A world-readable maildir is the sort of mistake that 86/// is only noticed afterwards. 87/// 88/// Systems with no POSIX mode at all get their platform default, which is the 89/// most that can be said there. 90pub const private_dir: Dir.Permissions = if (@hasDecl(Dir.Permissions, "fromMode")) 91 Dir.Permissions.fromMode(0o700) 92else 93 .default_dir; 94 95/// The same reasoning for the message files themselves. `0o600` rather than 96/// `0o666`-and-umask. 97pub const private_file: File.Permissions = if (@hasDecl(File.Permissions, "fromMode")) 98 File.Permissions.fromMode(0o600) 99else 100 .default_file; 101 102pub const Options = struct { 103 /// The character before the info field. See `Name.default_separator`. 104 separator: u8 = Name.default_separator, 105 /// The host name that goes on the end of a delivered message's name. 106 /// Null asks the system for it, which is what a mail program on the 107 /// machine it delivers for wants. 108 hostname: ?[]const u8 = null, 109 /// The permissions the three directories are created with. 110 permissions: Dir.Permissions = private_dir, 111}; 112 113pub const OpenError = Dir.OpenError || error{ 114 /// The directory exists but is not a maildir: one or more of `tmp`, `new` 115 /// and `cur` is missing. Deliberately not the same error as a missing 116 /// directory, because delivering into a directory that merely looks like 117 /// a mailbox is how mail gets lost. 118 NotAMaildir, 119}; 120 121/// Opens an existing maildir. Fails if `tmp`, `new` or `cur` is missing. 122pub fn open(parent: Dir, io: Io, sub_path: []const u8, options: Options) OpenError!Maildir { 123 const dir = try parent.openDir(io, sub_path, .{ .iterate = true }); 124 errdefer dir.close(io); 125 return openDir(dir, io, options); 126} 127 128/// Like `open`, but takes a directory handle this maildir then owns and will 129/// close. The handle must have been opened with `.iterate = true`, since 130/// `Store` lists the folders beside it. 131pub fn openDir(dir: Dir, io: Io, options: Options) OpenError!Maildir { 132 var opened: usize = 0; 133 var handles: [3]Dir = undefined; 134 errdefer Dir.closeMany(io, handles[0..opened]); 135 136 for ([_]Subdir{ .tmp, .new, .cur }, 0..) |which, index| { 137 handles[index] = dir.openDir(io, which.dirname(), .{ .iterate = true }) catch |err| switch (err) { 138 error.FileNotFound, error.NotDir => return error.NotAMaildir, 139 else => |e| return e, 140 }; 141 opened += 1; 142 } 143 144 var hostname_buffer: [unique.max_hostname]u8 = undefined; 145 return .{ 146 .dir = dir, 147 .tmp = handles[0], 148 .new = handles[1], 149 .cur = handles[2], 150 .separator = options.separator, 151 .generator = .init(options.hostname orelse 152 unique.systemHostname(&hostname_buffer)), 153 }; 154} 155 156pub const CreateError = Dir.CreateDirError || OpenError; 157 158/// Creates a maildir, or opens one that is already there. 159/// 160/// The order matters and is the specification's: `tmp`, then `new`, then 161/// `cur`. A delivery finding `tmp` but not `new` would write a message it 162/// could not then deliver, so the directory a writer needs last is created 163/// last. 164pub fn create(parent: Dir, io: Io, sub_path: []const u8, options: Options) CreateError!Maildir { 165 parent.createDir(io, sub_path, options.permissions) catch |err| switch (err) { 166 error.PathAlreadyExists => {}, 167 else => |e| return e, 168 }; 169 const dir = try parent.openDir(io, sub_path, .{ .iterate = true }); 170 errdefer dir.close(io); 171 try createSubdirs(dir, io, options.permissions); 172 return openDir(dir, io, options); 173} 174 175fn createSubdirs(dir: Dir, io: Io, permissions: Dir.Permissions) Dir.CreateDirError!void { 176 for ([_]Subdir{ .tmp, .new, .cur }) |which| { 177 dir.createDir(io, which.dirname(), permissions) catch |err| switch (err) { 178 error.PathAlreadyExists => {}, 179 else => |e| return e, 180 }; 181 } 182} 183 184/// Closes all four directory handles. The `Maildir` must not be used after 185/// this. 186pub fn close(self: *Maildir, io: Io) void { 187 Dir.closeMany(io, &.{ self.tmp, self.new, self.cur, self.dir }); 188 self.* = undefined; 189} 190 191/// The handle for one of the three subdirectories. 192pub fn subdir(self: *const Maildir, which: Subdir) Dir { 193 return switch (which) { 194 .tmp => self.tmp, 195 .new => self.new, 196 .cur => self.cur, 197 }; 198} 199 200// -- delivery ---------------------------------------------------------------- 201 202/// Where a delivered message lands. 203pub const Destination = union(enum) { 204 /// `new`, with no flags. An ordinary delivery: something arrived and 205 /// nobody has looked at it. 206 new, 207 /// `cur`, with flags already set. This is what IMAP's `APPEND` does when 208 /// the client says the message is already read, and what a client does 209 /// when it saves a draft. 210 cur: Flags, 211}; 212 213pub const DeliverOptions = struct { 214 to: Destination = .new, 215 /// Append `,S=<bytes>` to the name, so that the size can be had from a 216 /// directory listing. Dovecot and Courier both do this and both trust it, 217 /// which is safe only because a maildir message is never modified in 218 /// place. 219 record_size: bool = true, 220 /// Also append `,W=<bytes>`, the size the message would be with CRLF line 221 /// endings, which is the number IMAP reports. Off by default because it 222 /// means counting the line endings, and because a message that is already 223 /// CRLF makes it equal to `,S=`. 224 record_virtual_size: bool = false, 225 /// Ask the filesystem to put the message on the disk before it is named 226 /// in `new`. Without this a crash can leave a message that exists in the 227 /// directory and is empty on disk, which is worse than a message that 228 /// never arrived. Costs a round trip to the storage on every delivery. 229 sync: bool = true, 230 /// How many times to invent a new name when the one invented collides 231 /// with a file already in `tmp`. 232 attempts: usize = 10, 233}; 234 235pub const DeliverError = File.OpenError || File.Writer.Error || File.SyncError || 236 Dir.RenameError || error{ 237 /// The buffered writer failed and could not say why, which should not 238 /// happen: `File.Writer` records the real error and `commit` returns 239 /// that instead wherever it is there. 240 WriteFailed, 241 /// `attempts` names in a row were already taken in `tmp`. Something 242 /// is wrong that retrying will not fix — a clock stuck at a value a 243 /// previous run also used, with `tmp` never cleaned. 244 NameCollision, 245 /// The generated name did not fit in `Dir.max_name_bytes`, which 246 /// means the host name is absurd. 247 NameTooLong, 248}; 249 250/// Writes a message into the maildir and returns where it landed. 251/// 252/// This is the whole protocol: a unique name, an exclusive create in `tmp`, 253/// the bytes, a sync, and a rename into `new` or `cur`. If anything fails 254/// before the rename the partial file is removed, so a failed delivery leaves 255/// nothing behind and — more to the point — never leaves half a message where 256/// a reader will find it. 257pub fn deliver( 258 self: *Maildir, 259 io: Io, 260 bytes: []const u8, 261 options: DeliverOptions, 262) DeliverError!Message { 263 var delivery = try self.beginDelivery(io, options.attempts); 264 errdefer delivery.abort(io); 265 266 try delivery.file.writeStreamingAll(io, bytes); 267 delivery.size = bytes.len; 268 if (options.record_virtual_size) delivery.virtual_size = virtualSizeOf(bytes); 269 270 return delivery.commit(io, options); 271} 272 273/// The size `bytes` would have if every line ended in CRLF: the length plus 274/// one for every LF that is not already preceded by a CR. 275pub fn virtualSizeOf(bytes: []const u8) u64 { 276 var total: u64 = bytes.len; 277 for (bytes, 0..) |c, index| { 278 if (c == '\n' and (index == 0 or bytes[index - 1] != '\r')) total += 1; 279 } 280 return total; 281} 282 283/// A message being written into `tmp`, not yet delivered. 284/// 285/// Use this rather than `deliver` when the message is not already a slice — 286/// when it is being copied from a socket, or written by `mime.Message.write`, 287/// or is large enough that a second copy of it in memory is not wanted. 288/// 289/// **The value must not be moved once `writer` has been called**, because 290/// what `writer` returns points into it. 291pub const Delivery = struct { 292 /// Where the temporary file lives, so that `abort` can remove it and 293 /// `commit` can rename it without being handed the maildir again. 294 maildir: *const Maildir, 295 file: File, 296 name_buffer: NameBuffer, 297 name_len: usize, 298 /// The size of the message, if the caller already knows it. `commit` asks 299 /// the file otherwise. 300 size: ?u64 = null, 301 /// The `,W=` size, if the caller knows it. Nothing here can work it out 302 /// for a streamed message without reading every byte a second time, so a 303 /// caller that wants the field recorded has to count as it writes. 304 virtual_size: ?u64 = null, 305 file_writer: ?File.Writer = null, 306 file_open: bool = true, 307 finished: bool = false, 308 309 /// The name the file has in `tmp`. Not the name it will have once it is 310 /// delivered, which gains the size fields and the flags. 311 pub fn tempName(self: *const Delivery) []const u8 { 312 return self.name_buffer[0..self.name_len]; 313 } 314 315 /// A writer for the message body. `buffer` must outlive the delivery, and 316 /// the delivery must not be moved afterwards. 317 pub fn writer(self: *Delivery, io: Io, buffer: []u8) *Io.Writer { 318 self.file_writer = self.file.writer(io, buffer); 319 return &self.file_writer.?.interface; 320 } 321 322 /// Flushes, syncs, and renames the message into place; returns where it 323 /// landed. On failure the temporary file is removed, so a delivery either 324 /// produces a whole message or produces nothing. 325 pub fn commit(self: *Delivery, io: Io, options: DeliverOptions) DeliverError!Message { 326 std.debug.assert(!self.finished); 327 errdefer self.abort(io); 328 329 // `Io.Writer.flush` reports only that something failed; the error 330 // itself is kept on the `File.Writer` that produced it. 331 if (self.file_writer) |*fw| fw.interface.flush() catch 332 return fw.err orelse error.WriteFailed; 333 if (options.sync) try self.file.sync(io); 334 335 // A stat that fails costs the `,S=` field and nothing else, so it is 336 // not worth failing a delivery over. 337 const size = self.size orelse (self.file.length(io) catch null); 338 339 var final_buffer: NameBuffer = undefined; 340 var w: Io.Writer = .fixed(&final_buffer); 341 w.writeAll(self.tempName()) catch return error.NameTooLong; 342 if (options.record_size) if (size) |bytes| { 343 w.print(",S={d}", .{bytes}) catch return error.NameTooLong; 344 }; 345 if (options.record_virtual_size) if (self.virtual_size) |bytes| { 346 w.print(",W={d}", .{bytes}) catch return error.NameTooLong; 347 }; 348 const destination: Subdir = switch (options.to) { 349 .new => .new, 350 .cur => |flags| blk: { 351 w.writeByte(self.maildir.separator) catch return error.NameTooLong; 352 w.writeAll("2,") catch return error.NameTooLong; 353 flags.format(&w) catch return error.NameTooLong; 354 break :blk .cur; 355 }, 356 }; 357 const final_name = w.buffered(); 358 359 // Closed before the rename rather than after, so that the file is on 360 // its way to the disk before anything can see it under its real name. 361 self.file.close(io); 362 self.file_open = false; 363 364 try self.maildir.tmp.rename( 365 self.tempName(), 366 self.maildir.subdir(destination), 367 final_name, 368 io, 369 ); 370 self.finished = true; 371 return .init(destination, self.maildir.separator, final_name); 372 } 373 374 /// Throws the delivery away: closes the file and removes it from `tmp`. 375 /// Does nothing to a delivery that has already been committed or aborted, 376 /// so it is safe as an `errdefer` beside a `commit`. 377 pub fn abort(self: *Delivery, io: Io) void { 378 if (self.finished) return; 379 self.finished = true; 380 if (self.file_open) { 381 self.file.close(io); 382 self.file_open = false; 383 } 384 // If this fails the file is wreckage in `tmp`, which is what 385 // `cleanTemp` is for. There is nothing better to do about it here. 386 self.maildir.tmp.deleteFile(io, self.tempName()) catch {}; 387 } 388}; 389 390/// Creates the temporary file a message will be written into, inventing names 391/// until one of them is free. 392pub fn beginDelivery(self: *Maildir, io: Io, attempts: usize) DeliverError!Delivery { 393 var delivery: Delivery = .{ 394 .maildir = self, 395 .file = undefined, 396 .name_buffer = undefined, 397 .name_len = 0, 398 }; 399 400 var attempt: usize = 0; 401 while (attempt < @max(attempts, 1)) : (attempt += 1) { 402 const name = self.generator.bufNext(io, &delivery.name_buffer) catch 403 return error.NameTooLong; 404 delivery.name_len = name.len; 405 406 // Exclusive: the kernel refuses rather than truncating, so a name 407 // that has somehow been used before costs an attempt rather than 408 // somebody else's message. 409 delivery.file = self.tmp.createFile(io, name, .{ 410 .exclusive = true, 411 .permissions = private_file, 412 }) catch |err| switch (err) { 413 error.PathAlreadyExists => continue, 414 else => |e| return e, 415 }; 416 return delivery; 417 } 418 return error.NameCollision; 419} 420 421// -- reading ----------------------------------------------------------------- 422 423/// Walks one of the subdirectories, yielding a `Message` for each file in it. 424/// 425/// Entries whose names begin with a dot are skipped, which covers `.` and 426/// `..` and the marker files a Maildir++ store keeps beside its messages. 427/// Subdirectories are skipped too. 428pub const Iterator = struct { 429 inner: Dir.Iterator, 430 which: Subdir, 431 separator: u8, 432 433 pub const Error = Dir.Iterator.Error; 434 435 /// The next message, or null at the end. The returned `Message` owns its 436 /// name, so it stays valid after the following call. 437 pub fn next(self: *Iterator, io: Io) Error!?Message { 438 while (try self.inner.next(io)) |entry| { 439 if (entry.name.len == 0 or entry.name[0] == '.') continue; 440 switch (entry.kind) { 441 // `unknown` is what a filesystem that does not report a kind 442 // in its directory entries gives, and refusing those would 443 // make this library useless on them. 444 .file, .sym_link, .unknown => {}, 445 else => continue, 446 } 447 if (entry.name.len > Dir.max_name_bytes) continue; 448 return .init(self.which, self.separator, entry.name); 449 } 450 return null; 451 } 452}; 453 454pub fn iterate(self: *const Maildir, which: Subdir) Iterator { 455 return .{ 456 .inner = self.subdir(which).iterate(), 457 .which = which, 458 .separator = self.separator, 459 }; 460} 461 462pub const ListError = Iterator.Error || Allocator.Error; 463 464/// Every message in the given subdirectories, in one allocation-owning list. 465/// 466/// Iteration is the cheaper way to walk a maildir and should be preferred, 467/// but a caller that needs to sort the messages, or to know how many there 468/// are before it starts, needs them all at once. The order is the 469/// filesystem's, which is not chronological. 470pub fn list( 471 self: *const Maildir, 472 gpa: Allocator, 473 io: Io, 474 which: []const Subdir, 475) ListError![]Message { 476 var messages: std.ArrayList(Message) = .empty; 477 defer messages.deinit(gpa); 478 for (which) |subdir_kind| { 479 var it = self.iterate(subdir_kind); 480 while (try it.next(io)) |message| try messages.append(gpa, message); 481 } 482 return messages.toOwnedSlice(gpa); 483} 484 485/// Looks for a message by the unchanging part of its name — `Name.base`, 486/// which survives every flag change — in `new` and then in `cur`. 487/// 488/// This is how a program that remembered a message finds it again, and it is 489/// a linear scan because a maildir has no index. A caller doing this for 490/// every message of many should list the directory once instead. 491pub fn find(self: *const Maildir, io: Io, base: []const u8) Iterator.Error!?Message { 492 for ([_]Subdir{ .new, .cur }) |which| { 493 var it = self.iterate(which); 494 while (try it.next(io)) |message| { 495 if (std.mem.eql(u8, message.id(), base)) return message; 496 } 497 } 498 return null; 499} 500 501pub const CleanTempError = Iterator.Error || Dir.DeleteFileError; 502 503/// Deletes anything in `tmp` older than `max_age`, which is the housekeeping 504/// the specification asks for: a delivery that died between creating its file 505/// and renaming it leaves that file behind forever otherwise. 506/// 507/// Thirty-six hours is the specified threshold, and it is long for a reason — 508/// it must exceed the longest a legitimate delivery could take, since 509/// deleting a file out from under a delivery in progress loses the message. 510/// 511/// Returns how many files were removed. 512pub fn cleanTemp(self: *const Maildir, io: Io, max_age: Io.Duration) CleanTempError!usize { 513 const now = Io.Timestamp.now(io, .real); 514 var removed: usize = 0; 515 var it = self.tmp.iterate(); 516 while (try it.next(io)) |entry| { 517 if (entry.name.len == 0 or entry.name[0] == '.') continue; 518 const stat = self.tmp.statFile(io, entry.name, .{}) catch continue; 519 if (stat.mtime.durationTo(now).nanoseconds < max_age.nanoseconds) continue; 520 self.tmp.deleteFile(io, entry.name) catch |err| switch (err) { 521 error.FileNotFound => continue, 522 else => |e| return e, 523 }; 524 removed += 1; 525 } 526 return removed; 527} 528 529/// The age at which the specification says a file in `tmp` is wreckage. 530pub const temp_max_age: Io.Duration = .{ .nanoseconds = 36 * 60 * 60 * std.time.ns_per_s }; 531 532test { 533 _ = Message; 534}