A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
11 kB
260 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! Maildir++ folder names: the convention that turns one maildir into a tree
5//! of them.
6//!
7//! A maildir has no room for a second mailbox in it, so Maildir++ puts the
8//! folders *beside* the messages, as hidden directories in the top-level
9//! maildir, each a complete maildir of its own:
10//!
11//! ```text
12//! Maildir/ the top-level maildir, which IMAP calls INBOX
13//! tmp/ new/ cur/ its own messages
14//! maildirsize the quota file, covering everything below
15//! .Work/ the folder "Work"
16//! tmp/ new/ cur/
17//! maildirfolder the marker that says this is a folder and not a stray
18//! .Work.Reports/ the folder "Work/Reports"
19//! tmp/ new/ cur/
20//! maildirfolder
21//! ```
22//!
23//! The hierarchy is **flat on disk and nested in the name**: `.Work.Reports`
24//! is a sibling directory of `.Work`, not a child of it, which is what lets a
25//! whole folder tree be listed with one `readdir` and why renaming a folder
26//! means renaming every descendant.
27//!
28//! The dot is the hierarchy delimiter, and that has a consequence worth being
29//! explicit about: **a folder name cannot contain a dot**. There is no escape
30//! for one — Maildir++ never defined one — so a mailbox the user calls
31//! `example.com` is either the folder `com` inside the folder `example` or it
32//! is not representable, and this library says so with
33//! `error.InvalidComponent` rather than silently creating the wrong thing.
34//!
35//! What is *not* here is subscriptions. Which folders a client has subscribed
36//! to is IMAP's business and every server keeps it differently — Courier in
37//! `courierimapsubscribed`, Dovecot in `subscriptions` — so a file with that
38//! name is left alone rather than guessed at.
39
40const std = @import("std");
41const Io = std.Io;
42const Dir = Io.Dir;
43const testing = std.testing;
44
45/// The character between one level of the hierarchy and the next, and the
46/// character a directory name begins with to mark it as a folder.
47pub const delimiter = '.';
48
49/// The empty file that says a directory is a Maildir++ folder rather than
50/// something else that happens to be named with a leading dot. Courier
51/// requires it; Dovecot writes it and does not insist on it.
52pub const marker = "maildirfolder";
53
54pub const Error = error{
55 /// A path with no components. The top-level maildir is not a folder, and
56 /// naming it as one is a mistake worth reporting rather than resolving.
57 EmptyPath,
58 /// A component that is the empty string: `Work//Reports`, or a path with
59 /// a leading or trailing delimiter.
60 EmptyComponent,
61 /// A component containing a dot or a slash. A dot is the hierarchy
62 /// delimiter and Maildir++ has no escape for one; a slash would make the
63 /// name a path.
64 InvalidComponent,
65 /// The resulting directory name is longer than a file name may be.
66 NameTooLong,
67};
68
69/// Whether a component can be part of a folder name.
70pub fn validComponent(component: []const u8) bool {
71 if (component.len == 0) return false;
72 for (component) |c| switch (c) {
73 delimiter, '/', 0 => return false,
74 else => {},
75 };
76 return true;
77}
78
79/// Writes the directory name for a folder given its components:
80/// `.{"Work", "Reports"}` becomes `.Work.Reports`.
81pub fn writeComponents(w: *Io.Writer, path: []const []const u8) (Error || Io.Writer.Error)!void {
82 if (path.len == 0) return error.EmptyPath;
83 for (path) |component| {
84 if (component.len == 0) return error.EmptyComponent;
85 if (!validComponent(component)) return error.InvalidComponent;
86 try w.writeByte(delimiter);
87 try w.writeAll(component);
88 }
89}
90
91/// Writes the directory name for a folder given a path with a delimiter of
92/// the caller's choosing: `"Work/Reports"` with `/` becomes `.Work.Reports`.
93///
94/// This is the form an IMAP server has, since IMAP carries the hierarchy
95/// delimiter in the protocol and it is very often a slash even when the store
96/// underneath uses a dot.
97pub fn writePath(
98 w: *Io.Writer,
99 path: []const u8,
100 path_delimiter: u8,
101) (Error || Io.Writer.Error)!void {
102 if (path.len == 0) return error.EmptyPath;
103 var it = std.mem.splitScalar(u8, path, path_delimiter);
104 while (it.next()) |component| {
105 if (component.len == 0) return error.EmptyComponent;
106 if (!validComponent(component)) return error.InvalidComponent;
107 try w.writeByte(delimiter);
108 try w.writeAll(component);
109 }
110}
111
112/// `writeComponents`, into a buffer.
113pub fn bufComponents(buffer: []u8, path: []const []const u8) Error![]u8 {
114 var w: Io.Writer = .fixed(buffer);
115 writeComponents(&w, path) catch |err| switch (err) {
116 error.WriteFailed => return error.NameTooLong,
117 else => |e| return e,
118 };
119 return w.buffered();
120}
121
122/// `writePath`, into a buffer.
123pub fn bufPath(buffer: []u8, path: []const u8, path_delimiter: u8) Error![]u8 {
124 var w: Io.Writer = .fixed(buffer);
125 writePath(&w, path, path_delimiter) catch |err| switch (err) {
126 error.WriteFailed => return error.NameTooLong,
127 else => |e| return e,
128 };
129 return w.buffered();
130}
131
132/// Whether a directory name in the top-level maildir names a folder. `.` and
133/// `..` are not folders, and neither is anything without a leading dot.
134pub fn isFolder(dirname: []const u8) bool {
135 if (dirname.len < 2 or dirname[0] != delimiter) return false;
136 if (std.mem.eql(u8, dirname, "..")) return false;
137 // `.Work..Reports` has an empty component in it and is not a name this
138 // library would have produced.
139 var it = std.mem.splitScalar(u8, dirname[1..], delimiter);
140 while (it.next()) |component| if (component.len == 0) return false;
141 return true;
142}
143
144/// The components of a folder's directory name, outermost first.
145/// `.Work.Reports` yields `Work` then `Reports`.
146pub fn components(dirname: []const u8) Iterator {
147 return .{ .rest = if (dirname.len > 0 and dirname[0] == delimiter) dirname[1..] else dirname };
148}
149
150pub const Iterator = struct {
151 rest: []const u8,
152 done: bool = false,
153
154 pub fn next(self: *Iterator) ?[]const u8 {
155 if (self.done) return null;
156 if (std.mem.findScalar(u8, self.rest, delimiter)) |index| {
157 const component = self.rest[0..index];
158 self.rest = self.rest[index + 1 ..];
159 return component;
160 }
161 self.done = true;
162 return self.rest;
163 }
164};
165
166/// Writes a folder's directory name as a path with the caller's delimiter:
167/// `.Work.Reports` becomes `Work/Reports`. The reverse of `writePath`.
168pub fn writeName(
169 w: *Io.Writer,
170 dirname: []const u8,
171 path_delimiter: u8,
172) Io.Writer.Error!void {
173 var it = components(dirname);
174 var first = true;
175 while (it.next()) |component| {
176 if (!first) try w.writeByte(path_delimiter);
177 first = false;
178 try w.writeAll(component);
179 }
180}
181
182/// The directory name of a folder's parent, or null if it has none because it
183/// is directly below the top-level maildir.
184pub fn parent(dirname: []const u8) ?[]const u8 {
185 const index = std.mem.findScalarLast(u8, dirname, delimiter) orelse return null;
186 if (index == 0) return null;
187 return dirname[0..index];
188}
189
190/// Whether one folder is somewhere below another. `.Work.Reports.Q1` is under
191/// `.Work`; `.Workshop` is not, which is the case a plain `startsWith` gets
192/// wrong and the reason this exists.
193pub fn isBelow(dirname: []const u8, ancestor: []const u8) bool {
194 if (dirname.len <= ancestor.len) return false;
195 if (!std.mem.startsWith(u8, dirname, ancestor)) return false;
196 return dirname[ancestor.len] == delimiter;
197}
198
199test "a folder name is its components with a dot in front of each" {
200 var buffer: [64]u8 = undefined;
201 try testing.expectEqualStrings(".Work", try bufComponents(&buffer, &.{"Work"}));
202 try testing.expectEqualStrings(".Work.Reports", try bufComponents(&buffer, &.{ "Work", "Reports" }));
203}
204
205test "a path is split on whatever delimiter the caller uses" {
206 var buffer: [64]u8 = undefined;
207 try testing.expectEqualStrings(".Work.Reports", try bufPath(&buffer, "Work/Reports", '/'));
208 try testing.expectEqualStrings(".Work.Reports", try bufPath(&buffer, "Work.Reports", '.'));
209}
210
211test "a folder name cannot contain the delimiter, and says so" {
212 var buffer: [64]u8 = undefined;
213 try testing.expectError(error.InvalidComponent, bufComponents(&buffer, &.{"example.com"}));
214 try testing.expectError(error.InvalidComponent, bufComponents(&buffer, &.{"a/b"}));
215 try testing.expectError(error.EmptyComponent, bufComponents(&buffer, &.{""}));
216 try testing.expectError(error.EmptyPath, bufComponents(&buffer, &.{}));
217 try testing.expectError(error.EmptyComponent, bufPath(&buffer, "Work//Reports", '/'));
218 try testing.expectError(error.EmptyComponent, bufPath(&buffer, "/Work", '/'));
219}
220
221test "a name too long for a file name is refused rather than truncated" {
222 var buffer: [8]u8 = undefined;
223 try testing.expectError(error.NameTooLong, bufComponents(&buffer, &.{"a rather long folder name"}));
224}
225
226test "what is and is not a folder" {
227 try testing.expect(isFolder(".Work"));
228 try testing.expect(isFolder(".Work.Reports"));
229 try testing.expect(!isFolder("."));
230 try testing.expect(!isFolder(".."));
231 try testing.expect(!isFolder("cur"));
232 try testing.expect(!isFolder(""));
233 try testing.expect(!isFolder(".Work."));
234 try testing.expect(!isFolder(".Work..Reports"));
235}
236
237test "components round trip through a path" {
238 var it = components(".Work.Reports");
239 try testing.expectEqualStrings("Work", it.next().?);
240 try testing.expectEqualStrings("Reports", it.next().?);
241 try testing.expectEqual(@as(?[]const u8, null), it.next());
242
243 var buffer: [64]u8 = undefined;
244 var w: Io.Writer = .fixed(&buffer);
245 try writeName(&w, ".Work.Reports", '/');
246 try testing.expectEqualStrings("Work/Reports", w.buffered());
247}
248
249test "parent" {
250 try testing.expectEqualStrings(".Work", parent(".Work.Reports").?);
251 try testing.expectEqualStrings(".Work.Reports", parent(".Work.Reports.Q1").?);
252 try testing.expectEqual(@as(?[]const u8, null), parent(".Work"));
253}
254
255test "a folder below another, and one that only looks like it" {
256 try testing.expect(isBelow(".Work.Reports", ".Work"));
257 try testing.expect(isBelow(".Work.Reports.Q1", ".Work"));
258 try testing.expect(!isBelow(".Workshop", ".Work"));
259 try testing.expect(!isBelow(".Work", ".Work"));
260}