A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
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}