ReviewOS

also looking at this

mail-os/mail

Harden encrypted mail storage, recovery, and forwarding

#8
Merged chris wants to merge codex/mail-storage-hardening into main
50 files +1701 -9001

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/FORWARDING.mdadded+45-0
Changes to docs/FORWARDING.md
@@ -0,0 +1,45 @@
1# Forwarding
2
3Forwarding is declared in each Stacks application's `config/email.ts`:
4
5```ts
6forwards: {
7 'socials@stacksjs.com': ['chris@stacksjs.com'],
8 'hi@stacksjs.com': ['chris@stacksjs.com'],
9}
10```
11
12Buddy merges those declarations into the shared `/opt/mail/forwards.json`
13manifest and atomically compiles `/opt/mail/forwards.sieve`. The runtime script
14uses the standard RFC 5228 Sieve `envelope`, `redirect`, and `keep` commands.
15The JSON file is retained as a generated compatibility manifest for older
16server binaries; it is not the configuration source.
17
18Example generated rule:
19
20```sieve
21require ["envelope"];
22
23if envelope :is "to" "socials@stacksjs.com" {
24 redirect "chris@stacksjs.com";
25 keep;
26}
27```
28
29Rules are loaded for each accepted non-junk message. `keep` leaves the original
30copy in the role mailbox, while `redirect` creates the forwarded copy. Junk is
31not forwarded, which prevents the server from relaying spam.
32
33Reconcile from configuration and validate the live script:
34
35```bash
36buddy mail:provision --env production
37buddy mail:forward:compile
38```
39
40The compiler rejects invalid mailbox names, invalid destination addresses,
41empty target lists, and control characters. It sorts rules and removes
42duplicate targets for deterministic output.
43
44Implementation follow-up is tracked in
45[mail-os/mail#6](https://github.com/mail-os/mail/issues/6).
docs/MAILDIR.mdadded+45-0
Changes to docs/MAILDIR.md
@@ -0,0 +1,45 @@
1# Maildir++ storage
2
3Production mailboxes use Maildir++:
4
5```text
6mail/<mailbox>/
7├── tmp/
8├── new/
9├── cur/
10├── .Junk/
11│ ├── tmp/
12│ ├── new/
13│ └── cur/
14└── Junk -> .Junk/cur
15```
16
17The dot-prefixed directory is the standard Maildir++ representation. The
18visible relative symlink is a compatibility bridge for older server paths and
19is included in IMAP LIST responses. New IMAP folders are created in this form.
20DELETE and RENAME update the canonical directory and compatibility link
21together.
22
23Migrate an existing server with the reusable Buddy operation:
24
25```bash
26buddy mail:storage:maildir:migrate
27```
28
29The migration stops `mail.service`, creates missing `tmp/new/cur` directories,
30moves existing flat-folder messages to `.Folder/cur`, installs relative
31compatibility symlinks, and restarts the service. It is idempotent and refuses
32unexpected symlink targets, filename collisions, and nested legacy folders.
33
34Direct server invocation:
35
36```bash
37/usr/local/sbin/maildir-migrate status
38/usr/local/sbin/maildir-migrate migrate
39```
40
41Backups archive one copy of each folder rather than dereferencing both the
42canonical directory and its compatibility link.
43
44Implementation follow-up is tracked in
45[mail-os/mail#5](https://github.com/mail-os/mail/issues/5).
packages/zig/src/core/protocol.zigmodified+129-6
Changes to packages/zig/src/core/protocol.zig
@@ -22,6 +22,7 @@ const outbound = ;
2222const retry = ;
2323const aliases = ;
2424const typesense = ;
25const sieve = ;
2526
2627/// Maximum number of in-flight detached outbound/forward delivery worker
2728/// threads across the whole process. Without a cap, a flood of recipients
@@ -175,13 +176,13 @@ fn hasReverseDns(allocator: std.mem.Allocator, ip: []const u8) bool { renamed throughout
175176 });
176177 const rc1 = std.c.getaddrinfo(ip_z.ptr, null, &hints, &res);
177178 defer if (res) |r| std.c.freeaddrinfo(r);
178 if (@backingInt(rc1) != 0) return true; // unparseable -> don't penalize
179 if (@intFromEnum(rc1) != 0) return true; // unparseable -> don't penalize
179180 const node = res orelse return true;
180181 const sa = node.addr orelse return true;
181182
182183 var host_buf: [256]u8 = undefined;
183184 const rc2 = std.c.getnameinfo(sa, node.addrlen, &host_buf, @intCast(host_buf.len), null, 0, .{});
184 if (@backingInt(rc2) != 0) return true; // lookup error -> don't penalize
185 if (@intFromEnum(rc2) != 0) return true; // lookup error -> don't penalize
185186 const host = std.mem.sliceTo(&host_buf, 0);
186187 return !std.mem.eql(u8, host, ip); // numeric echo means no PTR
187188}
@@ -679,7 +680,7 @@ pub const Session = struct {
679680 self.conn_wrapper.deinitTls();
680681 // Clean up TLS reader/writer - use stored alignment
681682 if (self.tls_reader_info) |info| {
682 const log2_align: std.mem.Alignment = @fromBackingInt(@intCast(std.math.log2(info.alignment)));
683 const log2_align: std.mem.Alignment = @enumFromInt(std.math.log2(info.alignment));
683684 const ptr_bytes: [*]u8 = @ptrCast(info.ptr);
684685 switch (info.alignment) {
685686 1 => self.allocator.rawFree(ptr_bytes[0..info.size], log2_align, @returnAddress()),
@@ -691,7 +692,7 @@ pub const Session = struct {
691692 }
692693 }
693694 if (self.tls_writer_info) |info| {
694 const log2_align: std.mem.Alignment = @fromBackingInt(@intCast(std.math.log2(info.alignment)));
695 const log2_align: std.mem.Alignment = @enumFromInt(std.math.log2(info.alignment));
695696 const ptr_bytes: [*]u8 = @ptrCast(info.ptr);
696697 switch (info.alignment) {
697698 1 => self.allocator.rawFree(ptr_bytes[0..info.size], log2_align, @returnAddress()),
@@ -2164,9 +2165,131 @@ pub const Session = struct {
21642165 };
21652166 }
21662167
2167 /// Check forwards.json for auto-forwarding rules and relay if matched.
2168 /// Format: {"hi": ["chris@stacksjs.com"], "info": ["a@x.com","b@x.com"]}
2168 fn forwardMessage(self: *Session, username: []const u8, sender: []const u8, data: []const u8, forward_to: []const u8) !void {
2169 self.logger.info("Auto-forwarding from {s} to {s}", .{ username, forward_to });
2170
2171 const is_local_forward = if (std.mem.indexOf(u8, forward_to, "@")) |at_pos|
2172 self.config.isLocalDomain(forward_to[at_pos + 1 ..])
2173 else
2174 true;
2175
2176 if (is_local_forward) {
2177 const fwd_is_isolated = if (self.auth_backend) |ab|
2178 (ab.db.userExists(forward_to) catch false)
2179 else
2180 false;
2181 const fwd_username = if (fwd_is_isolated)
2182 forward_to
2183 else if (std.mem.indexOf(u8, forward_to, "@")) |at_pos|
2184 aliases.resolve(forward_to[0..at_pos])
2185 else
2186 aliases.resolve(forward_to);
2187
2188 const cwd = fs_compat.cwd();
2189 const fwd_dir = try std.fmt.allocPrint(self.allocator, "mail/{s}/new", .{fwd_username});
2190 defer self.allocator.free(fwd_dir);
2191 cwd.makePath(fwd_dir) catch return error.ForwardDirectoryFailed;
2192
2193 var fwd_base_buf: [64]u8 = undefined;
2194 const fwd_basename = maildirBasename(&fwd_base_buf) catch return error.ForwardNameFailed;
2195 const fwd_filename = try std.fmt.allocPrint(self.allocator, "mail/{s}/new/{s}", .{ fwd_username, fwd_basename });
2196 defer self.allocator.free(fwd_filename);
2197
2198 const file = cwd.createFileExclusive(fwd_filename) catch |err| {
2199 self.logger.err("Failed to create forward file {s}: {}", .{ fwd_filename, err });
2200 return error.ForwardCreateFailed;
2201 };
2202 file.writeAll(data) catch |err| {
2203 file.close();
2204 cwd.deleteFile(fwd_filename) catch {};
2205 self.logger.err("Failed to write forward data to {s}: {}", .{ fwd_filename, err });
2206 return error.ForwardWriteFailed;
2207 };
2208 file.close();
2209 self.logger.info("Local forward from {s} to {s} saved to {s}", .{ username, fwd_username, fwd_filename });
2210 return;
2211 }
2212
2213 if (!tryReserveOutboundSlot()) {
2214 self.logger.warn("Outbound worker limit reached; queueing forward to {s}", .{forward_to});
2215 retry.enqueueFailure(sender, forward_to, data);
2216 return;
2217 }
2218 const bg = self.allocator.create(OutboundContext) catch {
2219 releaseOutboundSlot();
2220 retry.enqueueFailure(sender, forward_to, data);
2221 return;
2222 };
2223 bg.* = .{
2224 .allocator = self.allocator,
2225 .from = self.allocator.dupe(u8, sender) catch {
2226 self.allocator.destroy(bg);
2227 releaseOutboundSlot();
2228 retry.enqueueFailure(sender, forward_to, data);
2229 return;
2230 },
2231 .to = self.allocator.dupe(u8, forward_to) catch {
2232 self.allocator.free(bg.from);
2233 self.allocator.destroy(bg);
2234 releaseOutboundSlot();
2235 retry.enqueueFailure(sender, forward_to, data);
2236 return;
2237 },
2238 .data = self.allocator.dupe(u8, data) catch {
2239 self.allocator.free(bg.from);
2240 self.allocator.free(bg.to);
2241 self.allocator.destroy(bg);
2242 releaseOutboundSlot();
2243 retry.enqueueFailure(sender, forward_to, data);
2244 return;
2245 },
2246 .hostname = self.config.hostname,
2247 .delivery_method = self.config.delivery_method,
2248 .ses_region = self.config.ses_region,
2249 };
2250 const thread = std.Thread.spawn(.{}, outboundWorker, .{bg}) catch |err| {
2251 self.logger.err("Failed to spawn forward thread to {s}: {}", .{ forward_to, err });
2252 self.allocator.free(bg.data);
2253 self.allocator.free(bg.to);
2254 self.allocator.free(bg.from);
2255 self.allocator.destroy(bg);
2256 releaseOutboundSlot();
2257 retry.enqueueFailure(sender, forward_to, data);
2258 return;
2259 };
2260 thread.detach();
2261 }
2262
2263 /// Evaluate the standard RFC 5228 forwarding script. `forwards.json`
2264 /// remains a compatibility fallback for installations not yet migrated.
21692265 fn checkAndForward(self: *Session, username: []const u8, sender: []const u8, data: []const u8) !void {
2266 if (fs_compat.readFileAlloc(self.allocator, "forwards.sieve")) |source| {
2267 defer self.allocator.free(source);
2268 var parser = sieve.SieveParser.init(self.allocator);
2269 defer parser.deinit();
2270 var script = parser.parse(source) catch |err| {
2271 self.logger.err("Invalid forwards.sieve: {}", .{err});
2272 return error.InvalidForwardScript;
2273 };
2274 defer script.deinit();
2275 if (!script.is_valid) return error.InvalidForwardScript;
2276
2277 var message = sieve.SieveMessage.init(self.allocator);
2278 defer message.deinit();
2279 message.envelope_from = sender;
2280 message.envelope_to = username;
2281
2282 var evaluator = sieve.SieveEvaluator.init(self.allocator);
2283 const actions = try evaluator.evaluate(&script, &message);
2284 defer self.allocator.free(actions);
2285 for (actions) |action| {
2286 if (action.action_type == .redirect) {
2287 if (action.argument) |target| try self.forwardMessage(username, sender, data, target);
2288 }
2289 }
2290 return;
2291 } else |_| {}
2292
21702293 const contents = fs_compat.readFileAlloc(self.allocator, "forwards.json") catch return;
21712294 defer self.allocator.free(contents);
21722295 if (contents.len == 0) return;
packages/zig/src/protocol/imap.zigmodified+130-6
Changes to packages/zig/src/protocol/imap.zig
@@ -17,6 +17,8 @@ const typesense = ;
1717// std.posix.getpid is not available on this Zig version's std; libc is linked
1818// (the binary links sqlite3 + libc), so declare the C symbol directly.
1919extern "c" fn getpid() c_int;
20extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, bufsiz: usize) isize;
21extern "c" fn symlink(target: [*:0]const u8, linkpath: [*:0]const u8) c_int;
2022
2123/// Upper bound on an APPEND literal we are willing to buffer in memory, to
2224/// prevent a client from exhausting server memory with a huge {N} literal.
@@ -794,6 +796,29 @@ fn renameFile(old_path: []const u8, new_path: []const u8) bool {
794796 return std.c.rename(old_z, new_z) == 0;
795797}
796798
799fn pathIsSymlink(path: []const u8) bool {
800 var path_buf: [4097]u8 = undefined;
801 var target_buf: [1]u8 = undefined;
802 if (path.len >= path_buf.len) return false;
803 @memcpy(path_buf[0..path.len], path);
804 path_buf[path.len] = 0;
805 const path_z: [*:0]const u8 = @ptrCast(&path_buf);
806 return readlink(path_z, &target_buf, target_buf.len) >= 0;
807}
808
809fn createRelativeSymlink(target: []const u8, path: []const u8) bool {
810 var target_buf: [4097]u8 = undefined;
811 var path_buf: [4097]u8 = undefined;
812 if (target.len >= target_buf.len or path.len >= path_buf.len) return false;
813 @memcpy(target_buf[0..target.len], target);
814 target_buf[target.len] = 0;
815 @memcpy(path_buf[0..path.len], path);
816 path_buf[path.len] = 0;
817 const target_z: [*:0]const u8 = @ptrCast(&target_buf);
818 const path_z: [*:0]const u8 = @ptrCast(&path_buf);
819 return symlink(target_z, path_z) == 0;
820}
821
797822/// IMAP session
798823pub const ImapSession = struct {
799824 allocator: std.mem.Allocator,
@@ -3361,6 +3386,51 @@ pub const ImapSession = struct {
33613386 _ = std.c.unlink(@ptrCast(&path_buf));
33623387 }
33633388
3389 if (pathIsSymlink(dir_path)) {
3390 var visible_buf: [4097]u8 = undefined;
3391 const visible = std.fmt.bufPrint(&visible_buf, "{s}", .{dir_path}) catch {
3392 try self.sendResponse(tag, "NO", "DELETE failed");
3393 return;
3394 };
3395 visible_buf[visible.len] = 0;
3396 _ = std.c.unlink(@ptrCast(&visible_buf));
3397
3398 const canonical = try std.fmt.allocPrint(self.allocator, "mail/{s}/.{s}", .{ local_part, name });
3399 defer self.allocator.free(canonical);
3400 for ([_][]const u8{ "tmp", "new", "cur" }) |component| {
3401 const child = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ canonical, component });
3402 defer self.allocator.free(child);
3403 const child_files = fs_compat.listEmlFiles(self.allocator, child) catch &.{};
3404 defer {
3405 for (child_files) |file_name| self.allocator.free(file_name);
3406 if (child_files.len > 0) self.allocator.free(child_files);
3407 }
3408 for (child_files) |file_name| {
3409 var child_file_buf: [4097]u8 = undefined;
3410 const child_file = std.fmt.bufPrint(&child_file_buf, "{s}/{s}", .{ child, file_name }) catch continue;
3411 child_file_buf[child_file.len] = 0;
3412 _ = std.c.unlink(@ptrCast(&child_file_buf));
3413 }
3414 var child_buf: [4097]u8 = undefined;
3415 const child_path = std.fmt.bufPrint(&child_buf, "{s}", .{child}) catch continue;
3416 child_buf[child_path.len] = 0;
3417 _ = std.c.rmdir(@ptrCast(&child_buf));
3418 }
3419 var canonical_buf: [4097]u8 = undefined;
3420 const canonical_path = std.fmt.bufPrint(&canonical_buf, "{s}", .{canonical}) catch {
3421 try self.sendResponse(tag, "NO", "DELETE failed");
3422 return;
3423 };
3424 canonical_buf[canonical_path.len] = 0;
3425 if (std.c.rmdir(@ptrCast(&canonical_buf)) != 0) {
3426 try self.sendResponse(tag, "NO", "DELETE failed");
3427 return;
3428 }
3429 std.log.info("IMAP DELETE: Removed Maildir++ mailbox {s}", .{name});
3430 try self.sendResponse(tag, "OK", "DELETE completed");
3431 return;
3432 }
3433
33643434 var dir_buf: [4097]u8 = undefined;
33653435 const dir_written = std.fmt.bufPrint(&dir_buf, "{s}", .{dir_path}) catch {
33663436 try self.sendResponse(tag, "OK", "DELETE completed");
@@ -3404,6 +3474,39 @@ pub const ImapSession = struct {
34043474 const new_path = try std.fmt.allocPrint(self.allocator, "mail/{s}/{s}", .{ local_part, new });
34053475 defer self.allocator.free(new_path);
34063476
3477 if (pathIsSymlink(old_path)) {
3478 const old_canonical = try std.fmt.allocPrint(self.allocator, "mail/{s}/.{s}", .{ local_part, old });
3479 defer self.allocator.free(old_canonical);
3480 const new_canonical = try std.fmt.allocPrint(self.allocator, "mail/{s}/.{s}", .{ local_part, new });
3481 defer self.allocator.free(new_canonical);
3482 const new_target = try std.fmt.allocPrint(self.allocator, ".{s}/cur", .{new});
3483 defer self.allocator.free(new_target);
3484 const old_target = try std.fmt.allocPrint(self.allocator, ".{s}/cur", .{old});
3485 defer self.allocator.free(old_target);
3486
3487 if (!renameFile(old_canonical, new_canonical)) {
3488 try self.sendResponse(tag, "NO", "RENAME failed");
3489 return;
3490 }
3491 var old_buf: [4097]u8 = undefined;
3492 const old_visible = std.fmt.bufPrint(&old_buf, "{s}", .{old_path}) catch {
3493 _ = renameFile(new_canonical, old_canonical);
3494 try self.sendResponse(tag, "NO", "RENAME failed");
3495 return;
3496 };
3497 old_buf[old_visible.len] = 0;
3498 _ = std.c.unlink(@ptrCast(&old_buf));
3499 if (!createRelativeSymlink(new_target, new_path)) {
3500 _ = renameFile(new_canonical, old_canonical);
3501 _ = createRelativeSymlink(old_target, old_path);
3502 try self.sendResponse(tag, "NO", "RENAME failed");
3503 return;
3504 }
3505 std.log.info("IMAP RENAME: {s} -> {s}", .{ old, new });
3506 try self.sendResponse(tag, "OK", "RENAME completed");
3507 return;
3508 }
3509
34073510 if (renameFile(old_path, new_path)) {
34083511 std.log.info("IMAP RENAME: {s} -> {s}", .{ old, new });
34093512 try self.sendResponse(tag, "OK", "RENAME completed");
@@ -3859,21 +3962,42 @@ pub const ImapSession = struct {
38593962 try self.sendResponse(tag, "OK", "ID completed");
38603963 },
38613964 .create => {
3862 // CREATE mailbox - create the Maildir folder
3965 // CREATE mailbox in canonical Maildir++ form. The visible
3966 // folder symlink preserves compatibility with older server
3967 // paths while external Maildir tools use .Folder/{tmp,new,cur}.
38633968 const create_name = stripQuotes(parts.next() orelse "");
38643969 if (!isSafeMailboxName(create_name)) {
38653970 // Rejects empty names and any path-traversal attempt.
38663971 try self.sendResponse(tag, "NO", "Invalid mailbox name");
38673972 } else {
3973 var created = false;
38683974 if (self.username) |uname| {
38693975 const local = uname;
3870 const dir = std.fmt.allocPrint(self.allocator, "mail/{s}/{s}", .{ local, create_name }) catch null;
3871 if (dir) |d| {
3872 fs_compat.cwd().makePath(d) catch {};
3873 self.allocator.free(d);
3976 const visible = std.fmt.allocPrint(self.allocator, "mail/{s}/{s}", .{ local, create_name }) catch null;
3977 const canonical = std.fmt.allocPrint(self.allocator, "mail/{s}/.{s}", .{ local, create_name }) catch null;
3978 const target = std.fmt.allocPrint(self.allocator, ".{s}/cur", .{create_name}) catch null;
3979 if (visible != null and canonical != null and target != null) {
3980 const tmp = std.fmt.allocPrint(self.allocator, "{s}/tmp", .{canonical.?}) catch null;
3981 const new = std.fmt.allocPrint(self.allocator, "{s}/new", .{canonical.?}) catch null;
3982 const cur = std.fmt.allocPrint(self.allocator, "{s}/cur", .{canonical.?}) catch null;
3983 if (tmp != null and new != null and cur != null) {
3984 fs_compat.cwd().makePath(tmp.?) catch {};
3985 fs_compat.cwd().makePath(new.?) catch {};
3986 fs_compat.cwd().makePath(cur.?) catch {};
3987 created = createRelativeSymlink(target.?, visible.?);
3988 }
3989 if (tmp) |value| self.allocator.free(value);
3990 if (new) |value| self.allocator.free(value);
3991 if (cur) |value| self.allocator.free(value);
38743992 }
3993 if (visible) |value| self.allocator.free(value);
3994 if (canonical) |value| self.allocator.free(value);
3995 if (target) |value| self.allocator.free(value);
38753996 }
3876 try self.sendResponse(tag, "OK", "CREATE completed");
3997 if (created)
3998 try self.sendResponse(tag, "OK", "CREATE completed")
3999 else
4000 try self.sendResponse(tag, "NO", "Mailbox already exists or could not be created");
38774001 }
38784002 },
38794003 .subscribe => {
scripts/mail-forward-compileadded+83-0
Changes to scripts/mail-forward-compile
@@ -0,0 +1,83 @@
1#!/usr/bin/env python3
2"""Compile the forwarding manifest into a deterministic RFC 5228 Sieve script."""
3
4import argparse
5import json
6import os
7import re
8import tempfile
9
10MAILBOX = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+(?:@[A-Za-z0-9.-]+)?$")
11ADDRESS = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$")
12
13
14def sieve_string(value: str) -> str:
15 if "\r" in value or "\n" in value or "\x00" in value:
16 raise ValueError("Sieve strings cannot contain control characters")
17 return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
18
19
20def compile_manifest(manifest: dict[str, object]) -> str:
21 lines = [
22 "# Generated from config/email.ts via forwards.json; do not edit by hand.",
23 'require ["envelope"];',
24 "",
25 ]
26 for mailbox in sorted(key for key in manifest if key != "_readme"):
27 targets = manifest[mailbox]
28 if not MAILBOX.fullmatch(mailbox):
29 raise ValueError(f"invalid forwarding mailbox: {mailbox}")
30 if not isinstance(targets, list) or not targets:
31 raise ValueError(f"forwarding mailbox {mailbox} must have at least one target")
32
33 unique: list[str] = []
34 for target in targets:
35 if not isinstance(target, str) or not ADDRESS.fullmatch(target):
36 raise ValueError(f"invalid forwarding target for {mailbox}: {target!r}")
37 if target not in unique:
38 unique.append(target)
39
40 lines.append(f"if envelope :is \"to\" {sieve_string(mailbox)} {{")
41 for target in unique:
42 lines.append(f" redirect {sieve_string(target)};")
43 lines.extend([" keep;", "}", ""])
44 return "\n".join(lines)
45
46
47def main() -> None:
48 parser = argparse.ArgumentParser()
49 parser.add_argument("manifest", nargs="?", default="/opt/mail/forwards.json")
50 parser.add_argument("output", nargs="?", default="/opt/mail/forwards.sieve")
51 args = parser.parse_args()
52
53 with open(args.manifest, encoding="utf-8") as source:
54 manifest = json.load(source)
55 if not isinstance(manifest, dict):
56 raise ValueError("forwarding manifest must be a JSON object")
57
58 compiled = compile_manifest(manifest)
59 output_path = os.path.abspath(args.output)
60 if os.path.islink(output_path):
61 output_path = os.path.realpath(output_path)
62 output_dir = os.path.dirname(output_path)
63 os.makedirs(output_dir, mode=0o700, exist_ok=True)
64 fd, temporary = tempfile.mkstemp(prefix=".forwards.sieve.", dir=output_dir, text=True)
65 try:
66 with os.fdopen(fd, "w", encoding="utf-8") as target:
67 target.write(compiled)
68 target.flush()
69 os.fsync(target.fileno())
70 os.chmod(temporary, 0o644)
71 try:
72 source_stat = os.stat(args.manifest)
73 os.chown(temporary, source_stat.st_uid, source_stat.st_gid)
74 except PermissionError:
75 pass
76 os.replace(temporary, output_path)
77 finally:
78 if os.path.exists(temporary):
79 os.unlink(temporary)
80
81
82if __name__ == "__main__":
83 main()
scripts/maildir-migrate.shadded+113-0
Changes to scripts/maildir-migrate.sh
@@ -0,0 +1,113 @@
1#!/usr/bin/env bash
2set -euo pipefail
3
4# Convert the server's historical layout into Maildir++ without changing the
5# paths used by existing server code. A folder is stored canonically at
6# `.Folder/{tmp,new,cur}` and the historical `Folder` path is a relative
7# symlink to `.Folder/cur`.
8
9COMMAND="${1:-status}"
10MAIL_ROOT="${MAIL_ROOT:-/opt/mail/mail}"
11
12die() {
13 echo "maildir-migrate: $*" >&2
14 exit 1
15}
16
17[[ "${EUID}" -eq 0 ]] || die "must run as root"
18[[ "${MAIL_ROOT}" == /* && "${MAIL_ROOT}" != "/" ]] || die "MAIL_ROOT must be an absolute non-root path"
19[[ -d "${MAIL_ROOT}" ]] || die "${MAIL_ROOT} does not exist"
20
21mail_was_active=false
22if [[ "${COMMAND}" == "migrate" ]] && systemctl is-active --quiet mail.service; then
23 mail_was_active=true
24 systemctl stop mail.service
25fi
26
27cleanup() {
28 if [[ "${mail_was_active}" == "true" ]]; then
29 systemctl start mail.service
30 fi
31}
32trap cleanup EXIT
33
34folder_count=0
35mailbox_count=0
36
37inspect_mailbox() {
38 local mailbox="$1"
39 local entry name canonical target
40
41 for required in tmp new cur; do
42 [[ -d "${mailbox}/${required}" ]] || {
43 [[ "${COMMAND}" == "migrate" ]] && install -d -m 0700 "${mailbox}/${required}"
44 }
45 done
46
47 while IFS= read -r -d '' entry; do
48 name="${entry##*/}"
49 [[ "${name}" != "tmp" && "${name}" != "new" && "${name}" != "cur" ]] || continue
50 [[ "${name}" != .* ]] || continue
51
52 canonical="${mailbox}/.${name}"
53 if [[ -L "${entry}" ]]; then
54 target="$(readlink "${entry}")"
55 [[ "${target}" == ".${name}/cur" ]] \
56 || die "${entry} points to unexpected target ${target}"
57 [[ -d "${canonical}/tmp" && -d "${canonical}/new" && -d "${canonical}/cur" ]] \
58 || die "${canonical} is incomplete"
59 if [[ "${COMMAND}" == "migrate" ]]; then
60 chown -R --reference="${mailbox}" "${canonical}"
61 chmod 0700 "${canonical}" "${canonical}/tmp" "${canonical}/new" "${canonical}/cur"
62 chown -h --reference="${mailbox}" "${entry}"
63 fi
64 folder_count=$((folder_count + 1))
65 continue
66 fi
67 [[ -d "${entry}" ]] || continue
68
69 if [[ "${COMMAND}" == "migrate" ]]; then
70 install -d -m 0700 "${canonical}/tmp" "${canonical}/new" "${canonical}/cur"
71 while IFS= read -r -d '' message; do
72 base="${message##*/}"
73 [[ ! -e "${canonical}/cur/${base}" ]] \
74 || die "collision while moving ${message}"
75 mv "${message}" "${canonical}/cur/${base}"
76 done < <(find "${entry}" -mindepth 1 -maxdepth 1 -type f -print0)
77
78 # Some historical folders already used Folder/{tmp,new,cur}. Preserve
79 # the delivery state of every message while moving those three standard
80 # components below the Maildir++ dot-folder.
81 for component in tmp new cur; do
82 [[ -d "${entry}/${component}" ]] || continue
83 while IFS= read -r -d '' message; do
84 base="${message##*/}"
85 [[ ! -e "${canonical}/${component}/${base}" ]] \
86 || die "collision while moving ${message}"
87 mv "${message}" "${canonical}/${component}/${base}"
88 done < <(find "${entry}/${component}" -mindepth 1 -maxdepth 1 -type f -print0)
89 rmdir "${entry}/${component}" \
90 || die "${entry}/${component} contains unsupported nested entries"
91 done
92 rmdir "${entry}" || die "${entry} contains unsupported nested entries"
93 ln -s ".${name}/cur" "${entry}"
94 chown -R --reference="${mailbox}" "${canonical}"
95 chmod 0700 "${canonical}" "${canonical}/tmp" "${canonical}/new" "${canonical}/cur"
96 chown -h --reference="${mailbox}" "${entry}"
97 fi
98 folder_count=$((folder_count + 1))
99 done < <(find "${mailbox}" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0)
100}
101
102while IFS= read -r -d '' mailbox; do
103 inspect_mailbox "${mailbox}"
104 mailbox_count=$((mailbox_count + 1))
105done < <(find -L "${MAIL_ROOT}" -mindepth 1 -maxdepth 1 -type d -print0)
106
107if [[ "${COMMAND}" == "migrate" ]]; then
108 echo "maildir-migrate: migrated ${mailbox_count} mailboxes and ${folder_count} folders"
109elif [[ "${COMMAND}" == "status" ]]; then
110 echo "maildir-migrate: ${mailbox_count} mailboxes, ${folder_count} compatible folders"
111else
112 die "usage: $0 {status|migrate}"
113fi