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 / Message.zig
12 kB 294 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! One message in a maildir: which of the three directories it is in, and 5//! what it is called. 6//! 7//! That is genuinely all a message is. There is no file handle here and no 8//! content — a `Message` is a name, and every operation on it either reads 9//! the file that name points at or renames it. It owns a copy of the name 10//! rather than borrowing one, so it stays valid after the iterator that 11//! produced it has moved on, and it is small enough to copy freely. 12//! 13//! The operations that change a flag take the message by pointer, because 14//! changing a flag changes the name and the caller's `Message` has to follow 15//! it. A `Message` whose name no longer exists — because another process 16//! moved or expunged it — is not detected until the next operation on it 17//! fails, which is the price of a mailbox with no lock in it. 18 19const std = @import("std"); 20const Io = std.Io; 21const Dir = Io.Dir; 22const File = Io.File; 23const Allocator = std.mem.Allocator; 24const testing = std.testing; 25 26const mime = @import("mime"); 27 28const Flags = @import("Flags.zig"); 29const Name = @import("Name.zig"); 30const Maildir = @import("Maildir.zig"); 31 32const Message = @This(); 33 34/// Which directory the file is in. A message in `new` has no flags, because 35/// that is what makes it new. 36subdir: Maildir.Subdir, 37/// The separator this message's name was read with, so that its flags can be 38/// parsed and rewritten without the maildir being passed in to do it. 39separator: u8, 40name_buffer: Maildir.NameBuffer, 41name_len: usize, 42 43/// Asserts the name fits in `Dir.max_name_bytes`, which every name a 44/// filesystem handed us does by construction. 45pub fn init(subdir: Maildir.Subdir, separator: u8, basename: []const u8) Message { 46 std.debug.assert(basename.len <= Dir.max_name_bytes); 47 var self: Message = .{ 48 .subdir = subdir, 49 .separator = separator, 50 .name_buffer = undefined, 51 .name_len = basename.len, 52 }; 53 @memcpy(self.name_buffer[0..basename.len], basename); 54 return self; 55} 56 57/// The file name, as it is on disk. 58pub fn filename(self: *const Message) []const u8 { 59 return self.name_buffer[0..self.name_len]; 60} 61 62/// The name taken apart. Borrows from `self`, so it must not outlive it. 63pub fn name(self: *const Message) Name { 64 return .parse(self.filename(), self.separator); 65} 66 67/// The flags on the message. A message in `new` has none. 68pub fn flags(self: *const Message) Flags { 69 return self.name().flags(); 70} 71 72/// The part of the name that identifies the message and survives every flag 73/// change — what to remember a message by, and what `Maildir.find` looks for. 74pub fn id(self: *const Message) []const u8 { 75 return self.name().base(); 76} 77 78fn dir(self: *const Message, maildir: *const Maildir) Dir { 79 return maildir.subdir(self.subdir); 80} 81 82// -- reading ----------------------------------------------------------------- 83 84/// Opens the message file for reading. 85pub fn open(self: *const Message, maildir: *const Maildir, io: Io) File.OpenError!File { 86 return self.dir(maildir).openFile(io, self.filename(), .{ .allow_directory = false }); 87} 88 89pub fn stat(self: *const Message, maildir: *const Maildir, io: Io) Dir.StatFileError!File.Stat { 90 return self.dir(maildir).statFile(io, self.filename(), .{}); 91} 92 93/// How big the message is, from the `,S=` field in its name if it has one and 94/// from the filesystem otherwise. 95/// 96/// Trusting the name is safe because a maildir message is written once and 97/// never modified, and it is what makes totalling a mailbox's size a 98/// directory listing rather than one `stat` per message. 99pub fn size(self: *const Message, maildir: *const Maildir, io: Io) Dir.StatFileError!u64 { 100 if (self.name().size()) |bytes| return bytes; 101 return (try self.stat(maildir, io)).size; 102} 103 104/// The largest message this will read into memory by default. Mail is not 105/// supposed to be bigger than this, and a maildir that has been handed 106/// something enormous should not take the reader down with it. 107pub const default_read_limit: Io.Limit = .limited(64 * 1024 * 1024); 108 109pub const ReadError = Dir.ReadFileAllocError; 110 111/// The whole message, headers and body, exactly as it is on disk. The caller 112/// owns the result. 113pub fn read( 114 self: *const Message, 115 maildir: *const Maildir, 116 gpa: Allocator, 117 io: Io, 118 limit: Io.Limit, 119) ReadError![]u8 { 120 return self.dir(maildir).readFileAlloc(io, self.filename(), gpa, limit); 121} 122 123/// `mime.Message.parse` only ever fails to allocate, so the errors here 124/// are the ones reading the file can produce. 125pub const ParseError = ReadError; 126 127/// The message, parsed: headers, addresses, dates, and the MIME tree. 128/// 129/// This is where <https://git.jcollie.dev/jeff/zig-mime> takes over. It parses 130/// from a slice rather than streaming, so the message is read into memory 131/// first and the returned `mime.Message` owns that copy — `deinit` frees 132/// both. A message written back out with `mime.Message.write` is byte for 133/// byte the one that was read, which is what makes it safe to open a signed 134/// message and forward it. 135/// 136/// ```zig 137/// var parsed = try message.parse(&maildir, gpa, io, .unlimited, .{}); 138/// defer parsed.deinit(); 139/// std.debug.print("{s}\n", .{(try parsed.root.subject()) orelse "(none)"}); 140/// ``` 141pub fn parse( 142 self: *const Message, 143 maildir: *const Maildir, 144 gpa: Allocator, 145 io: Io, 146 limit: Io.Limit, 147 options: mime.Message.ParseOptions, 148) ParseError!mime.Message { 149 const bytes = try self.read(maildir, gpa, io, limit); 150 defer gpa.free(bytes); 151 return mime.Message.parse(gpa, bytes, options); 152} 153 154// -- changing the name ------------------------------------------------------- 155 156pub const RenameError = Dir.RenameError || error{ 157 /// The new name did not fit in `Dir.max_name_bytes`. 158 NameTooLong, 159}; 160 161/// Renames the message so that it has exactly these flags, moving it from 162/// `new` to `cur` if it is still in `new`. 163/// 164/// Moving it is not a convenience: a name in `new` has no info field, so 165/// there is nowhere in `new` for a flag to be written. Marking a new message 166/// read and leaving it in `new` is not a thing a maildir can express, and 167/// every other implementation does the same move. 168/// 169/// On success `self` is updated to the new name. On failure it is untouched 170/// and still names the file that is still there. 171pub fn setFlags( 172 self: *Message, 173 maildir: *const Maildir, 174 io: Io, 175 new_flags: Flags, 176) RenameError!void { 177 var buffer: Maildir.NameBuffer = undefined; 178 const renamed = self.name().withFlags(new_flags).bufWrite(&buffer, maildir.separator) catch 179 return error.NameTooLong; 180 181 // A message already in `cur` whose flags are unchanged would be a rename 182 // onto itself, which is a no-op on POSIX but still a syscall and still a 183 // change of mtime on the directory. 184 if (self.subdir == .cur and std.mem.eql(u8, renamed, self.filename())) return; 185 186 try self.dir(maildir).rename(self.filename(), maildir.cur, renamed, io); 187 self.* = .init(.cur, maildir.separator, renamed); 188} 189 190/// Adds flags, leaving the others as they are. 191pub fn addFlags( 192 self: *Message, 193 maildir: *const Maildir, 194 io: Io, 195 to_add: Flags, 196) RenameError!void { 197 return self.setFlags(maildir, io, self.flags().unionWith(to_add)); 198} 199 200/// Removes flags, leaving the others as they are. 201pub fn removeFlags( 202 self: *Message, 203 maildir: *const Maildir, 204 io: Io, 205 to_remove: Flags, 206) RenameError!void { 207 return self.setFlags(maildir, io, self.flags().subtract(to_remove)); 208} 209 210/// Moves a message from `new` into `cur` without changing what it means — 211/// that is, with no flags — which is what a reader does when it has listed a 212/// mailbox and taken note of what was in it. 213/// 214/// A message already in `cur` is left exactly as it is, flags and all. 215pub fn moveToCur(self: *Message, maildir: *const Maildir, io: Io) RenameError!void { 216 if (self.subdir == .cur) return; 217 return self.setFlags(maildir, io, self.flags()); 218} 219 220pub const MoveError = RenameError || Maildir.DeliverError; 221 222/// Moves the message into another maildir, keeping its flags and giving it a 223/// name that is unique there. 224/// 225/// The name changes, and it has to. Two maildirs are two directories and 226/// nothing coordinates the names in them, so a message carrying its name into 227/// a folder that already has one like it would overwrite a message — and 228/// `rename` would do it silently. This is what IMAP's `MOVE` does, and it is 229/// why an IMAP server cannot promise that a moved message keeps its UID. 230/// 231/// `self` is updated to name the message in its new home. The two maildirs 232/// must be on the same filesystem, since this is a `rename` and not a copy. 233/// 234/// One sharp edge, and it is not this library's to fix: a **keyword does not 235/// survive the move with its meaning intact**. The letters in `Flags.other` 236/// are carried across unchanged, but what a letter *means* is recorded in a 237/// `dovecot-keywords` file inside each mailbox, so a message labelled 238/// "Important" in the inbox arrives in the archive carrying a letter that 239/// mailbox has never assigned. Dovecot's own `MOVE` updates the destination's 240/// mapping; nothing outside Dovecot can, because the mapping is Dovecot's 241/// rather than the maildir's. The six standard flags have no such problem — 242/// they mean the same thing everywhere. 243pub fn moveTo( 244 self: *Message, 245 from: *const Maildir, 246 to: *Maildir, 247 io: Io, 248) MoveError!void { 249 var unique_buffer: Maildir.NameBuffer = undefined; 250 const fresh = to.generator.bufNext(io, &unique_buffer) catch return error.NameTooLong; 251 252 // The flags travel; the unique part does not. Whatever `,S=` said is 253 // still true, so it is carried across rather than recomputed. 254 var buffer: Maildir.NameBuffer = undefined; 255 var w: Io.Writer = .fixed(&buffer); 256 w.writeAll(fresh) catch return error.NameTooLong; 257 const old = self.name(); 258 if (old.size()) |bytes| w.print(",S={d}", .{bytes}) catch return error.NameTooLong; 259 if (old.virtualSize()) |bytes| w.print(",W={d}", .{bytes}) catch return error.NameTooLong; 260 261 const destination: Maildir.Subdir = switch (self.subdir) { 262 .new => .new, 263 .cur, .tmp => blk: { 264 w.writeByte(to.separator) catch return error.NameTooLong; 265 w.writeAll("2,") catch return error.NameTooLong; 266 old.flags().format(&w) catch return error.NameTooLong; 267 break :blk .cur; 268 }, 269 }; 270 const renamed = w.buffered(); 271 272 try self.dir(from).rename(self.filename(), to.subdir(destination), renamed, io); 273 self.* = .init(destination, to.separator, renamed); 274} 275 276/// Deletes the message. This is IMAP's expunge, not its `\Deleted`: setting 277/// `trashed` marks a message, and this is what actually removes it. 278pub fn remove(self: *const Message, maildir: *const Maildir, io: Io) Dir.DeleteFileError!void { 279 return self.dir(maildir).deleteFile(io, self.filename()); 280} 281 282test "a message knows its own name" { 283 const message: Message = .init(.cur, ':', "1757700000.M1R2Q3.host,S=42:2,RS"); 284 try testing.expectEqualStrings("1757700000.M1R2Q3.host,S=42:2,RS", message.filename()); 285 try testing.expectEqualStrings("1757700000.M1R2Q3.host", message.id()); 286 try testing.expect(message.flags().seen and message.flags().replied); 287 try testing.expectEqual(@as(?u64, 42), message.name().size()); 288} 289 290test "a message in new has no flags" { 291 const message: Message = .init(.new, ':', "1757700000.M1R2Q3.host"); 292 try testing.expectEqual(Flags.none, message.flags()); 293 try testing.expectEqualStrings("1757700000.M1R2Q3.host", message.id()); 294}