also looking at this
Harden encrypted mail storage, recovery, and forwarding
#8
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.
| @@ -0,0 +1,45 @@ | ||
| 1 | # Forwarding | |
| 2 | ||
| 3 | Forwarding is declared in each Stacks application's `config/email.ts`: | |
| 4 | ||
| 5 | ```ts | |
| 6 | forwards: { | |
| 7 | 'socials@stacksjs.com': ['chris@stacksjs.com'], | |
| 8 | 'hi@stacksjs.com': ['chris@stacksjs.com'], | |
| 9 | } | |
| 10 | ``` | |
| 11 | ||
| 12 | Buddy merges those declarations into the shared `/opt/mail/forwards.json` | |
| 13 | manifest and atomically compiles `/opt/mail/forwards.sieve`. The runtime script | |
| 14 | uses the standard RFC 5228 Sieve `envelope`, `redirect`, and `keep` commands. | |
| 15 | The JSON file is retained as a generated compatibility manifest for older | |
| 16 | server binaries; it is not the configuration source. | |
| 17 | ||
| 18 | Example generated rule: | |
| 19 | ||
| 20 | ```sieve | |
| 21 | require ["envelope"]; | |
| 22 | ||
| 23 | if envelope :is "to" "socials@stacksjs.com" { | |
| 24 | redirect "chris@stacksjs.com"; | |
| 25 | keep; | |
| 26 | } | |
| 27 | ``` | |
| 28 | ||
| 29 | Rules are loaded for each accepted non-junk message. `keep` leaves the original | |
| 30 | copy in the role mailbox, while `redirect` creates the forwarded copy. Junk is | |
| 31 | not forwarded, which prevents the server from relaying spam. | |
| 32 | ||
| 33 | Reconcile from configuration and validate the live script: | |
| 34 | ||
| 35 | ```bash | |
| 36 | buddy mail:provision --env production | |
| 37 | buddy mail:forward:compile | |
| 38 | ``` | |
| 39 | ||
| 40 | The compiler rejects invalid mailbox names, invalid destination addresses, | |
| 41 | empty target lists, and control characters. It sorts rules and removes | |
| 42 | duplicate targets for deterministic output. | |
| 43 | ||
| 44 | Implementation follow-up is tracked in | |
| 45 | [mail-os/mail#6](https://github.com/mail-os/mail/issues/6). | |
| @@ -0,0 +1,45 @@ | ||
| 1 | # Maildir++ storage | |
| 2 | ||
| 3 | Production mailboxes use Maildir++: | |
| 4 | ||
| 5 | ```text | |
| 6 | mail/<mailbox>/ | |
| 7 | ├── tmp/ | |
| 8 | ├── new/ | |
| 9 | ├── cur/ | |
| 10 | ├── .Junk/ | |
| 11 | │ ├── tmp/ | |
| 12 | │ ├── new/ | |
| 13 | │ └── cur/ | |
| 14 | └── Junk -> .Junk/cur | |
| 15 | ``` | |
| 16 | ||
| 17 | The dot-prefixed directory is the standard Maildir++ representation. The | |
| 18 | visible relative symlink is a compatibility bridge for older server paths and | |
| 19 | is included in IMAP LIST responses. New IMAP folders are created in this form. | |
| 20 | DELETE and RENAME update the canonical directory and compatibility link | |
| 21 | together. | |
| 22 | ||
| 23 | Migrate an existing server with the reusable Buddy operation: | |
| 24 | ||
| 25 | ```bash | |
| 26 | buddy mail:storage:maildir:migrate | |
| 27 | ``` | |
| 28 | ||
| 29 | The migration stops `mail.service`, creates missing `tmp/new/cur` directories, | |
| 30 | moves existing flat-folder messages to `.Folder/cur`, installs relative | |
| 31 | compatibility symlinks, and restarts the service. It is idempotent and refuses | |
| 32 | unexpected symlink targets, filename collisions, and nested legacy folders. | |
| 33 | ||
| 34 | Direct server invocation: | |
| 35 | ||
| 36 | ```bash | |
| 37 | /usr/local/sbin/maildir-migrate status | |
| 38 | /usr/local/sbin/maildir-migrate migrate | |
| 39 | ``` | |
| 40 | ||
| 41 | Backups archive one copy of each folder rather than dereferencing both the | |
| 42 | canonical directory and its compatibility link. | |
| 43 | ||
| 44 | Implementation follow-up is tracked in | |
| 45 | [mail-os/mail#5](https://github.com/mail-os/mail/issues/5). | |
| @@ -22,6 +22,7 @@ const outbound = ; | ||
| 22 | 22 | const retry = ; |
| 23 | 23 | const aliases = ; |
| 24 | 24 | const typesense = ; |
| 25 | const sieve = ; | |
| 25 | 26 | |
| 26 | 27 | /// Maximum number of in-flight detached outbound/forward delivery worker |
| 27 | 28 | /// 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 | ||
| 175 | 176 | }); |
| 176 | 177 | const rc1 = std.c.getaddrinfo(ip_z.ptr, null, &hints, &res); |
| 177 | 178 | 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 | |
| 179 | 180 | const node = res orelse return true; |
| 180 | 181 | const sa = node.addr orelse return true; |
| 181 | 182 | |
| 182 | 183 | var host_buf: [256]u8 = undefined; |
| 183 | 184 | 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 | |
| 185 | 186 | const host = std.mem.sliceTo(&host_buf, 0); |
| 186 | 187 | return !std.mem.eql(u8, host, ip); // numeric echo means no PTR |
| 187 | 188 | } |
| @@ -679,7 +680,7 @@ pub const Session = struct { | ||
| 679 | 680 | self.conn_wrapper.deinitTls(); |
| 680 | 681 | // Clean up TLS reader/writer - use stored alignment |
| 681 | 682 | 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)); | |
| 683 | 684 | const ptr_bytes: [*]u8 = @ptrCast(info.ptr); |
| 684 | 685 | switch (info.alignment) { |
| 685 | 686 | 1 => self.allocator.rawFree(ptr_bytes[0..info.size], log2_align, @returnAddress()), |
| @@ -691,7 +692,7 @@ pub const Session = struct { | ||
| 691 | 692 | } |
| 692 | 693 | } |
| 693 | 694 | 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)); | |
| 695 | 696 | const ptr_bytes: [*]u8 = @ptrCast(info.ptr); |
| 696 | 697 | switch (info.alignment) { |
| 697 | 698 | 1 => self.allocator.rawFree(ptr_bytes[0..info.size], log2_align, @returnAddress()), |
| @@ -2164,9 +2165,131 @@ pub const Session = struct { | ||
| 2164 | 2165 | }; |
| 2165 | 2166 | } |
| 2166 | 2167 | |
| 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. | |
| 2169 | 2265 | 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 | ||
| 2170 | 2293 | const contents = fs_compat.readFileAlloc(self.allocator, "forwards.json") catch return; |
| 2171 | 2294 | defer self.allocator.free(contents); |
| 2172 | 2295 | if (contents.len == 0) return; |
| @@ -17,6 +17,8 @@ const typesense = ; | ||
| 17 | 17 | // std.posix.getpid is not available on this Zig version's std; libc is linked |
| 18 | 18 | // (the binary links sqlite3 + libc), so declare the C symbol directly. |
| 19 | 19 | extern "c" fn getpid() c_int; |
| 20 | extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, bufsiz: usize) isize; | |
| 21 | extern "c" fn symlink(target: [*:0]const u8, linkpath: [*:0]const u8) c_int; | |
| 20 | 22 | |
| 21 | 23 | /// Upper bound on an APPEND literal we are willing to buffer in memory, to |
| 22 | 24 | /// 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 { | ||
| 794 | 796 | return std.c.rename(old_z, new_z) == 0; |
| 795 | 797 | } |
| 796 | 798 | |
| 799 | fn 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 | ||
| 809 | fn 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 | ||
| 797 | 822 | /// IMAP session |
| 798 | 823 | pub const ImapSession = struct { |
| 799 | 824 | allocator: std.mem.Allocator, |
| @@ -3361,6 +3386,51 @@ pub const ImapSession = struct { | ||
| 3361 | 3386 | _ = std.c.unlink(@ptrCast(&path_buf)); |
| 3362 | 3387 | } |
| 3363 | 3388 | |
| 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 | ||
| 3364 | 3434 | var dir_buf: [4097]u8 = undefined; |
| 3365 | 3435 | const dir_written = std.fmt.bufPrint(&dir_buf, "{s}", .{dir_path}) catch { |
| 3366 | 3436 | try self.sendResponse(tag, "OK", "DELETE completed"); |
| @@ -3404,6 +3474,39 @@ pub const ImapSession = struct { | ||
| 3404 | 3474 | const new_path = try std.fmt.allocPrint(self.allocator, "mail/{s}/{s}", .{ local_part, new }); |
| 3405 | 3475 | defer self.allocator.free(new_path); |
| 3406 | 3476 | |
| 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 | ||
| 3407 | 3510 | if (renameFile(old_path, new_path)) { |
| 3408 | 3511 | std.log.info("IMAP RENAME: {s} -> {s}", .{ old, new }); |
| 3409 | 3512 | try self.sendResponse(tag, "OK", "RENAME completed"); |
| @@ -3859,21 +3962,42 @@ pub const ImapSession = struct { | ||
| 3859 | 3962 | try self.sendResponse(tag, "OK", "ID completed"); |
| 3860 | 3963 | }, |
| 3861 | 3964 | .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}. | |
| 3863 | 3968 | const create_name = stripQuotes(parts.next() orelse ""); |
| 3864 | 3969 | if (!isSafeMailboxName(create_name)) { |
| 3865 | 3970 | // Rejects empty names and any path-traversal attempt. |
| 3866 | 3971 | try self.sendResponse(tag, "NO", "Invalid mailbox name"); |
| 3867 | 3972 | } else { |
| 3973 | var created = false; | |
| 3868 | 3974 | if (self.username) |uname| { |
| 3869 | 3975 | 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); | |
| 3874 | 3992 | } |
| 3993 | if (visible) |value| self.allocator.free(value); | |
| 3994 | if (canonical) |value| self.allocator.free(value); | |
| 3995 | if (target) |value| self.allocator.free(value); | |
| 3875 | 3996 | } |
| 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"); | |
| 3877 | 4001 | } |
| 3878 | 4002 | }, |
| 3879 | 4003 | .subscribe => { |
| @@ -0,0 +1,83 @@ | ||
| 1 | #!/usr/bin/env python3 | |
| 2 | """Compile the forwarding manifest into a deterministic RFC 5228 Sieve script.""" | |
| 3 | ||
| 4 | import argparse | |
| 5 | import json | |
| 6 | import os | |
| 7 | import re | |
| 8 | import tempfile | |
| 9 | ||
| 10 | MAILBOX = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+(?:@[A-Za-z0-9.-]+)?$") | |
| 11 | ADDRESS = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$") | |
| 12 | ||
| 13 | ||
| 14 | def 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 | ||
| 20 | def 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 | ||
| 47 | def 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 | ||
| 82 | if __name__ == "__main__": | |
| 83 | main() | |
| @@ -0,0 +1,113 @@ | ||
| 1 | #!/usr/bin/env bash | |
| 2 | set -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 | ||
| 9 | COMMAND="${1:-status}" | |
| 10 | MAIL_ROOT="${MAIL_ROOT:-/opt/mail/mail}" | |
| 11 | ||
| 12 | die() { | |
| 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 | ||
| 21 | mail_was_active=false | |
| 22 | if [[ "${COMMAND}" == "migrate" ]] && systemctl is-active --quiet mail.service; then | |
| 23 | mail_was_active=true | |
| 24 | systemctl stop mail.service | |
| 25 | fi | |
| 26 | ||
| 27 | cleanup() { | |
| 28 | if [[ "${mail_was_active}" == "true" ]]; then | |
| 29 | systemctl start mail.service | |
| 30 | fi | |
| 31 | } | |
| 32 | trap cleanup EXIT | |
| 33 | ||
| 34 | folder_count=0 | |
| 35 | mailbox_count=0 | |
| 36 | ||
| 37 | inspect_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 | ||
| 102 | while IFS= read -r -d '' mailbox; do | |
| 103 | inspect_mailbox "${mailbox}" | |
| 104 | mailbox_count=$((mailbox_count + 1)) | |
| 105 | done < <(find -L "${MAIL_ROOT}" -mindepth 1 -maxdepth 1 -type d -print0) | |
| 106 | ||
| 107 | if [[ "${COMMAND}" == "migrate" ]]; then | |
| 108 | echo "maildir-migrate: migrated ${mailbox_count} mailboxes and ${folder_count} folders" | |
| 109 | elif [[ "${COMMAND}" == "status" ]]; then | |
| 110 | echo "maildir-migrate: ${mailbox_count} mailboxes, ${folder_count} compatible folders" | |
| 111 | else | |
| 112 | die "usage: $0 {status|migrate}" | |
| 113 | fi | |