// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Maildir++ folder names: the convention that turns one maildir into a tree //! of them. //! //! A maildir has no room for a second mailbox in it, so Maildir++ puts the //! folders *beside* the messages, as hidden directories in the top-level //! maildir, each a complete maildir of its own: //! //! ```text //! Maildir/ the top-level maildir, which IMAP calls INBOX //! tmp/ new/ cur/ its own messages //! maildirsize the quota file, covering everything below //! .Work/ the folder "Work" //! tmp/ new/ cur/ //! maildirfolder the marker that says this is a folder and not a stray //! .Work.Reports/ the folder "Work/Reports" //! tmp/ new/ cur/ //! maildirfolder //! ``` //! //! The hierarchy is **flat on disk and nested in the name**: `.Work.Reports` //! is a sibling directory of `.Work`, not a child of it, which is what lets a //! whole folder tree be listed with one `readdir` and why renaming a folder //! means renaming every descendant. //! //! The dot is the hierarchy delimiter, and that has a consequence worth being //! explicit about: **a folder name cannot contain a dot**. There is no escape //! for one — Maildir++ never defined one — so a mailbox the user calls //! `example.com` is either the folder `com` inside the folder `example` or it //! is not representable, and this library says so with //! `error.InvalidComponent` rather than silently creating the wrong thing. //! //! What is *not* here is subscriptions. Which folders a client has subscribed //! to is IMAP's business and every server keeps it differently — Courier in //! `courierimapsubscribed`, Dovecot in `subscriptions` — so a file with that //! name is left alone rather than guessed at. const std = @import("std"); const Io = std.Io; const Dir = Io.Dir; const testing = std.testing; /// The character between one level of the hierarchy and the next, and the /// character a directory name begins with to mark it as a folder. pub const delimiter = '.'; /// The empty file that says a directory is a Maildir++ folder rather than /// something else that happens to be named with a leading dot. Courier /// requires it; Dovecot writes it and does not insist on it. pub const marker = "maildirfolder"; pub const Error = error{ /// A path with no components. The top-level maildir is not a folder, and /// naming it as one is a mistake worth reporting rather than resolving. EmptyPath, /// A component that is the empty string: `Work//Reports`, or a path with /// a leading or trailing delimiter. EmptyComponent, /// A component containing a dot or a slash. A dot is the hierarchy /// delimiter and Maildir++ has no escape for one; a slash would make the /// name a path. InvalidComponent, /// The resulting directory name is longer than a file name may be. NameTooLong, }; /// Whether a component can be part of a folder name. pub fn validComponent(component: []const u8) bool { if (component.len == 0) return false; for (component) |c| switch (c) { delimiter, '/', 0 => return false, else => {}, }; return true; } /// Writes the directory name for a folder given its components: /// `.{"Work", "Reports"}` becomes `.Work.Reports`. pub fn writeComponents(w: *Io.Writer, path: []const []const u8) (Error || Io.Writer.Error)!void { if (path.len == 0) return error.EmptyPath; for (path) |component| { if (component.len == 0) return error.EmptyComponent; if (!validComponent(component)) return error.InvalidComponent; try w.writeByte(delimiter); try w.writeAll(component); } } /// Writes the directory name for a folder given a path with a delimiter of /// the caller's choosing: `"Work/Reports"` with `/` becomes `.Work.Reports`. /// /// This is the form an IMAP server has, since IMAP carries the hierarchy /// delimiter in the protocol and it is very often a slash even when the store /// underneath uses a dot. pub fn writePath( w: *Io.Writer, path: []const u8, path_delimiter: u8, ) (Error || Io.Writer.Error)!void { if (path.len == 0) return error.EmptyPath; var it = std.mem.splitScalar(u8, path, path_delimiter); while (it.next()) |component| { if (component.len == 0) return error.EmptyComponent; if (!validComponent(component)) return error.InvalidComponent; try w.writeByte(delimiter); try w.writeAll(component); } } /// `writeComponents`, into a buffer. pub fn bufComponents(buffer: []u8, path: []const []const u8) Error![]u8 { var w: Io.Writer = .fixed(buffer); writeComponents(&w, path) catch |err| switch (err) { error.WriteFailed => return error.NameTooLong, else => |e| return e, }; return w.buffered(); } /// `writePath`, into a buffer. pub fn bufPath(buffer: []u8, path: []const u8, path_delimiter: u8) Error![]u8 { var w: Io.Writer = .fixed(buffer); writePath(&w, path, path_delimiter) catch |err| switch (err) { error.WriteFailed => return error.NameTooLong, else => |e| return e, }; return w.buffered(); } /// Whether a directory name in the top-level maildir names a folder. `.` and /// `..` are not folders, and neither is anything without a leading dot. pub fn isFolder(dirname: []const u8) bool { if (dirname.len < 2 or dirname[0] != delimiter) return false; if (std.mem.eql(u8, dirname, "..")) return false; // `.Work..Reports` has an empty component in it and is not a name this // library would have produced. var it = std.mem.splitScalar(u8, dirname[1..], delimiter); while (it.next()) |component| if (component.len == 0) return false; return true; } /// The components of a folder's directory name, outermost first. /// `.Work.Reports` yields `Work` then `Reports`. pub fn components(dirname: []const u8) Iterator { return .{ .rest = if (dirname.len > 0 and dirname[0] == delimiter) dirname[1..] else dirname }; } pub const Iterator = struct { rest: []const u8, done: bool = false, pub fn next(self: *Iterator) ?[]const u8 { if (self.done) return null; if (std.mem.findScalar(u8, self.rest, delimiter)) |index| { const component = self.rest[0..index]; self.rest = self.rest[index + 1 ..]; return component; } self.done = true; return self.rest; } }; /// Writes a folder's directory name as a path with the caller's delimiter: /// `.Work.Reports` becomes `Work/Reports`. The reverse of `writePath`. pub fn writeName( w: *Io.Writer, dirname: []const u8, path_delimiter: u8, ) Io.Writer.Error!void { var it = components(dirname); var first = true; while (it.next()) |component| { if (!first) try w.writeByte(path_delimiter); first = false; try w.writeAll(component); } } /// The directory name of a folder's parent, or null if it has none because it /// is directly below the top-level maildir. pub fn parent(dirname: []const u8) ?[]const u8 { const index = std.mem.findScalarLast(u8, dirname, delimiter) orelse return null; if (index == 0) return null; return dirname[0..index]; } /// Whether one folder is somewhere below another. `.Work.Reports.Q1` is under /// `.Work`; `.Workshop` is not, which is the case a plain `startsWith` gets /// wrong and the reason this exists. pub fn isBelow(dirname: []const u8, ancestor: []const u8) bool { if (dirname.len <= ancestor.len) return false; if (!std.mem.startsWith(u8, dirname, ancestor)) return false; return dirname[ancestor.len] == delimiter; } test "a folder name is its components with a dot in front of each" { var buffer: [64]u8 = undefined; try testing.expectEqualStrings(".Work", try bufComponents(&buffer, &.{"Work"})); try testing.expectEqualStrings(".Work.Reports", try bufComponents(&buffer, &.{ "Work", "Reports" })); } test "a path is split on whatever delimiter the caller uses" { var buffer: [64]u8 = undefined; try testing.expectEqualStrings(".Work.Reports", try bufPath(&buffer, "Work/Reports", '/')); try testing.expectEqualStrings(".Work.Reports", try bufPath(&buffer, "Work.Reports", '.')); } test "a folder name cannot contain the delimiter, and says so" { var buffer: [64]u8 = undefined; try testing.expectError(error.InvalidComponent, bufComponents(&buffer, &.{"example.com"})); try testing.expectError(error.InvalidComponent, bufComponents(&buffer, &.{"a/b"})); try testing.expectError(error.EmptyComponent, bufComponents(&buffer, &.{""})); try testing.expectError(error.EmptyPath, bufComponents(&buffer, &.{})); try testing.expectError(error.EmptyComponent, bufPath(&buffer, "Work//Reports", '/')); try testing.expectError(error.EmptyComponent, bufPath(&buffer, "/Work", '/')); } test "a name too long for a file name is refused rather than truncated" { var buffer: [8]u8 = undefined; try testing.expectError(error.NameTooLong, bufComponents(&buffer, &.{"a rather long folder name"})); } test "what is and is not a folder" { try testing.expect(isFolder(".Work")); try testing.expect(isFolder(".Work.Reports")); try testing.expect(!isFolder(".")); try testing.expect(!isFolder("..")); try testing.expect(!isFolder("cur")); try testing.expect(!isFolder("")); try testing.expect(!isFolder(".Work.")); try testing.expect(!isFolder(".Work..Reports")); } test "components round trip through a path" { var it = components(".Work.Reports"); try testing.expectEqualStrings("Work", it.next().?); try testing.expectEqualStrings("Reports", it.next().?); try testing.expectEqual(@as(?[]const u8, null), it.next()); var buffer: [64]u8 = undefined; var w: Io.Writer = .fixed(&buffer); try writeName(&w, ".Work.Reports", '/'); try testing.expectEqualStrings("Work/Reports", w.buffered()); } test "parent" { try testing.expectEqualStrings(".Work", parent(".Work.Reports").?); try testing.expectEqualStrings(".Work.Reports", parent(".Work.Reports.Q1").?); try testing.expectEqual(@as(?[]const u8, null), parent(".Work")); } test "a folder below another, and one that only looks like it" { try testing.expect(isBelow(".Work.Reports", ".Work")); try testing.expect(isBelow(".Work.Reports.Q1", ".Work")); try testing.expect(!isBelow(".Workshop", ".Work")); try testing.expect(!isBelow(".Work", ".Work")); }