Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
submodules: recursive

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -105,7 +105,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
submodules: recursive

Expand Down Expand Up @@ -164,7 +164,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
submodules: recursive

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
submodules: recursive

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
submodules: recursive

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -92,7 +92,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Extract version from tag
id: version
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
70 changes: 70 additions & 0 deletions packages/zig/src/config_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
59 changes: 59 additions & 0 deletions packages/zig/src/core/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
46 changes: 14 additions & 32 deletions packages/zig/src/core/protocol.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading