also looking at this
feat(config): add hosted_domains for multi-domain hosting
#1
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.
| @@ -268,3 +268,73 @@ test "authentication configuration flags" { | ||
| 268 | 268 | |
| 269 | 269 | try testing.expectEqual(false, cfg_auth_off.enable_auth); |
| 270 | 270 | } |
| 271 | ||
| 272 | fn 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 | ||
| 302 | test "isLocalDomain matches hostname exactly" { | |
| 303 | const cfg = baseCfg("mail.stacksjs.com", &.{}); | |
| 304 | try testing.expect(cfg.isLocalDomain("mail.stacksjs.com")); | |
| 305 | } | |
| 306 | ||
| 307 | test "isLocalDomain matches parent of hostname" { | |
| 308 | const cfg = baseCfg("mail.stacksjs.com", &.{}); | |
| 309 | try testing.expect(cfg.isLocalDomain("stacksjs.com")); | |
| 310 | } | |
| 311 | ||
| 312 | test "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 | ||
| 319 | test "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 | ||
| 327 | test "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 | ||
| 335 | test "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 | } | |
| @@ -47,6 +47,10 @@ pub const Config = struct { | ||
| 47 | 47 | rate_limit_cleanup_interval: u64, |
| 48 | 48 | max_recipients: usize, |
| 49 | 49 | 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 = &.{}, | |
| 50 | 54 | webhook_url: ?[]const u8, |
| 51 | 55 | webhook_enabled: bool, |
| 52 | 56 | enable_dnsbl: bool, |
| @@ -86,6 +90,22 @@ pub const Config = struct { | ||
| 86 | 90 | if (self.webhook_url) |url| allocator.free(url); |
| 87 | 91 | if (self.acme_email) |email| allocator.free(email); |
| 88 | 92 | 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; | |
| 89 | 109 | } |
| 90 | 110 | |
| 91 | 111 | /// Validates the configuration and returns detailed error messages |
| @@ -265,6 +285,31 @@ pub fn loadConfig(allocator: std.mem.Allocator, cli_args: args.Args) !Config { | ||
| 265 | 285 | return cfg; |
| 266 | 286 | } |
| 267 | 287 | |
| 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. | |
| 291 | fn 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 | ||
| 308 | fn 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 | ||
| 268 | 313 | /// Determine which configuration profile to use |
| 269 | 314 | fn determineProfile() config_profiles.Profile { |
| 270 | 315 | if (env.get("SMTP_PROFILE")) |profile_str| { |
| @@ -304,6 +349,7 @@ fn loadDefaultsFromProfile(allocator: std.mem.Allocator, profile: config_profile | ||
| 304 | 349 | .rate_limit_cleanup_interval = @as(u64, profile_config.rate_limit_window_seconds) * 60, // Convert to seconds |
| 305 | 350 | .max_recipients = @intCast(profile_config.max_recipients), |
| 306 | 351 | .hostname = try allocator.dupe(u8, "localhost"), |
| 352 | .hosted_domains = try allocator.alloc([]const u8, 0), | |
| 307 | 353 | .webhook_url = null, |
| 308 | 354 | .webhook_enabled = false, // Only enable when URL is provided via env var |
| 309 | 355 | .enable_dnsbl = false, // Not in profile config yet |
| @@ -348,6 +394,13 @@ fn applyEnvironmentVariables(allocator: std.mem.Allocator, cfg: *Config) !void { | ||
| 348 | 394 | cfg.hostname = try allocator.dupe(u8, value); |
| 349 | 395 | } |
| 350 | 396 | |
| 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 | ||
| 351 | 404 | // SMTP_MAX_CONNECTIONS |
| 352 | 405 | if (env.get("SMTP_MAX_CONNECTIONS")) |value| { |
| 353 | 406 | 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) | ||
| 569 | 622 | allocator.free(cfg.hostname); |
| 570 | 623 | cfg.hostname = try allocator.dupe(u8, value); |
| 571 | 624 | } |
| 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 | } | |
| 572 | 631 | if (server.getInt("max_connections")) |value| { |
| 573 | 632 | cfg.max_connections = @intCast(value); |
| 574 | 633 | } |
| @@ -672,19 +672,12 @@ pub const Session = struct { | ||
| 672 | 672 | return; |
| 673 | 673 | } |
| 674 | 674 | |
| 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). | |
| 676 | 677 | if (!self.authenticated) { |
| 677 | 678 | if (std.mem.indexOf(u8, addr, "@")) |at_pos| { |
| 678 | 679 | 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)) { | |
| 688 | 681 | self.logger.logSecurityEvent(self.remote_addr, "Relay access denied for unauthenticated sender"); |
| 689 | 682 | try self.sendResponse(writer, 550, "5.7.1 Relay access denied", null); |
| 690 | 683 | return; |
| @@ -1357,18 +1350,12 @@ pub const Session = struct { | ||
| 1357 | 1350 | const sender = self.mail_from orelse "unknown"; |
| 1358 | 1351 | |
| 1359 | 1352 | 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; | |
| 1372 | 1359 | |
| 1373 | 1360 | if (is_local) { |
| 1374 | 1361 | // Local delivery: save to Maildir |
| @@ -1500,16 +1487,11 @@ pub const Session = struct { | ||
| 1500 | 1487 | |
| 1501 | 1488 | self.logger.info("Auto-forwarding from {s} to {s}", .{ username, forward_to }); |
| 1502 | 1489 | |
| 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; | |
| 1513 | 1495 | |
| 1514 | 1496 | if (is_local_forward) { |
| 1515 | 1497 | // Local forward: save directly to recipient's mailbox |