A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
11 kB
277 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! The name of a message file, taken apart.
5//!
6//! A maildir keeps a message's metadata in its file name, which is why
7//! changing a flag is a `rename` and why the name has to be parsed and
8//! rebuilt rather than treated as opaque. The shape is
9//!
10//! ```text
11//! 1757700000.M492817R3f0a1c2b4d5e6f70Q1.mail.example.com,S=4211:2,RS
12//! \_________________ unique ________________________/\____/ \_____/
13//! fields info
14//! ```
15//!
16//! * The **unique** part is everything before the separator, and this library
17//! never invents meaning for it beyond the fields below. It is produced at
18//! delivery and then left exactly alone, because two programs sharing a
19//! maildir agree on a message's identity by its unique part and nothing
20//! else — change it and every IMAP UID, every read/unread record and every
21//! synchronisation state keyed to it is lost.
22//! * The **fields** are `,`-separated `letter=value` pairs that Dovecot and
23//! Courier append to the unique part. `S` is the size of the file in bytes
24//! and `W` is its size once every line ends in CRLF, and both are there so
25//! that a quota can be totalled and an IMAP `RFC822.SIZE` answered from a
26//! directory listing rather than from a `stat` of every message. They are
27//! part of the unique part as far as everything else is concerned.
28//! * The **info** is `2,` followed by the flags. A message in `new` has no
29//! info at all, which is exactly what makes it new.
30//!
31//! `parse` cannot fail. A name it does not understand keeps its info verbatim
32//! in `Info.other` and is written back byte for byte, because the alternative
33//! — refusing to list a message because something else wrote its name in a
34//! dialect this library has not heard of — loses mail that is sitting right
35//! there.
36
37const std = @import("std");
38const Io = std.Io;
39const testing = std.testing;
40
41const Flags = @import("Flags.zig");
42
43const Name = @This();
44
45/// Everything before the separator, including any `,S=` and `,W=` fields.
46/// Borrowed from whatever the name was parsed out of.
47unique: []const u8,
48/// What followed the separator.
49info: Info,
50
51/// The character between the unique part and the info.
52///
53/// A colon is what the maildir defines and what every Unix mail program
54/// expects. It is also illegal in a FAT, exFAT or NTFS file name, so a
55/// maildir on a memory stick or a Windows share is written by isync and
56/// Dovecot with some other character — usually `!` or `;` — and a reader that
57/// insists on a colon sees every message in it as new and flagless. That is
58/// why the separator is a parameter of every function here rather than a
59/// constant.
60pub const default_separator: u8 = ':';
61
62/// What follows the separator in a message's name.
63pub const Info = union(enum) {
64 /// There was no separator. A message in `new` has no info, and that is
65 /// the whole of what "new" means.
66 none,
67 /// `2,` followed by flags: the only info semantics ever defined.
68 flags: Flags,
69 /// A separator followed by something else — the experimental `1,`
70 /// semantics, or a name written by software that has its own ideas.
71 /// Kept as written, and written back unchanged.
72 other: []const u8,
73};
74
75/// Takes a file name apart. Never fails: a name that makes no sense is a name
76/// with no flags and an `Info.other` that reproduces it.
77///
78/// The result borrows from `basename`, which must outlive it. When that is a
79/// directory entry, "outlive it" means "until the next call to `next`".
80pub fn parse(basename: []const u8, separator: u8) Name {
81 const index = std.mem.findScalarLast(u8, basename, separator) orelse return .{
82 .unique = basename,
83 .info = .none,
84 };
85 const unique = basename[0..index];
86 const rest = basename[index + 1 ..];
87
88 // Only `2,` was ever defined. `1,` was reserved for experiments that
89 // never happened, and anything else is somebody's extension.
90 if (std.mem.startsWith(u8, rest, "2,")) {
91 if (Flags.parse(rest[2..])) |parsed| {
92 return .{ .unique = unique, .info = .{ .flags = parsed } };
93 } else |_| {}
94 }
95 return .{ .unique = unique, .info = .{ .other = rest } };
96}
97
98/// The flags on the message, treating a name with no info or an info this
99/// library does not understand as having none — which is the truth as far as
100/// anything can tell.
101pub fn flags(self: Name) Flags {
102 return switch (self.info) {
103 .flags => |f| f,
104 .none, .other => .none,
105 };
106}
107
108/// `self` with different flags, and the same unique part.
109pub fn withFlags(self: Name, new_flags: Flags) Name {
110 return .{ .unique = self.unique, .info = .{ .flags = new_flags } };
111}
112
113/// The unique part with the `,`-separated fields removed: the part that
114/// identifies the message and never changes, even when its size is recorded
115/// or its flags are set.
116pub fn base(self: Name) []const u8 {
117 const index = std.mem.findScalar(u8, self.unique, ',') orelse return self.unique;
118 return self.unique[0..index];
119}
120
121/// The value of a `,<letter>=<value>` field appended to the unique part, or
122/// null if the name does not carry one. See `size` and `virtualSize` for the
123/// two that are defined.
124pub fn field(self: Name, letter: u8) ?[]const u8 {
125 var rest = self.unique;
126 while (std.mem.findScalar(u8, rest, ',')) |comma| {
127 rest = rest[comma + 1 ..];
128 const end = std.mem.findScalar(u8, rest, ',') orelse rest.len;
129 const item = rest[0..end];
130 if (item.len >= 2 and item[0] == letter and item[1] == '=') return item[2..];
131 }
132 return null;
133}
134
135fn fieldInt(self: Name, letter: u8) ?u64 {
136 const text = self.field(letter) orelse return null;
137 return std.fmt.parseInt(u64, text, 10) catch null;
138}
139
140/// The size of the message in bytes, from the `,S=` field, or null if the
141/// name does not carry one — in which case the only way to know is to `stat`
142/// the file, which is what `Message.size` does.
143///
144/// It is not checked against the file. A name that disagrees with its content
145/// was written by something that got it wrong, or the file was modified in
146/// place, which a maildir forbids.
147pub fn size(self: Name) ?u64 {
148 return self.fieldInt('S');
149}
150
151/// The size the message would have if every line ended in CRLF, from the
152/// `,W=` field. This is the number IMAP's `RFC822.SIZE` wants, and it differs
153/// from `size` for a message stored with bare newlines.
154pub fn virtualSize(self: Name) ?u64 {
155 return self.fieldInt('W');
156}
157
158/// Writes the name back out.
159pub fn write(self: Name, w: *Io.Writer, separator: u8) Io.Writer.Error!void {
160 try w.writeAll(self.unique);
161 switch (self.info) {
162 .none => {},
163 .flags => |f| {
164 try w.writeByte(separator);
165 try w.writeAll("2,");
166 try f.format(w);
167 },
168 .other => |text| {
169 try w.writeByte(separator);
170 try w.writeAll(text);
171 },
172 }
173}
174
175/// Writes the name with the default separator, for `{f}`.
176pub fn format(self: Name, w: *Io.Writer) Io.Writer.Error!void {
177 return self.write(w, default_separator);
178}
179
180/// Writes the name into `buffer` and returns the part used.
181pub fn bufWrite(self: Name, buffer: []u8, separator: u8) error{NoSpaceLeft}![]u8 {
182 var w: Io.Writer = .fixed(buffer);
183 self.write(&w, separator) catch return error.NoSpaceLeft;
184 return w.buffered();
185}
186
187test "a message in new has no info" {
188 const name: Name = .parse("1757700000.M1R2Q3.host", ':');
189 try testing.expectEqualStrings("1757700000.M1R2Q3.host", name.unique);
190 try testing.expectEqual(Name.Info.none, name.info);
191 try testing.expectEqual(Flags.none, name.flags());
192 try testing.expectFmt("1757700000.M1R2Q3.host", "{f}", .{name});
193}
194
195test "a message in cur has flags" {
196 const name: Name = .parse("1757700000.M1R2Q3.host:2,RS", ':');
197 try testing.expectEqualStrings("1757700000.M1R2Q3.host", name.unique);
198 try testing.expect(name.flags().seen and name.flags().replied);
199 try testing.expectFmt("1757700000.M1R2Q3.host:2,RS", "{f}", .{name});
200}
201
202test "an empty flag list is a message in cur with nothing set" {
203 const name: Name = .parse("x:2,", ':');
204 try testing.expectEqual(Flags.none, name.flags());
205 try testing.expectFmt("x:2,", "{f}", .{name});
206}
207
208test "the size fields are part of the unique name and readable from it" {
209 const name: Name = .parse("1757700000.M1R2Q3.host,S=4211,W=4300:2,S", ':');
210 try testing.expectEqualStrings("1757700000.M1R2Q3.host", name.base());
211 try testing.expectEqual(@as(?u64, 4211), name.size());
212 try testing.expectEqual(@as(?u64, 4300), name.virtualSize());
213 try testing.expect(name.flags().seen);
214}
215
216test "a name with no size field says so rather than guessing" {
217 const name: Name = .parse("1757700000.M1R2Q3.host:2,S", ':');
218 try testing.expectEqual(@as(?u64, null), name.size());
219 try testing.expectEqual(@as(?u64, null), name.virtualSize());
220}
221
222test "a size field that is not a number is not a number" {
223 const name: Name = .parse("x,S=beef:2,", ':');
224 try testing.expectEqual(@as(?u64, null), name.size());
225 try testing.expectEqualStrings("beef", name.field('S').?);
226}
227
228test "changing a flag leaves the unique part alone" {
229 const name: Name = .parse("1757700000.M1R2Q3.host,S=4211:2,S", ':');
230 const replied = name.withFlags(name.flags().with(.replied));
231 try testing.expectFmt("1757700000.M1R2Q3.host,S=4211:2,RS", "{f}", .{replied});
232}
233
234test "the experimental info semantics are kept rather than understood" {
235 const name: Name = .parse("x:1,whatever", ':');
236 try testing.expectEqualStrings("1,whatever", name.info.other);
237 try testing.expectEqual(Flags.none, name.flags());
238 try testing.expectFmt("x:1,whatever", "{f}", .{name});
239}
240
241test "an info that is not flags at all round trips byte for byte" {
242 // `2,` followed by something that cannot be a flag: kept verbatim rather
243 // than parsed into nothing, so that whatever wrote it can read it back.
244 const name: Name = .parse("x:2,S=1", ':');
245 try testing.expectEqualStrings("2,S=1", name.info.other);
246 try testing.expectFmt("x:2,S=1", "{f}", .{name});
247}
248
249test "a separator that is not a colon" {
250 // What isync writes on a filesystem that will not take a colon. Read with
251 // the wrong separator, the flags vanish and the message looks new -- which
252 // is the whole reason this is a parameter.
253 const name: Name = .parse("1757700000.M1R2Q3.host!2,S", '!');
254 try testing.expect(name.flags().seen);
255 try testing.expectFmt("1757700000.M1R2Q3.host!2,S", "{f}", .{
256 struct {
257 n: Name,
258 pub fn format(s: @This(), w: *Io.Writer) Io.Writer.Error!void {
259 return s.n.write(w, '!');
260 }
261 }{ .n = name },
262 });
263
264 const misread: Name = .parse("1757700000.M1R2Q3.host!2,S", ':');
265 try testing.expectEqual(Name.Info.none, misread.info);
266}
267
268test "bufWrite" {
269 const name: Name = .parse("x:2,S", ':');
270 var buffer: [64]u8 = undefined;
271 try testing.expectEqualStrings("x:2,RS", try name.withFlags(
272 name.flags().with(.replied),
273 ).bufWrite(&buffer, ':'));
274
275 var tiny: [3]u8 = undefined;
276 try testing.expectError(error.NoSpaceLeft, name.bufWrite(&tiny, ':'));
277}