Stalwart
Stalwart is an open-source, all-in-one mail server solution that supports JMAP, IMAP4, and SMTP protocols. It's designed to be secure, fast, robust, and scalable, with features like built-in DMARC, DKIM, SPF, and ARC support for message authentication. It also provides strong transport security through DANE, MTA-STS, and SMTP TLS reporting. Stalwart is written in Rust, ensuring high performance and memory safety.
Setup
The following example enables the Stalwart mail server for the domain example.org, listening on mail delivery SMTP/Submission (25, 465), IMAPS (993) and JMAP ports (8080/443) for mail clients to connect to. Mailboxes for the accounts postmaster@example.org and info@example.org get created if they don't exist yet.
Most of the DNS entries are managed by Stalwart including TLS key generation. In this example we configure INWX as domain provider. For supported protocols and hosts see upstream documentation.
# FIXME: Workaround that inwx dns driver sends MX/CNAME/NS record
# content as FQDN with trailing dot, which INWX api rejects.
# Still broken upstream as of dns-update 0.5.8.
nixpkgs.overlays = [
(final: prev: {
stalwart_0_16 = prev.stalwart_0_16.overrideAttrs (oldAttrs: {
cargoDeps = oldAttrs.cargoDeps.overrideAttrs (oldDeps: {
buildCommand = (oldDeps.buildCommand or "") + ''
substituteInPlace "$out/source-registry-0/dns-update-0.5.5/src/providers/inwx.rs" \
--replace-fail 'DnsRecord::CNAME(name) => ("CNAME", name.as_str().into_fqdn().into_owned(), None),' \
'DnsRecord::CNAME(name) => ("CNAME", name.as_str().into_name().into_owned(), None),' \
--replace-fail 'DnsRecord::NS(name) => ("NS", name.as_str().into_fqdn().into_owned(), None),' \
'DnsRecord::NS(name) => ("NS", name.as_str().into_name().into_owned(), None),' \
--replace-fail 'mx.exchange.as_str().into_fqdn().into_owned(),' \
'mx.exchange.as_str().into_name().into_owned(),'
'';
outputHash = "sha256-9AI+YSY85FgZhIxuhmZCUizGEhwYmC+O3iGwu3mv2Nk=";
outputHashAlgo = "sha256";
outputHashMode = "recursive";
});
});
})
];
environment.etc = {
"stalwart-admin-password".text = "mypassword";
"stalwart-inwx-password".text = "myinwxpass";
};
services.stalwart = {
enable = true;
package = pkgs.stalwart_0_16;
openFirewall = true;
admin = {
enable = true;
username = "admin";
};
credentials.admin = "/etc/stalwart-admin-password";
url = "https://mail.example.org";
stateVersion = "26.11";
provision =
let
variant = type: value: { "@type" = type; } // value;
in
{
enable = true;
url = "http://127.0.0.1:8080";
singletons = {
SystemSettings = {
defaultHostname = "mail.example.org";
defaultDomainId = "#main-domain";
};
Http.useXForwarded = true;
MtaSts.mode = "enforce";
};
objects = {
NetworkListener = {
reconcile = true;
match = [ "name" ];
objects = {
listener-mgmt = {
name = "management";
protocol = "http";
bind = [ "[::]:8080" ];
tlsImplicit = false;
};
listener-smtp-relay = {
name = "relay";
protocol = "smtp";
bind = [ "[::]:25" ];
tlsImplicit = false;
};
listener-smtp-submissions = {
name = "submissions";
protocol = "smtp";
bind = [ "[::]:465" ];
tlsImplicit = true;
};
listener-imap = {
name = "imap";
protocol = "imap";
bind = [ "[::]:993" ];
tlsImplicit = true;
};
};
};
DnsServer = {
reconcile = false;
match = null;
objects = {
dns-inwx = variant "Inwx" {
description = "INWX";
username = "myuser";
password = variant "File" {
filePath = "/etc/stalwart-inwx-password";
};
sandbox = false;
};
};
};
AcmeProvider = {
reconcile = false;
match = null;
objects = {
acme-letsencrypt = {
directory = "https://acme-v02.api.letsencrypt.org/directory";
challengeType = "Dns01";
contact = [ "postmaster@example.org" ];
};
};
};
Domain = {
reconcile = true;
match = [ "name" ];
objects = {
main-domain = {
isEnabled = true;
name = "example.org";
catchAllAddress = "all@example.org";
reportAddressUri = "mailto:postmaster@example.org";
subAddressing = variant "Enabled" { };
dnsManagement = variant "Automatic" {
dnsServerId = "#dns-inwx";
publishRecords = [
"mx"
"spf"
"dkim"
"dmarc"
"tlsa"
"srv"
"mtaSts"
"tlsRpt"
"autoConfig"
"autoDiscover"
# FIXME: Currently disabled since the CAA config
# is very strict and would conflict with Caddy ACME
# "caa"
];
};
certificateManagement = variant "Automatic" {
acmeProviderId = "#acme-letsencrypt";
};
dkimManagement = variant "Automatic" (
let
days2millis = days: days * 24 * 60 * 60 * 1000;
in
{
selectorTemplate = "v{version}-{algorithm}-{date-%Y%m%d}";
rotateAfter = days2millis 90;
retireAfter = days2millis 7;
deleteAfter = days2millis 30;
}
);
};
};
};
Account = {
reconcile = false;
match = [
"name"
"domainId"
];
objects = {
user-info = variant "User" {
name = "info";
domainId = "#main-domain";
memberGroupIds = [ "#group-admin" ];
roles = variant "User" { };
credentials = [ (variant "Password" { secret = "mypassword"; }) ];
};
group-admin = variant "Group" {
name = "admin";
domainId = "#main-domain";
description = "Administrators";
roles = variant "Default" { };
aliases = [
{
enabled = true;
name = "postmaster";
domainId = "#main-domain";
}
];
};
};
};
};
};
};
services.caddy = {
enable = true;
openFirewall = true;
# Let's Encrypt account contact
email = "postmaster@example.org";
virtualHosts."mail.example.org" = {
serverAliases = [
"mta-sts.example.org"
"autoconfig.example.org"
"autodiscover.example.org"
"ua-auto-config.example.org"
];
extraConfig = ''
reverse_proxy http://127.0.0.1:8080
'';
};
};
Change the user and password of your DNS provider. The password for the info@ mailbox and admin user is stored in plain-text here for demonstration purpose, please consider using a secret-management tool such as agenix or sops-nix.
DNS records
Following DNS records need to be configured manually since they are not managed by Stalwart.
| Record Type | Name | Value / Target | Notes |
|---|---|---|---|
| A | example.org | IPv4 address of the mail server | Required |
| AAAA | example.org | IPv6 address of the mail server | Required |
| CNAME | example.org | Mail host |
rDNS setup
Configure rDNS in your VPS provider configuration dashbord to the IPv4 and IPv6 addresses, used in the DNS records above.
DNSSEC
Ensure that DNSSEC is enabled for your primary and mail server domain. It can be enabled by your domain provider.
For example, check if DNSSEC is working correctly for your new TLSA record
# nix shell nixpkgs#dnsutils --command delv _25._tcp.mail.example.org TLSA @1.1.1.1 ; fully validated _25._tcp.mail.example.org. 10800 IN TLSA 3 1 1 7f59d873a70e224b184c95a4eb54caa9621e47d48b4a25d312d83d96 e3498238 _25._tcp.mail.example.org. 10800 IN RRSIG TLSA 13 5 10800 20230601000000 20230511000000 39688 example.org. He9VYZ35xTC3fNo8GJa6swPrZodSnjjIWPG6Th2YbsOEKTV1E8eGtJ2A +eyBd9jgG+B3cA/jw8EJHmpvy/buCw==
Running behind reverse proxy
When running behind a load balancer or reverse proxy, Stalwart will not be able to see the "real" sender IP-addresses of incoming mails in case of simple port forwarding. HAProxy or Proxy Protocol solves this problem and should be used on the reverse proxy server to forward SMTP traffic. Stalwart will start parsing the Proxy Protocol packages if correctly configured on the listener.
services.stalwart = {
settings = {
server = {
listener = {
smtp = {
protocol = "smtp";
bind = "[::]:25";
proxy.trusted-networks = [
"10.250.0.1/32"
"fdc9:281f:4d7:9ee9::1/128"
];
};
[...]
};
};
};
};
In this example we set proxy.trusted-networks with an array of the gateway IP-addresses in the smtp listener section.
Configuration
Mail aliases
Considering the configuration above, we could add a mail alias for user1@example.org by simply adding further addresses to the email-array such as user1real@example.org
services.stalwart = {
settings = {
[...]
directory."in-memory" = {
type = "memory";
principals = [
{
class = "individual";
name = "User 1";
secret = "%{file:/run/credentials/stalwart.service/mail-pw1}%";
email = [ "user1@example.org" "user1real@example.org ];
}
];
};
};
};
Blocking mail sender address
If you don't want to receive any mails from a specific address, even not into your spam folder, you can add it to the spam-trap array.
services.stalwart = {
settings = {
lookup = {
spam-trap = {
"malicious_sender1@spamhost.com" = "";
"malicious_sender2@spamhost.com" = "";
};
};
};
Tips and tricks
Sending from subaddresses
Receiving mails to subaddresses like john+secondary@example.org is enabled by default. Sending from subaddresses will fail with "You are not allowed to send from this address" as long as they are not an configured alias address. You can disable this check but it will allow any authenticated user to send from any other address.
services.stalwart = {
settings = {
[...]
session.auth.must-match-sender = false;
};
};
A configuration option to customize the pattern of authorized sender addresses is a planned feature.
Test mail server
You can use several online tools to test your mail server configuration:
- en.internet.nl/test-mail: Test your mail server configuration for validity and security.
- hardenize.com: Test your mail server configuration for validity and security. Checks DANE validity even when not all MX servers support DANE.
- mail-tester.com: Send a mail to this service and get a rating about the "spaminess" of your mail server.
- Send a mail to the echo server
echo@univie.ac.at. You should receive a response containing your message in several seconds.
Unsecure setup for testing environments
The following minimal configuration example is unsecure and for testing purpose only. It will run the Stalwart mail server on localhost, listening on port 143 (IMAP) and 587 (Submission). Users alice and bob are configured with the password foobar.
services.stalwart = {
enable = true;
settings = {
server = {
hostname = "localhost";
tls.enable = false;
listener = {
"smtp-submission" = {
bind = [ "[::]:587" ];
protocol = "smtp";
};
"imap" = {
bind = [ "[::]:143" ];
protocol = "imap";
};
};
};
imap.auth.allow-plain-text = true;
session.auth = {
mechanisms = "[plain, auth]";
directory = "'in-memory'";
};
storage.directory = "in-memory";
session.rcpt.directory = "'in-memory'";
queue.outbound.next-hop = "'local'";
directory."in-memory" = {
type = "memory";
principals = [
{
class = "individual";
name = "alice";
secret = "foobar";
email = [ "alice@localhost" ];
}
{
class = "individual";
name = "bob";
secret = "foobar";
email = [ "bob@$localhost" ];
}
];
};
};
};
See also
- Maddy, a composable, modern mail server written in Go.
- Simple NixOS Mailserver