From 144207195fcae8bbedcb1320543b7e24c7885db4 Mon Sep 17 00:00:00 2001 From: glennmichael123 Date: Wed, 6 May 2026 18:43:18 +0800 Subject: [PATCH 1/2] chore(ci): bump actions/checkout to v6, actions/cache to v5 --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/deploy.yml | 2 +- .github/workflows/release.yml | 6 +++--- .github/workflows/version.yml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3293ce..e477bab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: recursive @@ -57,7 +57,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Pantry uses: home-lang/pantry/packages/action@main @@ -83,7 +83,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Zig uses: mlugg/setup-zig@v2 @@ -105,7 +105,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: recursive @@ -164,7 +164,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: recursive diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2f85c59..64c28c7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: recursive diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a1b5bf..c9b00f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: recursive @@ -59,7 +59,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -92,7 +92,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Extract version from tag id: version diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 9192d5c..9ceef50 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} From 7937488eba2f74931f8e60292035c2ed11a0955d Mon Sep 17 00:00:00 2001 From: glennmichael123 Date: Wed, 6 May 2026 20:53:53 +0800 Subject: [PATCH 2/2] feat(config): add hosted_domains for multi-domain hosting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Config.hosted_domains so the server can accept inbound mail for domains beyond `hostname` and its parent. Configurable via TOML (`server.hosted_domains` as a comma-separated string until the TOML parser supports arrays) or env (`SMTP_HOSTED_DOMAINS`). Replaces three duplicated is_local blocks in protocol.zig with a single `Config.isLocalDomain` helper covering the relay-denial check, local delivery routing, and auto-forward target classification. Side effect: the local-delivery and forward checks were case-sensitive before; they're now case-insensitive (matches the relay check and RFC 5321 §2.3.5). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/zig/src/config_test.zig | 70 ++++++++++++++++++++++++++++++ packages/zig/src/core/config.zig | 59 +++++++++++++++++++++++++ packages/zig/src/core/protocol.zig | 46 ++++++-------------- 3 files changed, 143 insertions(+), 32 deletions(-) diff --git a/packages/zig/src/config_test.zig b/packages/zig/src/config_test.zig index 2d3d753..1520604 100644 --- a/packages/zig/src/config_test.zig +++ b/packages/zig/src/config_test.zig @@ -268,3 +268,73 @@ test "authentication configuration flags" { try testing.expectEqual(false, cfg_auth_off.enable_auth); } + +fn baseCfg(hostname: []const u8, hosted: []const []const u8) config.Config { + return .{ + .host = "0.0.0.0", + .port = 2525, + .max_connections = 100, + .enable_tls = false, + .tls_cert_path = null, + .tls_key_path = null, + .enable_auth = true, + .max_message_size = 10 * 1024 * 1024, + .timeout_seconds = 300, + .data_timeout_seconds = 600, + .command_timeout_seconds = 300, + .greeting_timeout_seconds = 30, + .rate_limit_per_ip = 100, + .rate_limit_per_user = 200, + .rate_limit_cleanup_interval = 3600, + .max_recipients = 100, + .hostname = hostname, + .hosted_domains = hosted, + .webhook_url = null, + .webhook_enabled = false, + .enable_dnsbl = false, + .enable_greylist = false, + .enable_tracing = false, + .tracing_service_name = "mail", + .enable_json_logging = false, + }; +} + +test "isLocalDomain matches hostname exactly" { + const cfg = baseCfg("mail.stacksjs.com", &.{}); + try testing.expect(cfg.isLocalDomain("mail.stacksjs.com")); +} + +test "isLocalDomain matches parent of hostname" { + const cfg = baseCfg("mail.stacksjs.com", &.{}); + try testing.expect(cfg.isLocalDomain("stacksjs.com")); +} + +test "isLocalDomain matches an entry in hosted_domains" { + const extras = [_][]const u8{ "paweldregan.com", "example.org" }; + const cfg = baseCfg("mail.stacksjs.com", &extras); + try testing.expect(cfg.isLocalDomain("paweldregan.com")); + try testing.expect(cfg.isLocalDomain("example.org")); +} + +test "isLocalDomain is case-insensitive" { + const extras = [_][]const u8{"paweldregan.com"}; + const cfg = baseCfg("mail.stacksjs.com", &extras); + try testing.expect(cfg.isLocalDomain("MAIL.Stacksjs.COM")); + try testing.expect(cfg.isLocalDomain("StacksJS.COM")); + try testing.expect(cfg.isLocalDomain("PaWeLdReGaN.com")); +} + +test "isLocalDomain rejects unrelated domains" { + const extras = [_][]const u8{"paweldregan.com"}; + const cfg = baseCfg("mail.stacksjs.com", &extras); + try testing.expect(!cfg.isLocalDomain("evil.example")); + try testing.expect(!cfg.isLocalDomain("notstacksjs.com")); + try testing.expect(!cfg.isLocalDomain("")); +} + +test "isLocalDomain has no parent for single-label hostname" { + // "localhost" has no '.' so there's no parent to match + const cfg = baseCfg("localhost", &.{}); + try testing.expect(cfg.isLocalDomain("localhost")); + try testing.expect(!cfg.isLocalDomain("host")); +} diff --git a/packages/zig/src/core/config.zig b/packages/zig/src/core/config.zig index 095f185..30fff74 100644 --- a/packages/zig/src/core/config.zig +++ b/packages/zig/src/core/config.zig @@ -47,6 +47,10 @@ pub const Config = struct { rate_limit_cleanup_interval: u64, max_recipients: usize, hostname: []const u8, + /// Additional domains this server accepts mail for, beyond `hostname` + /// and its parent. Populated from TOML `server.hosted_domains` (comma- + /// separated string) or env `SMTP_HOSTED_DOMAINS`. + hosted_domains: []const []const u8 = &.{}, webhook_url: ?[]const u8, webhook_enabled: bool, enable_dnsbl: bool, @@ -86,6 +90,22 @@ pub const Config = struct { if (self.webhook_url) |url| allocator.free(url); if (self.acme_email) |email| allocator.free(email); if (self.list_unsubscribe_url) |url| allocator.free(url); + for (self.hosted_domains) |domain| allocator.free(domain); + if (self.hosted_domains.len > 0) allocator.free(self.hosted_domains); + } + + /// Returns true if `domain` is one this server accepts mail for: + /// `hostname`, the parent of `hostname` (e.g. `mail.X` accepts `X`), + /// or any entry in `hosted_domains`. Match is case-insensitive. + pub fn isLocalDomain(self: Config, domain: []const u8) bool { + if (std.ascii.eqlIgnoreCase(domain, self.hostname)) return true; + if (std.mem.indexOfScalar(u8, self.hostname, '.')) |dot_pos| { + if (std.ascii.eqlIgnoreCase(domain, self.hostname[dot_pos + 1 ..])) return true; + } + for (self.hosted_domains) |hosted| { + if (std.ascii.eqlIgnoreCase(domain, hosted)) return true; + } + return false; } /// Validates the configuration and returns detailed error messages @@ -265,6 +285,31 @@ pub fn loadConfig(allocator: std.mem.Allocator, cli_args: args.Args) !Config { return cfg; } +/// Parse a comma-separated list of domain names into an owned slice of +/// allocator-owned strings. Empty entries and surrounding whitespace are +/// trimmed; an empty input yields a zero-length slice. +fn parseDomainList(allocator: std.mem.Allocator, raw: []const u8) ![]const []const u8 { + var list = std.ArrayList([]const u8).empty; + errdefer { + for (list.items) |item| allocator.free(item); + list.deinit(allocator); + } + var it = std.mem.splitScalar(u8, raw, ','); + while (it.next()) |part| { + const trimmed = std.mem.trim(u8, part, " \t\r\n"); + if (trimmed.len == 0) continue; + const dup = try allocator.dupe(u8, trimmed); + errdefer allocator.free(dup); + try list.append(allocator, dup); + } + return list.toOwnedSlice(allocator); +} + +fn freeDomainList(allocator: std.mem.Allocator, list: []const []const u8) void { + for (list) |entry| allocator.free(entry); + if (list.len > 0) allocator.free(list); +} + /// Determine which configuration profile to use fn determineProfile() config_profiles.Profile { if (env.get("SMTP_PROFILE")) |profile_str| { @@ -304,6 +349,7 @@ fn loadDefaultsFromProfile(allocator: std.mem.Allocator, profile: config_profile .rate_limit_cleanup_interval = @as(u64, profile_config.rate_limit_window_seconds) * 60, // Convert to seconds .max_recipients = @intCast(profile_config.max_recipients), .hostname = try allocator.dupe(u8, "localhost"), + .hosted_domains = try allocator.alloc([]const u8, 0), .webhook_url = null, .webhook_enabled = false, // Only enable when URL is provided via env var .enable_dnsbl = false, // Not in profile config yet @@ -348,6 +394,13 @@ fn applyEnvironmentVariables(allocator: std.mem.Allocator, cfg: *Config) !void { cfg.hostname = try allocator.dupe(u8, value); } + // SMTP_HOSTED_DOMAINS — comma-separated list of additional hosted domains + if (env.get("SMTP_HOSTED_DOMAINS")) |value| { + const parsed = try parseDomainList(allocator, value); + freeDomainList(allocator, cfg.hosted_domains); + cfg.hosted_domains = parsed; + } + // SMTP_MAX_CONNECTIONS if (env.get("SMTP_MAX_CONNECTIONS")) |value| { 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) allocator.free(cfg.hostname); cfg.hostname = try allocator.dupe(u8, value); } + // Comma-separated string until the TOML parser supports arrays. + if (server.getString("hosted_domains")) |value| { + const parsed = try parseDomainList(allocator, value); + freeDomainList(allocator, cfg.hosted_domains); + cfg.hosted_domains = parsed; + } if (server.getInt("max_connections")) |value| { cfg.max_connections = @intCast(value); } diff --git a/packages/zig/src/core/protocol.zig b/packages/zig/src/core/protocol.zig index 70848b5..675eb6f 100644 --- a/packages/zig/src/core/protocol.zig +++ b/packages/zig/src/core/protocol.zig @@ -672,19 +672,12 @@ pub const Session = struct { return; } - // Prevent open relay: unauthenticated clients can only send to local domain + // Prevent open relay: unauthenticated clients can only send to a local domain + // (hostname, its parent, or any entry in config.hosted_domains). if (!self.authenticated) { if (std.mem.indexOf(u8, addr, "@")) |at_pos| { const recipient_domain = addr[at_pos + 1 ..]; - // Check if recipient domain matches hostname or is the parent domain - // e.g. hostname "mail.stacksjs.com" should accept mail for "stacksjs.com" - const is_local = std.ascii.eqlIgnoreCase(recipient_domain, self.config.hostname) or blk: { - if (std.mem.indexOf(u8, self.config.hostname, ".")) |dot_pos| { - break :blk std.ascii.eqlIgnoreCase(recipient_domain, self.config.hostname[dot_pos + 1 ..]); - } - break :blk false; - }; - if (!is_local) { + if (!self.config.isLocalDomain(recipient_domain)) { self.logger.logSecurityEvent(self.remote_addr, "Relay access denied for unauthenticated sender"); try self.sendResponse(writer, 550, "5.7.1 Relay access denied", null); return; @@ -1357,18 +1350,12 @@ pub const Session = struct { const sender = self.mail_from orelse "unknown"; for (self.rcpt_to.items) |rcpt| { - // Determine if recipient is local or external - // Compare against hostname and its parent domain (mail.stacksjs.com -> stacksjs.com) - const is_local = if (std.mem.indexOf(u8, rcpt, "@")) |at_pos| blk: { - const rcpt_domain = rcpt[at_pos + 1 ..]; - if (std.mem.eql(u8, rcpt_domain, self.config.hostname)) break :blk true; - // Also check parent domain: if hostname is "mail.X", accept "X" as local - if (std.mem.indexOf(u8, self.config.hostname, ".")) |dot_pos| { - const parent_domain = self.config.hostname[dot_pos + 1 ..]; - if (std.mem.eql(u8, rcpt_domain, parent_domain)) break :blk true; - } - break :blk false; - } else true; + // Determine if recipient is local or external. A bare address with + // no '@' is treated as local (legacy local-user delivery). + const is_local = if (std.mem.indexOf(u8, rcpt, "@")) |at_pos| + self.config.isLocalDomain(rcpt[at_pos + 1 ..]) + else + true; if (is_local) { // Local delivery: save to Maildir @@ -1500,16 +1487,11 @@ pub const Session = struct { self.logger.info("Auto-forwarding from {s} to {s}", .{ username, forward_to }); - // Check if forward target is a local address - const is_local_forward = if (std.mem.indexOf(u8, forward_to, "@")) |at_pos| blk: { - const fwd_domain = forward_to[at_pos + 1 ..]; - if (std.mem.eql(u8, fwd_domain, self.config.hostname)) break :blk true; - if (std.mem.indexOf(u8, self.config.hostname, ".")) |dot_pos| { - const parent_domain = self.config.hostname[dot_pos + 1 ..]; - if (std.mem.eql(u8, fwd_domain, parent_domain)) break :blk true; - } - break :blk false; - } else true; + // Check if forward target is a local address. + const is_local_forward = if (std.mem.indexOf(u8, forward_to, "@")) |at_pos| + self.config.isLocalDomain(forward_to[at_pos + 1 ..]) + else + true; if (is_local_forward) { // Local forward: save directly to recipient's mailbox