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