also looking at this
feat(config): add hosted_domains for multi-domain hosting
#1
7 files
+153
-42
| @@ -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 | } |