A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
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}