ReviewOS

also looking at this

mail-os/mail

feat(config): add hosted_domains for multi-domain hosting

#1
Open glennmichael123 wants to merge feat/hosted-domains into main
7 files +153 -42

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.

packages/zig/src/config_test.zigmodified+70-0
Changes to packages/zig/src/config_test.zig
@@ -268,3 +268,73 @@ test "authentication configuration flags" {
268268
269269 try testing.expectEqual(false, cfg_auth_off.enable_auth);
270270}
271
272fn baseCfg(hostname: []const u8, hosted: []const []const u8) config.Config {
273 return .{
274 .host = "0.0.0.0",
275 .port = 2525,
276 .max_connections = 100,
277 .enable_tls = false,
278 .tls_cert_path = null,
279 .tls_key_path = null,
280 .enable_auth = true,
281 .max_message_size = 10 * 1024 * 1024,
282 .timeout_seconds = 300,
283 .data_timeout_seconds = 600,
284 .command_timeout_seconds = 300,
285 .greeting_timeout_seconds = 30,
286 .rate_limit_per_ip = 100,
287 .rate_limit_per_user = 200,
288 .rate_limit_cleanup_interval = 3600,
289 .max_recipients = 100,
290 .hostname = hostname,
291 .hosted_domains = hosted,
292 .webhook_url = null,
293 .webhook_enabled = false,
294 .enable_dnsbl = false,
295 .enable_greylist = false,
296 .enable_tracing = false,
297 .tracing_service_name = "mail",
298 .enable_json_logging = false,
299 };
300}
301
302test "isLocalDomain matches hostname exactly" {
303 const cfg = baseCfg("mail.stacksjs.com", &.{});
304 try testing.expect(cfg.isLocalDomain("mail.stacksjs.com"));
305}
306
307test "isLocalDomain matches parent of hostname" {
308 const cfg = baseCfg("mail.stacksjs.com", &.{});
309 try testing.expect(cfg.isLocalDomain("stacksjs.com"));
310}
311
312test "isLocalDomain matches an entry in hosted_domains" {
313 const extras = [_][]const u8{ "paweldregan.com", "example.org" };
314 const cfg = baseCfg("mail.stacksjs.com", &extras);
315 try testing.expect(cfg.isLocalDomain("paweldregan.com"));
316 try testing.expect(cfg.isLocalDomain("example.org"));
317}
318
319test "isLocalDomain is case-insensitive" {
320 const extras = [_][]const u8{"paweldregan.com"};
321 const cfg = baseCfg("mail.stacksjs.com", &extras);
322 try testing.expect(cfg.isLocalDomain("MAIL.Stacksjs.COM"));
323 try testing.expect(cfg.isLocalDomain("StacksJS.COM"));
324 try testing.expect(cfg.isLocalDomain("PaWeLdReGaN.com"));
325}
326
327test "isLocalDomain rejects unrelated domains" {
328 const extras = [_][]const u8{"paweldregan.com"};
329 const cfg = baseCfg("mail.stacksjs.com", &extras);
330 try testing.expect(!cfg.isLocalDomain("evil.example"));
331 try testing.expect(!cfg.isLocalDomain("notstacksjs.com"));
332 try testing.expect(!cfg.isLocalDomain(""));
333}
334
335test "isLocalDomain has no parent for single-label hostname" {
336 // "localhost" has no '.' so there's no parent to match
337 const cfg = baseCfg("localhost", &.{});
338 try testing.expect(cfg.isLocalDomain("localhost"));
339 try testing.expect(!cfg.isLocalDomain("host"));
340}
packages/zig/src/core/config.zigmodified+59-0
Changes to packages/zig/src/core/config.zig
@@ -47,6 +47,10 @@ pub const Config = struct {
4747 rate_limit_cleanup_interval: u64,
4848 max_recipients: usize,
4949 hostname: []const u8,
50 /// Additional domains this server accepts mail for, beyond `hostname`
51 /// and its parent. Populated from TOML `server.hosted_domains` (comma-
52 /// separated string) or env `SMTP_HOSTED_DOMAINS`.
53 hosted_domains: []const []const u8 = &.{},
5054 webhook_url: ?[]const u8,
5155 webhook_enabled: bool,
5256 enable_dnsbl: bool,
@@ -86,6 +90,22 @@ pub const Config = struct {
8690 if (self.webhook_url) |url| allocator.free(url);
8791 if (self.acme_email) |email| allocator.free(email);
8892 if (self.list_unsubscribe_url) |url| allocator.free(url);
93 for (self.hosted_domains) |domain| allocator.free(domain);
94 if (self.hosted_domains.len > 0) allocator.free(self.hosted_domains);
95 }
96
97 /// Returns true if `domain` is one this server accepts mail for:
98 /// `hostname`, the parent of `hostname` (e.g. `mail.X` accepts `X`),
99 /// or any entry in `hosted_domains`. Match is case-insensitive.
100 pub fn isLocalDomain(self: Config, domain: []const u8) bool {
101 if (std.ascii.eqlIgnoreCase(domain, self.hostname)) return true;
102 if (std.mem.indexOfScalar(u8, self.hostname, '.')) |dot_pos| {
103 if (std.ascii.eqlIgnoreCase(domain, self.hostname[dot_pos + 1 ..])) return true;
104 }
105 for (self.hosted_domains) |hosted| {
106 if (std.ascii.eqlIgnoreCase(domain, hosted)) return true;
107 }
108 return false;
89109 }
90110
91111 /// Validates the configuration and returns detailed error messages
@@ -265,6 +285,31 @@ pub fn loadConfig(allocator: std.mem.Allocator, cli_args: args.Args) !Config {
265285 return cfg;
266286}
267287
288/// Parse a comma-separated list of domain names into an owned slice of
289/// allocator-owned strings. Empty entries and surrounding whitespace are
290/// trimmed; an empty input yields a zero-length slice.
291fn parseDomainList(allocator: std.mem.Allocator, raw: []const u8) ![]const []const u8 {
292 var list = std.ArrayList([]const u8).empty;
293 errdefer {
294 for (list.items) |item| allocator.free(item);
295 list.deinit(allocator);
296 }
297 var it = std.mem.splitScalar(u8, raw, ',');
298 while (it.next()) |part| {
299 const trimmed = std.mem.trim(u8, part, " \t\r\n");
300 if (trimmed.len == 0) continue;
301 const dup = try allocator.dupe(u8, trimmed);
302 errdefer allocator.free(dup);
303 try list.append(allocator, dup);
304 }
305 return list.toOwnedSlice(allocator);
306}
307
308fn freeDomainList(allocator: std.mem.Allocator, list: []const []const u8) void {
309 for (list) |entry| allocator.free(entry);
310 if (list.len > 0) allocator.free(list);
311}
312
268313/// Determine which configuration profile to use
269314fn determineProfile() config_profiles.Profile {
270315 if (env.get("SMTP_PROFILE")) |profile_str| {
@@ -304,6 +349,7 @@ fn loadDefaultsFromProfile(allocator: std.mem.Allocator, profile: config_profile
304349 .rate_limit_cleanup_interval = @as(u64, profile_config.rate_limit_window_seconds) * 60, // Convert to seconds
305350 .max_recipients = @intCast(profile_config.max_recipients),
306351 .hostname = try allocator.dupe(u8, "localhost"),
352 .hosted_domains = try allocator.alloc([]const u8, 0),
307353 .webhook_url = null,
308354 .webhook_enabled = false, // Only enable when URL is provided via env var
309355 .enable_dnsbl = false, // Not in profile config yet
@@ -348,6 +394,13 @@ fn applyEnvironmentVariables(allocator: std.mem.Allocator, cfg: *Config) !void {
348394 cfg.hostname = try allocator.dupe(u8, value);
349395 }
350396
397 // SMTP_HOSTED_DOMAINS — comma-separated list of additional hosted domains
398 if (env.get("SMTP_HOSTED_DOMAINS")) |value| {
399 const parsed = try parseDomainList(allocator, value);
400 freeDomainList(allocator, cfg.hosted_domains);
401 cfg.hosted_domains = parsed;
402 }
403
351404 // SMTP_MAX_CONNECTIONS
352405 if (env.get("SMTP_MAX_CONNECTIONS")) |value| {
353406 cfg.max_connections = std.fmt.parseInt(usize, value, 10) catch cfg.max_connections;
@@ -569,6 +622,12 @@ fn applyConfigFile(allocator: std.mem.Allocator, cfg: *Config, path: []const u8)
569622 allocator.free(cfg.hostname);
570623 cfg.hostname = try allocator.dupe(u8, value);
571624 }
625 // Comma-separated string until the TOML parser supports arrays.
626 if (server.getString("hosted_domains")) |value| {
627 const parsed = try parseDomainList(allocator, value);
628 freeDomainList(allocator, cfg.hosted_domains);
629 cfg.hosted_domains = parsed;
630 }
572631 if (server.getInt("max_connections")) |value| {
573632 cfg.max_connections = @intCast(value);
574633 }
packages/zig/src/core/protocol.zigmodified+14-32
Changes to packages/zig/src/core/protocol.zig
@@ -672,19 +672,12 @@ pub const Session = struct {
672672 return;
673673 }
674674
675 // Prevent open relay: unauthenticated clients can only send to local domain
675 // Prevent open relay: unauthenticated clients can only send to a local domain
676 // (hostname, its parent, or any entry in config.hosted_domains).
676677 if (!self.authenticated) {
677678 if (std.mem.indexOf(u8, addr, "@")) |at_pos| {
678679 const recipient_domain = addr[at_pos + 1 ..];
679 // Check if recipient domain matches hostname or is the parent domain
680 // e.g. hostname "mail.stacksjs.com" should accept mail for "stacksjs.com"
681 const is_local = std.ascii.eqlIgnoreCase(recipient_domain, self.config.hostname) or blk: {
682 if (std.mem.indexOf(u8, self.config.hostname, ".")) |dot_pos| {
683 break :blk std.ascii.eqlIgnoreCase(recipient_domain, self.config.hostname[dot_pos + 1 ..]);
684 }
685 break :blk false;
686 };
687 if (!is_local) {
680 if (!self.config.isLocalDomain(recipient_domain)) {
688681 self.logger.logSecurityEvent(self.remote_addr, "Relay access denied for unauthenticated sender");
689682 try self.sendResponse(writer, 550, "5.7.1 Relay access denied", null);
690683 return;
@@ -1357,18 +1350,12 @@ pub const Session = struct {
13571350 const sender = self.mail_from orelse "unknown";
13581351
13591352 for (self.rcpt_to.items) |rcpt| {
1360 // Determine if recipient is local or external
1361 // Compare against hostname and its parent domain (mail.stacksjs.com -> stacksjs.com)
1362 const is_local = if (std.mem.indexOf(u8, rcpt, "@")) |at_pos| blk: {
1363 const rcpt_domain = rcpt[at_pos + 1 ..];
1364 if (std.mem.eql(u8, rcpt_domain, self.config.hostname)) break :blk true;
1365 // Also check parent domain: if hostname is "mail.X", accept "X" as local
1366 if (std.mem.indexOf(u8, self.config.hostname, ".")) |dot_pos| {
1367 const parent_domain = self.config.hostname[dot_pos + 1 ..];
1368 if (std.mem.eql(u8, rcpt_domain, parent_domain)) break :blk true;
1369 }
1370 break :blk false;
1371 } else true;
1353 // Determine if recipient is local or external. A bare address with
1354 // no '@' is treated as local (legacy local-user delivery).
1355 const is_local = if (std.mem.indexOf(u8, rcpt, "@")) |at_pos|
1356 self.config.isLocalDomain(rcpt[at_pos + 1 ..])
1357 else
1358 true;
13721359
13731360 if (is_local) {
13741361 // Local delivery: save to Maildir
@@ -1500,16 +1487,11 @@ pub const Session = struct {
15001487
15011488 self.logger.info("Auto-forwarding from {s} to {s}", .{ username, forward_to });
15021489
1503 // Check if forward target is a local address
1504 const is_local_forward = if (std.mem.indexOf(u8, forward_to, "@")) |at_pos| blk: {
1505 const fwd_domain = forward_to[at_pos + 1 ..];
1506 if (std.mem.eql(u8, fwd_domain, self.config.hostname)) break :blk true;
1507 if (std.mem.indexOf(u8, self.config.hostname, ".")) |dot_pos| {
1508 const parent_domain = self.config.hostname[dot_pos + 1 ..];
1509 if (std.mem.eql(u8, fwd_domain, parent_domain)) break :blk true;
1510 }
1511 break :blk false;
1512 } else true;
1490 // Check if forward target is a local address.
1491 const is_local_forward = if (std.mem.indexOf(u8, forward_to, "@")) |at_pos|
1492 self.config.isLocalDomain(forward_to[at_pos + 1 ..])
1493 else
1494 true;
15131495
15141496 if (is_local_forward) {
15151497 // Local forward: save directly to recipient's mailbox