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 / folder.zig
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}