diff --git a/apps/server/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs b/apps/server/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs index 1428d3db4..dfd2fda6e 100644 --- a/apps/server/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs +++ b/apps/server/Services/AliasVault.SmtpService/Handlers/DatabaseMessageStore.cs @@ -294,7 +294,9 @@ public class DatabaseMessageStore(ILogger logger, Config c private async Task ProcessEmailForRecipient(MimeMessage message, IMailbox? toAddress) { // Check if toAddress domain is allowed. - if (toAddress is null || !config.AllowedToDomains.Contains(toAddress.Host.ToLowerInvariant())) + if (toAddress is null || + string.IsNullOrWhiteSpace(toAddress.Host) || + !config.AllowedToDomains.Contains(toAddress.Host.Trim().ToLowerInvariant())) { // ToAddress domain is not allowed. logger.LogInformation( diff --git a/apps/server/Services/AliasVault.SmtpService/Handlers/RecipientDomainMailboxFilter.cs b/apps/server/Services/AliasVault.SmtpService/Handlers/RecipientDomainMailboxFilter.cs new file mode 100644 index 000000000..c1205e590 --- /dev/null +++ b/apps/server/Services/AliasVault.SmtpService/Handlers/RecipientDomainMailboxFilter.cs @@ -0,0 +1,68 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.SmtpService.Handlers; + +using SmtpServer; +using SmtpServer.Mail; +using SmtpServer.Protocol; +using SmtpServer.Storage; + +/// +/// Mailbox filter that rejects recipients outside configured managed domains during RCPT TO. +/// +/// SMTP service configuration. +/// ILogger instance. +public class RecipientDomainMailboxFilter(Config config, ILogger logger) : MailboxFilter +{ + /// + /// Validate sender mailbox. + /// + /// The SMTP session context for the current connection. + /// The sender mailbox from the MAIL FROM command. + /// The declared message size in bytes. + /// A token that can cancel the asynchronous operation. + /// A task containing when the sender is accepted. + public override Task CanAcceptFromAsync(ISessionContext context, IMailbox @from, int size, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + /// + /// Validate recipient mailbox during RCPT TO command. + /// + /// The SMTP session context for the current connection. + /// The recipient mailbox from the RCPT TO command. + /// The sender mailbox from the MAIL FROM command. + /// A token that can cancel the asynchronous operation. + /// A task containing when the recipient is accepted. + public override Task CanDeliverToAsync(ISessionContext context, IMailbox to, IMailbox @from, CancellationToken cancellationToken) + { + if (IsAllowedRecipientDomain(to.Host)) + { + return Task.FromResult(true); + } + + logger.LogInformation( + "Rejected RCPT TO for recipient domain {RecipientDomain}: domain is not managed by this instance.", + to.Host); + + // Use 550 to mirror typical "relay not permitted" behavior and keep client behavior consistent. + throw new SmtpResponseException(new SmtpResponse(SmtpReplyCode.MailboxUnavailable, "Relay not permitted")); + } + + private bool IsAllowedRecipientDomain(string? domain) + { + if (string.IsNullOrWhiteSpace(domain)) + { + return false; + } + + var normalizedDomain = domain.Trim().ToLowerInvariant(); + return config.AllowedToDomains.Contains(normalizedDomain); + } +} diff --git a/apps/server/Services/AliasVault.SmtpService/Program.cs b/apps/server/Services/AliasVault.SmtpService/Program.cs index e622d1654..1810b7af4 100644 --- a/apps/server/Services/AliasVault.SmtpService/Program.cs +++ b/apps/server/Services/AliasVault.SmtpService/Program.cs @@ -34,7 +34,12 @@ builder.Services.ConfigureLogging(builder.Configuration, Assembly.GetExecutingAs // Create global config object, get values from environment variables. Config config = new Config(); var emailDomains = Environment.GetEnvironmentVariable("PRIVATE_EMAIL_DOMAINS") ?? string.Empty; -config.AllowedToDomains = emailDomains.Split(',').ToList(); +config.AllowedToDomains = emailDomains + .Split(',') + .Where(static x => !string.IsNullOrWhiteSpace(x)) + .Select(static x => x.Trim().ToLowerInvariant()) + .Distinct(StringComparer.Ordinal) + .ToList(); var tlsEnabled = Environment.GetEnvironmentVariable("SMTP_TLS_ENABLED") ?? "false"; config.SmtpTlsEnabled = tlsEnabled; @@ -72,6 +77,7 @@ builder.Services.AddSingleton(config); builder.Services.AddAliasVaultDatabaseConfiguration(builder.Configuration); builder.Services.AddTransient(); +builder.Services.AddTransient(); builder.Services.AddSingleton( provider => { diff --git a/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index f0ddc8a30..ca6d579e2 100644 --- a/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -157,6 +157,46 @@ public class SmtpServerTests }); } + /// + /// RCPT TO for external domains should be rejected immediately with a 550 relay denial. + /// + /// Task. + [Test] + public async Task RcptToExternalDomain_IsRejectedAtRcptStage() + { + using var client = await ConnectRawSmtpClient(); + using var stream = client.GetStream(); + + _ = await ReadSmtpLineAsync(stream); // Greeting + Assert.That(await SendSmtpCommandAsync(stream, "HELO localhost"), Does.StartWith("250")); + Assert.That(await SendSmtpCommandAsync(stream, "MAIL FROM:"), Does.StartWith("250")); + + var rcptResponse = await SendSmtpCommandAsync(stream, "RCPT TO:"); + Assert.Multiple(() => + { + Assert.That(rcptResponse, Does.StartWith("550")); + Assert.That(rcptResponse.ToLowerInvariant(), Does.Contain("relay not permitted")); + }); + } + + /// + /// RCPT TO for managed domains should be accepted regardless of alias existence (privacy protection). + /// + /// Task. + [Test] + public async Task RcptToManagedDomain_IsAcceptedAtRcptStage() + { + using var client = await ConnectRawSmtpClient(); + using var stream = client.GetStream(); + + _ = await ReadSmtpLineAsync(stream); // Greeting + Assert.That(await SendSmtpCommandAsync(stream, "HELO localhost"), Does.StartWith("250")); + Assert.That(await SendSmtpCommandAsync(stream, "MAIL FROM:"), Does.StartWith("250")); + + var rcptResponse = await SendSmtpCommandAsync(stream, "RCPT TO:"); + Assert.That(rcptResponse, Does.StartWith("250")); + } + /// /// Tests sending a single email in plain format to the SMTP server with valid claim to check if it is processed correctly. /// @@ -272,7 +312,6 @@ public class SmtpServerTests message.From.Add(new MailboxAddress("Test Sender", "sender@example.com")); message.To.Add(new MailboxAddress("Test Recipient", "claimed@example.tld")); message.Cc.Add(new MailboxAddress("Test Recipient 2", "claimed.cc@example.tld")); - message.Cc.Add(new MailboxAddress("Test Recipient 3 unknown domain", "recipient@unknowndomain.tld")); message.Subject = "Test Email"; const string textBody = "This is a test email plain."; @@ -402,4 +441,41 @@ public class SmtpServerTests await client.DisconnectAsync(true); } } + + private static async Task ConnectRawSmtpClient() + { + var client = new TcpClient(); + const int maxRetries = 10; + const int retryDelayMs = 100; + + for (var attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + await client.ConnectAsync("127.0.0.1", 2525); + return client; + } + catch (SocketException) when (attempt < maxRetries) + { + await Task.Delay(retryDelayMs); + } + } + + throw new InvalidOperationException("Unable to connect to SMTP server for raw command test."); + } + + private static async Task SendSmtpCommandAsync(NetworkStream stream, string command) + { + var bytes = Encoding.ASCII.GetBytes($"{command}\r\n"); + await stream.WriteAsync(bytes.AsMemory(0, bytes.Length)); + await stream.FlushAsync(); + return await ReadSmtpLineAsync(stream); + } + + private static async Task ReadSmtpLineAsync(NetworkStream stream) + { + var buffer = new byte[512]; + var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length)); + return Encoding.ASCII.GetString(buffer, 0, bytesRead).Trim(); + } } diff --git a/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs b/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs index afa400b2e..e999fe674 100644 --- a/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs +++ b/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs @@ -95,6 +95,7 @@ public class TestHostBuilder : AbstractTestHostBuilder }; }); + services.AddTransient(); services.AddTransient(); services.AddSingleton( provider =>