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 / main.zig
14 kB 389 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2// SPDX-License-Identifier: MIT 3 4//! `zig-maildir`, a command-line tool over the library. 5//! 6//! It exists to show what the library looks like from outside, and to be 7//! something the NixOS tests can point at a maildir that Dovecot is also 8//! looking at — an interoperability claim needs two programs, and this is the 9//! second one. 10 11const std = @import("std"); 12const Io = std.Io; 13const Dir = Io.Dir; 14const Allocator = std.mem.Allocator; 15 16const mime = @import("mime"); 17const maildir = @import("maildir"); 18const Maildir = maildir.Maildir; 19const Store = maildir.Store; 20const Flags = maildir.Flags; 21 22const usage = 23 \\usage: zig-maildir <command> [arguments] 24 \\ 25 \\ create <maildir> make a maildir, or a whole store 26 \\ deliver <maildir> [file] deliver a message; `-` is stdin 27 \\ list <maildir> every message, with its flags 28 \\ headers <maildir> <id> one message's headers, decoded 29 \\ flag <maildir> <id> <+-><F>.. set or clear flags: +S -F 30 \\ move <maildir> <id> <folder> move a message to another folder 31 \\ remove <maildir> <id> delete a message 32 \\ folders <maildir> the Maildir++ folders in a store 33 \\ mkfolder <maildir> <folder> create one, with its parents 34 \\ rmfolder <maildir> <folder> delete one and everything below it 35 \\ quota <maildir> [bytes] [msgs] show the quota, or set it 36 \\ recalc <maildir> add the store up again 37 \\ clean <maildir> remove old wreckage from tmp 38 \\ 39 \\A folder is named with `/` between its levels: "Work/Reports". 40 \\An id is the unchanging part of a message's name, as `list` prints it. 41 \\ 42; 43 44pub fn main(init: std.process.Init) !void { 45 const io = init.io; 46 const gpa = init.gpa; 47 48 var stdout_buffer: [4096]u8 = undefined; 49 var stdout = Io.File.stdout().writer(io, &stdout_buffer); 50 const out = &stdout.interface; 51 52 var args: std.process.Args.Iterator = .init(init.minimal.args); 53 _ = args.skip(); 54 55 const command = args.next() orelse { 56 try out.writeAll(usage); 57 try out.flush(); 58 return error.MissingCommand; 59 }; 60 const path = args.next() orelse { 61 try out.writeAll(usage); 62 try out.flush(); 63 return error.MissingMaildir; 64 }; 65 66 const cwd: Dir = .cwd(); 67 68 if (std.mem.eql(u8, command, "create")) { 69 var store = try Store.create(cwd, io, path, .{}); 70 store.close(io); 71 try out.print("created {s}\n", .{path}); 72 } else if (std.mem.eql(u8, command, "deliver")) { 73 try deliver(gpa, io, out, cwd, path, args.next()); 74 } else if (std.mem.eql(u8, command, "list")) { 75 try list(gpa, io, out, cwd, path); 76 } else if (std.mem.eql(u8, command, "headers")) { 77 try headers(gpa, io, out, cwd, path, args.next() orelse return error.MissingId); 78 } else if (std.mem.eql(u8, command, "flag")) { 79 const id = args.next() orelse return error.MissingId; 80 try flag(io, out, cwd, path, id, &args); 81 } else if (std.mem.eql(u8, command, "move")) { 82 const id = args.next() orelse return error.MissingId; 83 const destination = args.next() orelse return error.MissingFolder; 84 try move(io, out, cwd, path, id, destination); 85 } else if (std.mem.eql(u8, command, "remove")) { 86 try remove(io, out, cwd, path, args.next() orelse return error.MissingId); 87 } else if (std.mem.eql(u8, command, "folders")) { 88 try folders(gpa, io, out, cwd, path); 89 } else if (std.mem.eql(u8, command, "mkfolder")) { 90 var store = try Store.open(cwd, io, path, .{}); 91 defer store.close(io); 92 var folder = try store.createFolderPath(io, args.next() orelse return error.MissingFolder, '/'); 93 folder.close(io); 94 } else if (std.mem.eql(u8, command, "rmfolder")) { 95 try removeFolder(gpa, io, cwd, path, args.next() orelse return error.MissingFolder); 96 } else if (std.mem.eql(u8, command, "quota")) { 97 try quota(gpa, io, out, cwd, path, &args); 98 } else if (std.mem.eql(u8, command, "recalc")) { 99 var store = try Store.open(cwd, io, path, .{}); 100 defer store.close(io); 101 const total = try store.recalculateQuota(gpa, io); 102 try out.print("{d} bytes in {d} messages\n", .{ total.bytes, total.messages }); 103 } else if (std.mem.eql(u8, command, "clean")) { 104 var m = try Maildir.open(cwd, io, path, .{}); 105 defer m.close(io); 106 const removed = try m.cleanTemp(io, Maildir.temp_max_age); 107 try out.print("removed {d}\n", .{removed}); 108 } else { 109 try out.writeAll(usage); 110 try out.flush(); 111 return error.UnknownCommand; 112 } 113 114 try out.flush(); 115} 116 117/// Everything in the store: the top-level maildir first, then each folder. 118/// The folder name is printed beside every message so that the output can be 119/// read without knowing which mailbox is which. 120fn forEachMailbox( 121 gpa: Allocator, 122 io: Io, 123 cwd: Dir, 124 path: []const u8, 125 context: anytype, 126 comptime visit: fn (@TypeOf(context), []const u8, *Maildir) anyerror!void, 127) !void { 128 var store = try Store.open(cwd, io, path, .{}); 129 defer store.close(io); 130 131 var inbox = try store.inbox(io); 132 defer inbox.close(io); 133 try visit(context, "INBOX", &inbox); 134 135 var names = try store.folders(gpa, io); 136 defer names.deinit(gpa); 137 for (names.names) |dirname| { 138 var folder_maildir = store.openFolderDirname(io, dirname) catch continue; 139 defer folder_maildir.close(io); 140 141 var buffer: [Dir.max_name_bytes]u8 = undefined; 142 var w: Io.Writer = .fixed(&buffer); 143 try maildir.folder.writeName(&w, dirname, '/'); 144 try visit(context, w.buffered(), &folder_maildir); 145 } 146} 147 148fn list(gpa: Allocator, io: Io, out: *Io.Writer, cwd: Dir, path: []const u8) !void { 149 const Context = struct { 150 out: *Io.Writer, 151 io: Io, 152 153 fn visit(self: @This(), name: []const u8, m: *Maildir) anyerror!void { 154 for ([_]Maildir.Subdir{ .new, .cur }) |which| { 155 var it = m.iterate(which); 156 while (try it.next(self.io)) |message| { 157 const size = message.size(m, self.io) catch 0; 158 try self.out.print("{s}\t{s}\t{f}\t{d}\t{s}\n", .{ 159 @tagName(which), 160 name, 161 message.flags(), 162 size, 163 message.id(), 164 }); 165 } 166 } 167 } 168 }; 169 try forEachMailbox(gpa, io, cwd, path, Context{ .out = out, .io = io }, Context.visit); 170} 171 172fn folders(gpa: Allocator, io: Io, out: *Io.Writer, cwd: Dir, path: []const u8) !void { 173 var store = try Store.open(cwd, io, path, .{}); 174 defer store.close(io); 175 176 var names = try store.folders(gpa, io); 177 defer names.deinit(gpa); 178 for (names.names) |dirname| { 179 try maildir.folder.writeName(out, dirname, '/'); 180 try out.writeByte('\n'); 181 } 182} 183 184fn deliver( 185 gpa: Allocator, 186 io: Io, 187 out: *Io.Writer, 188 cwd: Dir, 189 path: []const u8, 190 file: ?[]const u8, 191) !void { 192 var m = try Maildir.open(cwd, io, path, .{}); 193 defer m.close(io); 194 195 const source = file orelse "-"; 196 const bytes = if (std.mem.eql(u8, source, "-")) blk: { 197 var buffer: [4096]u8 = undefined; 198 var stdin = Io.File.stdin().readerStreaming(io, &buffer); 199 break :blk try stdin.interface.allocRemaining(gpa, .unlimited); 200 } else try cwd.readFileAlloc(io, source, gpa, .unlimited); 201 defer gpa.free(bytes); 202 203 const message = try m.deliver(io, bytes, .{}); 204 try out.print("{s}\n", .{message.id()}); 205} 206 207/// Finds a message by its id anywhere in the store, so that the tool's 208/// commands can take the id `list` printed without also being told which 209/// mailbox it was in. 210const Found = struct { 211 store: Store, 212 mailbox: Maildir, 213 message: maildir.Message, 214 215 fn close(self: *Found, io: Io) void { 216 self.mailbox.close(io); 217 self.store.close(io); 218 } 219}; 220 221fn find(gpa: Allocator, io: Io, cwd: Dir, path: []const u8, id: []const u8) !Found { 222 var store = try Store.open(cwd, io, path, .{}); 223 errdefer store.close(io); 224 225 var inbox = try store.inbox(io); 226 if (try inbox.find(io, id)) |message| { 227 return .{ .store = store, .mailbox = inbox, .message = message }; 228 } 229 inbox.close(io); 230 231 var names = try store.folders(gpa, io); 232 defer names.deinit(gpa); 233 for (names.names) |dirname| { 234 var folder_maildir = store.openFolderDirname(io, dirname) catch continue; 235 if (try folder_maildir.find(io, id)) |message| { 236 return .{ .store = store, .mailbox = folder_maildir, .message = message }; 237 } 238 folder_maildir.close(io); 239 } 240 return error.MessageNotFound; 241} 242 243fn headers( 244 gpa: Allocator, 245 io: Io, 246 out: *Io.Writer, 247 cwd: Dir, 248 path: []const u8, 249 id: []const u8, 250) !void { 251 var found = try find(gpa, io, cwd, path, id); 252 defer found.close(io); 253 254 var message = try found.message.parse(&found.mailbox, gpa, io, .unlimited, .{}); 255 defer message.deinit(); 256 257 // Decoded, which is the whole reason this goes through zig-mime rather 258 // than printing the first few lines of the file. 259 if (try message.root.subject()) |subject| try out.print("Subject: {s}\n", .{subject}); 260 for ([_][]const u8{ "from", "to", "cc" }) |field| { 261 var addresses = message.root.addresses(gpa, field) catch continue; 262 defer addresses.deinit(gpa); 263 if (addresses.addresses.len == 0) continue; 264 try out.print("{s}:", .{field}); 265 for (addresses.addresses) |address| try out.print(" {f}", .{address}); 266 try out.writeByte('\n'); 267 } 268} 269 270fn flag( 271 io: Io, 272 out: *Io.Writer, 273 cwd: Dir, 274 path: []const u8, 275 id: []const u8, 276 args: *std.process.Args.Iterator, 277) !void { 278 // The store has to stay open while the message is renamed, so this cannot 279 // use `find`'s allocator-free path; the arena is the process allocator. 280 var buffer: [4096]u8 = undefined; 281 var fba: std.heap.FixedBufferAllocator = .init(&buffer); 282 283 var found = try find(fba.allocator(), io, cwd, path, id); 284 defer found.close(io); 285 286 var add: Flags = .none; 287 var clear: Flags = .none; 288 while (args.next()) |argument| { 289 if (argument.len < 2) return error.BadFlagArgument; 290 const adding = switch (argument[0]) { 291 '+' => true, 292 '-' => false, 293 else => return error.BadFlagArgument, 294 }; 295 for (argument[1..]) |letter| { 296 if (maildir.Flags.Letters.indexOf(letter) == null) return error.BadFlagArgument; 297 if (adding) add.setLetter(letter, true) else clear.setLetter(letter, true); 298 } 299 } 300 301 try found.message.setFlags( 302 &found.mailbox, 303 io, 304 found.message.flags().unionWith(add).subtract(clear), 305 ); 306 try out.print("{f}\n", .{found.message.flags()}); 307} 308 309fn move( 310 io: Io, 311 out: *Io.Writer, 312 cwd: Dir, 313 path: []const u8, 314 id: []const u8, 315 destination: []const u8, 316) !void { 317 var buffer: [4096]u8 = undefined; 318 var fba: std.heap.FixedBufferAllocator = .init(&buffer); 319 320 var found = try find(fba.allocator(), io, cwd, path, id); 321 defer found.close(io); 322 323 var target = if (std.mem.eql(u8, destination, "INBOX")) 324 try found.store.inbox(io) 325 else 326 try found.store.openFolderPath(io, destination, '/'); 327 defer target.close(io); 328 329 try found.message.moveTo(&found.mailbox, &target, io); 330 try out.print("{s}\n", .{found.message.id()}); 331} 332 333fn remove(io: Io, out: *Io.Writer, cwd: Dir, path: []const u8, id: []const u8) !void { 334 var buffer: [4096]u8 = undefined; 335 var fba: std.heap.FixedBufferAllocator = .init(&buffer); 336 337 var found = try find(fba.allocator(), io, cwd, path, id); 338 defer found.close(io); 339 try found.message.remove(&found.mailbox, io); 340 try out.print("removed {s}\n", .{id}); 341} 342 343fn removeFolder(gpa: Allocator, io: Io, cwd: Dir, path: []const u8, name: []const u8) !void { 344 var store = try Store.open(cwd, io, path, .{}); 345 defer store.close(io); 346 347 var components: [Store.max_depth][]const u8 = undefined; 348 var count: usize = 0; 349 var it = std.mem.splitScalar(u8, name, '/'); 350 while (it.next()) |component| : (count += 1) { 351 if (count == components.len) return error.FolderTooDeep; 352 components[count] = component; 353 } 354 try store.deleteFolder(gpa, io, components[0..count], .{ .recursive = true }); 355} 356 357fn quota( 358 gpa: Allocator, 359 io: Io, 360 out: *Io.Writer, 361 cwd: Dir, 362 path: []const u8, 363 args: *std.process.Args.Iterator, 364) !void { 365 var store = try Store.open(cwd, io, path, .{}); 366 defer store.close(io); 367 368 if (args.next()) |bytes_text| { 369 const limits: maildir.quota.Limits = .{ 370 .bytes = std.fmt.parseInt(u64, bytes_text, 10) catch null, 371 .messages = if (args.next()) |messages_text| 372 std.fmt.parseInt(u64, messages_text, 10) catch null 373 else 374 null, 375 }; 376 const existing = try store.quotaState(gpa, io); 377 try store.setQuota(io, limits, if (existing) |state| state.usage else .zero); 378 } 379 380 const state = (try store.quotaState(gpa, io)) orelse { 381 try out.writeAll("no quota\n"); 382 return; 383 }; 384 const used = state.usage.clamped(); 385 try out.print("limit\t{f}\n", .{state.limits}); 386 try out.print("used\t{d} bytes\t{d} messages\n", .{ used.bytes, used.messages }); 387 try out.print("over\t{}\n", .{state.exceeded()}); 388 try out.print("stale\t{}\n", .{state.isStale(io, .{})}); 389}