// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! `zig-maildir`, a command-line tool over the library. //! //! It exists to show what the library looks like from outside, and to be //! something the NixOS tests can point at a maildir that Dovecot is also //! looking at — an interoperability claim needs two programs, and this is the //! second one. const std = @import("std"); const Io = std.Io; const Dir = Io.Dir; const Allocator = std.mem.Allocator; const mime = @import("mime"); const maildir = @import("maildir"); const Maildir = maildir.Maildir; const Store = maildir.Store; const Flags = maildir.Flags; const usage = \\usage: zig-maildir [arguments] \\ \\ create make a maildir, or a whole store \\ deliver [file] deliver a message; `-` is stdin \\ list every message, with its flags \\ headers one message's headers, decoded \\ flag <+->.. set or clear flags: +S -F \\ move move a message to another folder \\ remove delete a message \\ folders the Maildir++ folders in a store \\ mkfolder create one, with its parents \\ rmfolder delete one and everything below it \\ quota [bytes] [msgs] show the quota, or set it \\ recalc add the store up again \\ clean remove old wreckage from tmp \\ \\A folder is named with `/` between its levels: "Work/Reports". \\An id is the unchanging part of a message's name, as `list` prints it. \\ ; pub fn main(init: std.process.Init) !void { const io = init.io; const gpa = init.gpa; var stdout_buffer: [4096]u8 = undefined; var stdout = Io.File.stdout().writer(io, &stdout_buffer); const out = &stdout.interface; var args: std.process.Args.Iterator = .init(init.minimal.args); _ = args.skip(); const command = args.next() orelse { try out.writeAll(usage); try out.flush(); return error.MissingCommand; }; const path = args.next() orelse { try out.writeAll(usage); try out.flush(); return error.MissingMaildir; }; const cwd: Dir = .cwd(); if (std.mem.eql(u8, command, "create")) { var store = try Store.create(cwd, io, path, .{}); store.close(io); try out.print("created {s}\n", .{path}); } else if (std.mem.eql(u8, command, "deliver")) { try deliver(gpa, io, out, cwd, path, args.next()); } else if (std.mem.eql(u8, command, "list")) { try list(gpa, io, out, cwd, path); } else if (std.mem.eql(u8, command, "headers")) { try headers(gpa, io, out, cwd, path, args.next() orelse return error.MissingId); } else if (std.mem.eql(u8, command, "flag")) { const id = args.next() orelse return error.MissingId; try flag(io, out, cwd, path, id, &args); } else if (std.mem.eql(u8, command, "move")) { const id = args.next() orelse return error.MissingId; const destination = args.next() orelse return error.MissingFolder; try move(io, out, cwd, path, id, destination); } else if (std.mem.eql(u8, command, "remove")) { try remove(io, out, cwd, path, args.next() orelse return error.MissingId); } else if (std.mem.eql(u8, command, "folders")) { try folders(gpa, io, out, cwd, path); } else if (std.mem.eql(u8, command, "mkfolder")) { var store = try Store.open(cwd, io, path, .{}); defer store.close(io); var folder = try store.createFolderPath(io, args.next() orelse return error.MissingFolder, '/'); folder.close(io); } else if (std.mem.eql(u8, command, "rmfolder")) { try removeFolder(gpa, io, cwd, path, args.next() orelse return error.MissingFolder); } else if (std.mem.eql(u8, command, "quota")) { try quota(gpa, io, out, cwd, path, &args); } else if (std.mem.eql(u8, command, "recalc")) { var store = try Store.open(cwd, io, path, .{}); defer store.close(io); const total = try store.recalculateQuota(gpa, io); try out.print("{d} bytes in {d} messages\n", .{ total.bytes, total.messages }); } else if (std.mem.eql(u8, command, "clean")) { var m = try Maildir.open(cwd, io, path, .{}); defer m.close(io); const removed = try m.cleanTemp(io, Maildir.temp_max_age); try out.print("removed {d}\n", .{removed}); } else { try out.writeAll(usage); try out.flush(); return error.UnknownCommand; } try out.flush(); } /// Everything in the store: the top-level maildir first, then each folder. /// The folder name is printed beside every message so that the output can be /// read without knowing which mailbox is which. fn forEachMailbox( gpa: Allocator, io: Io, cwd: Dir, path: []const u8, context: anytype, comptime visit: fn (@TypeOf(context), []const u8, *Maildir) anyerror!void, ) !void { var store = try Store.open(cwd, io, path, .{}); defer store.close(io); var inbox = try store.inbox(io); defer inbox.close(io); try visit(context, "INBOX", &inbox); var names = try store.folders(gpa, io); defer names.deinit(gpa); for (names.names) |dirname| { var folder_maildir = store.openFolderDirname(io, dirname) catch continue; defer folder_maildir.close(io); var buffer: [Dir.max_name_bytes]u8 = undefined; var w: Io.Writer = .fixed(&buffer); try maildir.folder.writeName(&w, dirname, '/'); try visit(context, w.buffered(), &folder_maildir); } } fn list(gpa: Allocator, io: Io, out: *Io.Writer, cwd: Dir, path: []const u8) !void { const Context = struct { out: *Io.Writer, io: Io, fn visit(self: @This(), name: []const u8, m: *Maildir) anyerror!void { for ([_]Maildir.Subdir{ .new, .cur }) |which| { var it = m.iterate(which); while (try it.next(self.io)) |message| { const size = message.size(m, self.io) catch 0; try self.out.print("{s}\t{s}\t{f}\t{d}\t{s}\n", .{ @tagName(which), name, message.flags(), size, message.id(), }); } } } }; try forEachMailbox(gpa, io, cwd, path, Context{ .out = out, .io = io }, Context.visit); } fn folders(gpa: Allocator, io: Io, out: *Io.Writer, cwd: Dir, path: []const u8) !void { var store = try Store.open(cwd, io, path, .{}); defer store.close(io); var names = try store.folders(gpa, io); defer names.deinit(gpa); for (names.names) |dirname| { try maildir.folder.writeName(out, dirname, '/'); try out.writeByte('\n'); } } fn deliver( gpa: Allocator, io: Io, out: *Io.Writer, cwd: Dir, path: []const u8, file: ?[]const u8, ) !void { var m = try Maildir.open(cwd, io, path, .{}); defer m.close(io); const source = file orelse "-"; const bytes = if (std.mem.eql(u8, source, "-")) blk: { var buffer: [4096]u8 = undefined; var stdin = Io.File.stdin().readerStreaming(io, &buffer); break :blk try stdin.interface.allocRemaining(gpa, .unlimited); } else try cwd.readFileAlloc(io, source, gpa, .unlimited); defer gpa.free(bytes); const message = try m.deliver(io, bytes, .{}); try out.print("{s}\n", .{message.id()}); } /// Finds a message by its id anywhere in the store, so that the tool's /// commands can take the id `list` printed without also being told which /// mailbox it was in. const Found = struct { store: Store, mailbox: Maildir, message: maildir.Message, fn close(self: *Found, io: Io) void { self.mailbox.close(io); self.store.close(io); } }; fn find(gpa: Allocator, io: Io, cwd: Dir, path: []const u8, id: []const u8) !Found { var store = try Store.open(cwd, io, path, .{}); errdefer store.close(io); var inbox = try store.inbox(io); if (try inbox.find(io, id)) |message| { return .{ .store = store, .mailbox = inbox, .message = message }; } inbox.close(io); var names = try store.folders(gpa, io); defer names.deinit(gpa); for (names.names) |dirname| { var folder_maildir = store.openFolderDirname(io, dirname) catch continue; if (try folder_maildir.find(io, id)) |message| { return .{ .store = store, .mailbox = folder_maildir, .message = message }; } folder_maildir.close(io); } return error.MessageNotFound; } fn headers( gpa: Allocator, io: Io, out: *Io.Writer, cwd: Dir, path: []const u8, id: []const u8, ) !void { var found = try find(gpa, io, cwd, path, id); defer found.close(io); var message = try found.message.parse(&found.mailbox, gpa, io, .unlimited, .{}); defer message.deinit(); // Decoded, which is the whole reason this goes through zig-mime rather // than printing the first few lines of the file. if (try message.root.subject()) |subject| try out.print("Subject: {s}\n", .{subject}); for ([_][]const u8{ "from", "to", "cc" }) |field| { var addresses = message.root.addresses(gpa, field) catch continue; defer addresses.deinit(gpa); if (addresses.addresses.len == 0) continue; try out.print("{s}:", .{field}); for (addresses.addresses) |address| try out.print(" {f}", .{address}); try out.writeByte('\n'); } } fn flag( io: Io, out: *Io.Writer, cwd: Dir, path: []const u8, id: []const u8, args: *std.process.Args.Iterator, ) !void { // The store has to stay open while the message is renamed, so this cannot // use `find`'s allocator-free path; the arena is the process allocator. var buffer: [4096]u8 = undefined; var fba: std.heap.FixedBufferAllocator = .init(&buffer); var found = try find(fba.allocator(), io, cwd, path, id); defer found.close(io); var add: Flags = .none; var clear: Flags = .none; while (args.next()) |argument| { if (argument.len < 2) return error.BadFlagArgument; const adding = switch (argument[0]) { '+' => true, '-' => false, else => return error.BadFlagArgument, }; for (argument[1..]) |letter| { if (maildir.Flags.Letters.indexOf(letter) == null) return error.BadFlagArgument; if (adding) add.setLetter(letter, true) else clear.setLetter(letter, true); } } try found.message.setFlags( &found.mailbox, io, found.message.flags().unionWith(add).subtract(clear), ); try out.print("{f}\n", .{found.message.flags()}); } fn move( io: Io, out: *Io.Writer, cwd: Dir, path: []const u8, id: []const u8, destination: []const u8, ) !void { var buffer: [4096]u8 = undefined; var fba: std.heap.FixedBufferAllocator = .init(&buffer); var found = try find(fba.allocator(), io, cwd, path, id); defer found.close(io); var target = if (std.mem.eql(u8, destination, "INBOX")) try found.store.inbox(io) else try found.store.openFolderPath(io, destination, '/'); defer target.close(io); try found.message.moveTo(&found.mailbox, &target, io); try out.print("{s}\n", .{found.message.id()}); } fn remove(io: Io, out: *Io.Writer, cwd: Dir, path: []const u8, id: []const u8) !void { var buffer: [4096]u8 = undefined; var fba: std.heap.FixedBufferAllocator = .init(&buffer); var found = try find(fba.allocator(), io, cwd, path, id); defer found.close(io); try found.message.remove(&found.mailbox, io); try out.print("removed {s}\n", .{id}); } fn removeFolder(gpa: Allocator, io: Io, cwd: Dir, path: []const u8, name: []const u8) !void { var store = try Store.open(cwd, io, path, .{}); defer store.close(io); var components: [Store.max_depth][]const u8 = undefined; var count: usize = 0; var it = std.mem.splitScalar(u8, name, '/'); while (it.next()) |component| : (count += 1) { if (count == components.len) return error.FolderTooDeep; components[count] = component; } try store.deleteFolder(gpa, io, components[0..count], .{ .recursive = true }); } fn quota( gpa: Allocator, io: Io, out: *Io.Writer, cwd: Dir, path: []const u8, args: *std.process.Args.Iterator, ) !void { var store = try Store.open(cwd, io, path, .{}); defer store.close(io); if (args.next()) |bytes_text| { const limits: maildir.quota.Limits = .{ .bytes = std.fmt.parseInt(u64, bytes_text, 10) catch null, .messages = if (args.next()) |messages_text| std.fmt.parseInt(u64, messages_text, 10) catch null else null, }; const existing = try store.quotaState(gpa, io); try store.setQuota(io, limits, if (existing) |state| state.usage else .zero); } const state = (try store.quotaState(gpa, io)) orelse { try out.writeAll("no quota\n"); return; }; const used = state.usage.clamped(); try out.print("limit\t{f}\n", .{state.limits}); try out.print("used\t{d} bytes\t{d} messages\n", .{ used.bytes, used.messages }); try out.print("over\t{}\n", .{state.exceeded()}); try out.print("stale\t{}\n", .{state.isStale(io, .{})}); }