Add option to configure SMTP advertised hostname for self-hosted setups (#1877)

* feat(smtp): make advertised hostname configurable for PTR/banner alignment

* test(integration): align SMTP TestHostBuilder with AdvertisedHostnameConfiguration and IConfiguration

* test(import): expect Dashlane notes newline per Environment.NewLine

* docs: document SMTP advertised hostname and PTR/banner alignment

* restore original .vscode folder content

* Use env-only SMTP advertised hostname in tests and service

* revert

* remove unused reference

* remove unused methods

* Use aliasvault when SMTP advertised hostname is empty

* Update SMTP advertised hostname docs

* Update install.sh SMTP advertised hostname prompt

* Update .env.example

* Update docs

---------

Co-authored-by: Arnaud Dartois <opensource.fork@tordais.cc>
Co-authored-by: Leendert de Borst <ldeborst@xivisoft.com>
This commit is contained in:
Arnaud Dartois
2026-04-13 14:03:54 +02:00
committed by GitHub
parent a9402c1267
commit ce96b2e85a
9 changed files with 169 additions and 18 deletions

View File

@@ -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.
#

2
.vscode/tasks.json vendored
View File

@@ -225,4 +225,4 @@
"dependsOn": []
},
]
}
}

View File

@@ -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<ILogger<SmtpServerWorker>>();
var configuration = provider.GetRequiredService<IConfiguration>();
logger.LogInformation("SMTP advertised hostname (banner / EHLO): {AdvertisedHostname}", advertisedHostname);
var options = new SmtpServerOptionsBuilder()
.ServerName("aliasvault");
.ServerName(advertisedHostname);
if (tlsAvailable && loadedCertificate != null)
{

View File

@@ -106,6 +106,30 @@ public class AbstractTestHostBuilder : IAsyncDisposable
}
}
/// <summary>
/// Adds extra in-memory configuration keys before the test <see cref="IConfiguration"/> is built.
/// </summary>
/// <param name="settings">Mutable dictionary merged into the configuration (includes database keys).</param>
protected virtual void AddIntegrationTestConfiguration(IDictionary<string, string?> settings)
{
}
/// <summary>
/// Builds the standard in-memory settings for integration tests, including database connection.
/// </summary>
/// <param name="aliasServerConnectionString">Connection string for <c>AliasServerDbContext</c>.</param>
/// <returns>Dictionary passed to ConfigurationBuilder.AddInMemoryCollection.</returns>
protected Dictionary<string, string?> CreateMemoryConfigurationSettings(string aliasServerConnectionString)
{
var settings = new Dictionary<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = aliasServerConnectionString,
};
AddIntegrationTestConfiguration(settings);
return settings;
}
/// <summary>
/// Creates a new test host builder with test database connection already configured.
/// </summary>
@@ -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<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.AddInMemoryCollection(memorySettings)
.Build();
services.AddSingleton<IConfiguration>(configuration);

View File

@@ -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();
}
/// <summary>
/// The SMTP greeting line must include the configured advertised hostname (same path as production resolver).
/// </summary>
/// <returns>Task.</returns>
[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));
});
}
/// <summary>
/// Tests sending a single email in plain format to the SMTP server with valid claim to check if it is processed correctly.
/// </summary>

View File

@@ -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;
/// </summary>
public class TestHostBuilder : AbstractTestHostBuilder
{
/// <summary>
/// Hostname advertised in SMTP banner / EHLO for integration tests (must match production resolver usage).
/// </summary>
public const string IntegrationAdvertisedHostname = "mail.integration.test";
/// <summary>
/// Builds the SmtpService test host with a provided database connection.
/// </summary>
@@ -29,6 +35,8 @@ public class TestHostBuilder : AbstractTestHostBuilder
/// <returns>IHost.</returns>
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<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.AddInMemoryCollection(memorySettings)
.Build();
services.AddSingleton<IConfiguration>(configuration);
@@ -60,6 +65,8 @@ public class TestHostBuilder : AbstractTestHostBuilder
/// <returns>IHost.</returns>
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
/// <param name="services">The service collection to configure.</param>
private static void ConfigureSmtpServices(IServiceCollection services)
{
services.AddSingleton(new Config
services.AddSingleton(provider =>
{
AllowedToDomains = new List<string> { "example.tld" },
SmtpTlsEnabled = "false",
var configuration = provider.GetRequiredService<IConfiguration>();
return new Config
{
AllowedToDomains = new List<string> { "example.tld" },
SmtpTlsEnabled = "false",
};
});
services.AddTransient<IMessageStore, DatabaseMessageStore>();
services.AddSingleton<SmtpServer>(
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<SmtpServerWorker>();
}
private static string ResolveAdvertisedHostname(
string? environmentValue,
Func<string> 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;
}
}

View File

@@ -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:

View File

@@ -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

View File

@@ -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 "$@"