diff --git a/.env.example b/.env.example index 9ee8d465b..1f6c2216a 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,11 @@ PRIVATE_EMAIL_DOMAINS= # Note: Domains listed here should ALSO be included in PRIVATE_EMAIL_DOMAINS above. HIDDEN_PRIVATE_EMAIL_DOMAINS= +# Hostname announced in the SMTP banner and EHLO. Should match the PTR (reverse DNS) for your +# server's public IP for best deliverability. If empty, "aliasvault" is used. +# Example: SMTP_ADVERTISED_HOSTNAME=mail.mydomain.net +SMTP_ADVERTISED_HOSTNAME= + # Enable TLS for SMTP (STARTTLS). # When enabled, the SMTP server will offer STARTTLS to connecting mail servers. # diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c501ed1f1..c189f18bd 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -225,4 +225,4 @@ "dependsOn": [] }, ] -} +} \ No newline at end of file diff --git a/apps/server/Services/AliasVault.SmtpService/Program.cs b/apps/server/Services/AliasVault.SmtpService/Program.cs index 4c5892724..e622d1654 100644 --- a/apps/server/Services/AliasVault.SmtpService/Program.cs +++ b/apps/server/Services/AliasVault.SmtpService/Program.cs @@ -16,6 +16,7 @@ using AliasVault.SmtpService.Handlers; using AliasVault.SmtpService.Workers; using AliasVault.WorkerStatus.ServiceExtensions; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using SmtpServer; using SmtpServer.Storage; @@ -41,6 +42,12 @@ config.SmtpTlsEnabled = tlsEnabled; var certPath = Environment.GetEnvironmentVariable("SMTP_CERTIFICATES_PATH") ?? "/certificates/smtp"; config.SmtpCertificatesPath = certPath; +var advertisedHostname = Environment.GetEnvironmentVariable("SMTP_ADVERTISED_HOSTNAME"); +if (string.IsNullOrWhiteSpace(advertisedHostname)) +{ + advertisedHostname = "aliasvault"; +} + // Check if TLS is requested but certificates are not available, if so, fallback to non-TLS mode. var tlsAvailable = false; X509Certificate2? loadedCertificate = null; @@ -70,8 +77,10 @@ builder.Services.AddSingleton( { // Use SmtpServerWorker logger so logs appear in the database (it's in the allowed sources list). var logger = provider.GetRequiredService>(); + var configuration = provider.GetRequiredService(); + logger.LogInformation("SMTP advertised hostname (banner / EHLO): {AdvertisedHostname}", advertisedHostname); var options = new SmtpServerOptionsBuilder() - .ServerName("aliasvault"); + .ServerName(advertisedHostname); if (tlsAvailable && loadedCertificate != null) { diff --git a/apps/server/Tests/AliasVault.IntegrationTests/AbstractTestHostBuilder.cs b/apps/server/Tests/AliasVault.IntegrationTests/AbstractTestHostBuilder.cs index 7c4d4cea1..babf2879c 100644 --- a/apps/server/Tests/AliasVault.IntegrationTests/AbstractTestHostBuilder.cs +++ b/apps/server/Tests/AliasVault.IntegrationTests/AbstractTestHostBuilder.cs @@ -106,6 +106,30 @@ public class AbstractTestHostBuilder : IAsyncDisposable } } + /// + /// Adds extra in-memory configuration keys before the test is built. + /// + /// Mutable dictionary merged into the configuration (includes database keys). + protected virtual void AddIntegrationTestConfiguration(IDictionary settings) + { + } + + /// + /// Builds the standard in-memory settings for integration tests, including database connection. + /// + /// Connection string for AliasServerDbContext. + /// Dictionary passed to ConfigurationBuilder.AddInMemoryCollection. + protected Dictionary CreateMemoryConfigurationSettings(string aliasServerConnectionString) + { + var settings = new Dictionary + { + ["DatabaseProvider"] = "postgresql", + ["ConnectionStrings:AliasServerDbContext"] = aliasServerConnectionString, + }; + AddIntegrationTestConfiguration(settings); + return settings; + } + /// /// Creates a new test host builder with test database connection already configured. /// @@ -136,13 +160,10 @@ public class AbstractTestHostBuilder : IAsyncDisposable .ConfigureServices((context, services) => { // Override configuration + var memorySettings = CreateMemoryConfigurationSettings(dbConnection.ConnectionString); var configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json", optional: true) - .AddInMemoryCollection(new Dictionary - { - ["DatabaseProvider"] = "postgresql", - ["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString, - }) + .AddInMemoryCollection(memorySettings) .Build(); services.AddSingleton(configuration); diff --git a/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs b/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs index 6e01e9233..f0ddc8a30 100644 --- a/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs +++ b/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/SmtpServerTests.cs @@ -7,6 +7,7 @@ namespace AliasVault.IntegrationTests.SmtpServer; +using System.Net.Sockets; using System.Text; using AliasServerDb; using AliasVault.Cryptography.Server; @@ -120,6 +121,42 @@ public class SmtpServerTests await _testHostBuilder.DisposeAsync(); } + /// + /// The SMTP greeting line must include the configured advertised hostname (same path as production resolver). + /// + /// Task. + [Test] + public async Task SmtpBanner_ContainsConfiguredHostname() + { + using 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); + break; + } + catch (SocketException) when (attempt < maxRetries) + { + await Task.Delay(retryDelayMs); + } + } + + using var stream = client.GetStream(); + var buffer = new byte[512]; + var n = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length)); + var line = Encoding.ASCII.GetString(buffer, 0, n); + + Assert.Multiple(() => + { + Assert.That(line, Does.StartWith("220 ")); + Assert.That(line, Does.Contain(TestHostBuilder.IntegrationAdvertisedHostname)); + }); + } + /// /// Tests sending a single email in plain format to the SMTP server with valid claim to check if it is processed correctly. /// diff --git a/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs b/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs index ca50d0707..afa400b2e 100644 --- a/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs +++ b/apps/server/Tests/AliasVault.IntegrationTests/SmtpServer/TestHostBuilder.cs @@ -8,6 +8,7 @@ namespace AliasVault.IntegrationTests.SmtpServer; using System.Data.Common; +using System.Net; using AliasVault.SmtpService; using AliasVault.SmtpService.Handlers; using AliasVault.SmtpService.Workers; @@ -22,6 +23,11 @@ using Microsoft.Extensions.Hosting; /// public class TestHostBuilder : AbstractTestHostBuilder { + /// + /// Hostname advertised in SMTP banner / EHLO for integration tests (must match production resolver usage). + /// + public const string IntegrationAdvertisedHostname = "mail.integration.test"; + /// /// Builds the SmtpService test host with a provided database connection. /// @@ -29,6 +35,8 @@ public class TestHostBuilder : AbstractTestHostBuilder /// IHost. public IHost Build(DbConnection dbConnection) { + Environment.SetEnvironmentVariable("SMTP_ADVERTISED_HOSTNAME", IntegrationAdvertisedHostname); + // Get base builder with database connection already configured. var builder = CreateBuilder(); @@ -37,13 +45,10 @@ public class TestHostBuilder : AbstractTestHostBuilder { // Override database connection with provided connection. services.Remove(services.First(x => x.ServiceType == typeof(IConfiguration))); + var memorySettings = CreateMemoryConfigurationSettings(dbConnection.ConnectionString); var configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json", optional: true) - .AddInMemoryCollection(new Dictionary - { - ["DatabaseProvider"] = "postgresql", - ["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString, - }) + .AddInMemoryCollection(memorySettings) .Build(); services.AddSingleton(configuration); @@ -60,6 +65,8 @@ public class TestHostBuilder : AbstractTestHostBuilder /// IHost. public IHost Build() { + Environment.SetEnvironmentVariable("SMTP_ADVERTISED_HOSTNAME", IntegrationAdvertisedHostname); + // Get base builder with database connection already configured. var builder = CreateBuilder(); @@ -78,18 +85,25 @@ public class TestHostBuilder : AbstractTestHostBuilder /// The service collection to configure. private static void ConfigureSmtpServices(IServiceCollection services) { - services.AddSingleton(new Config + services.AddSingleton(provider => { - AllowedToDomains = new List { "example.tld" }, - SmtpTlsEnabled = "false", + var configuration = provider.GetRequiredService(); + return new Config + { + AllowedToDomains = new List { "example.tld" }, + SmtpTlsEnabled = "false", + }; }); services.AddTransient(); services.AddSingleton( provider => { + var advertisedHostname = ResolveAdvertisedHostname( + Environment.GetEnvironmentVariable("SMTP_ADVERTISED_HOSTNAME"), + Dns.GetHostName); var options = new SmtpServerOptionsBuilder() - .ServerName("aliasvault"); + .ServerName(advertisedHostname); // Note: port 25 doesn't work in GitHub actions so we use these instead for the integration tests: // - 2525 for the SMTP server @@ -106,4 +120,28 @@ public class TestHostBuilder : AbstractTestHostBuilder services.AddHostedService(); } + + private static string ResolveAdvertisedHostname( + string? environmentValue, + Func dnsHostNameFallback) + { + var fromEnvironment = TrimOrNull(environmentValue); + if (fromEnvironment != null) + { + return fromEnvironment; + } + + return dnsHostNameFallback(); + } + + private static string? TrimOrNull(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.Length == 0 ? null : trimmed; + } } diff --git a/docs/installation/docker-compose/index.md b/docs/installation/docker-compose/index.md index 69fb30cde..ca1f2ea0a 100644 --- a/docs/installation/docker-compose/index.md +++ b/docs/installation/docker-compose/index.md @@ -189,6 +189,21 @@ Important: DNS propagation can take up to 24-48 hours. During this time, email d If you encounter any issues, feel free to join the [Discord chat](https://discord.gg/DsaXMTEtpF) to get help from other users and maintainers. +### Optional: SMTP advertised hostname + +When you expose SMTP (ports 25 and 587), remote clients see a **hostname** in the SMTP **banner** and **EHLO** responses. This value defaults to `aliasvault` which works fine for general email deliverability, but some diagnostic tools may check that this name is consistent with **reverse DNS (PTR)** with your email MX record. + +If you wish to set this value, update the `docker-compose.yml` file: + +```bash +# ... + environment: + SMTP_ADVERTISED_HOSTNAME: "mail.yourdomain1.com" +# ... +``` + +Restart the container after changing this value. + ### Optional: SMTP TLS (STARTTLS) By default, SMTP TLS is disabled (`SMTP_TLS_ENABLED: "false"`). This does NOT significantly impact email deliverability: most email providers will still deliver to your server. However, if you want to enable TLS for SMTP connections: diff --git a/docs/installation/script/index.md b/docs/installation/script/index.md index cdc5074ae..b0373d2da 100644 --- a/docs/installation/script/index.md +++ b/docs/installation/script/index.md @@ -163,8 +163,9 @@ After setting up your DNS, continue with configuring AliasVault to let it know w ./install.sh configure-email ```` 2. Follow the interactive prompts to: - - Configure your domain(s) - - Restart required services + 1. Configure your domain(s) + 2. Set the SMTP advertised hostname to your main domain (helps with improving deliverability) + 3. Restart required services 3. Once configured, you can: - Create new aliases in the AliasVault client diff --git a/install.sh b/install.sh index e429697db..6806f806f 100755 --- a/install.sh +++ b/install.sh @@ -1300,6 +1300,24 @@ update_env_var() { printf " ${GREEN}> $key has been set in $ENV_FILE.${NC}\n" } +# Prompt for SMTP_ADVERTISED_HOSTNAME (banner / EHLO). Empty value defaults to "aliasvault" at runtime. +prompt_smtp_advertised_hostname() { + local current + current=$(grep "^SMTP_ADVERTISED_HOSTNAME=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2- | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + + printf "\n${CYAN}Enter SMTP advertised hostname (banner / EHLO)${NC}\n" + printf "This is shown to other mail servers when they connect, setting it can improve email deliverability.\n" + printf "Example: mail.example.com. Leave blank to default to \"aliasvault\" (which is fine for general use).\n" + if [ -n "$current" ]; then + printf "Current value: ${CYAN}${current}${NC}\n" + fi + read -r -p "Hostname: " user_in + + local trimmed + trimmed=$(echo "$user_in" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + update_env_var "SMTP_ADVERTISED_HOSTNAME" "$trimmed" +} + # Helper function to write secrets to files instead of .env write_secret_to_file() { local secret_name=$1 @@ -2182,6 +2200,8 @@ handle_email_configuration() { fi done + prompt_smtp_advertised_hostname + # Update .env file and restart if ! update_env_var "PRIVATE_EMAIL_DOMAINS" "$new_domains"; then printf "${RED}Failed to update configuration.${NC}\n" @@ -3343,6 +3363,11 @@ check_and_populate_env() { update_env_var "SMTP_TLS_PORT" "587" printf " Set SMTP_TLS_PORT\n" fi + + # SMTP_ADVERTISED_HOSTNAME + if ! grep -q "^SMTP_ADVERTISED_HOSTNAME=" "$ENV_FILE" 2>/dev/null; then + update_env_var "SMTP_ADVERTISED_HOSTNAME" "" + fi } main "$@"