Jump to content

Stalwart: Difference between revisions

From Official NixOS Wiki
Onny (talk | contribs)
→Tips and tricks: Auto update TLSA
Onny (talk | contribs)
(25 intermediate revisions by 6 users not shown)
Line 1: Line 1:
[https://stalw.art 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.
[https://stalw.art 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.


{{Notice
  |icon=ⓘ
  |color=var(--border-color-warning)
  |background=var(--background-color-warning-subtle)
  |The content of this page are in reference to versions of Stalwart starting from v0.16. For legacy setup and migration instructions please refer follwing [[Stalwart/Legacy_setup]] page.}}
== Setup ==
== Setup ==
The following example enables the Stalwart mail server for the domain ''example.org'', listening on mail delivery SMTP/Submission (<code>25, 465</code>), IMAPS (<code>993</code>) and JMAP ports (8080/443) for mail clients to connect to. Mailboxes for the accounts <code>postmaster@example.org</code> and <code>user1@example.org</code> get created if they don't exist yet.
{{Warning|1=The Stalwart module for version the version >= 0.16 is not yet upstream and will be available in the upcoming NixOS 26.11 release.}}The following example enables the Stalwart mail server for the domain ''example.org'', listening on mail delivery SMTP/Submission (<code>25, 465</code>), IMAPS (<code>993</code>) and JMAP ports (8080/443) for mail clients to connect to. Mailboxes for the accounts <code>postmaster@example.org</code> and <code>info@example.org</code> get created if they don't exist yet.


{{file|/etc/nixos/configuration.nix|nix|3=environment.etc = {
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 [https://github.com/stalwartlabs/dns-update upstream documentation].
  "stalwart/mail-pw1".text = "foobar";
 
  "stalwart/mail-pw2".text = "foobar";
{{file|||3=# FIXME: Workaround that inwx dns driver sends MX/CNAME/NS record
   "stalwart/admin-pw".text = "foobar";
# content as FQDN with trailing dot, which INWX api rejects.
   "stalwart/acme-secret".text = "secret123";
# 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-mail = {
services.stalwart = {
   enable = true;
   enable = true;
  package = pkgs.stalwart_0_16;
   openFirewall = true;
   openFirewall = true;
   settings = {
   admin = {
     server = {
     enable = true;
      hostname = "mx1.example.org";
    username = "admin";
       tls = {
  };
        enable = true;
  credentials.admin = "/etc/stalwart-admin-password";
         implicit = true;
  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";
       };
       };
       listener = {
       objects = {
        smtp = {
        NetworkListener = {
          protocol = "smtp";
          reconcile = true;
          bind = "[::]:25";
          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;
            };
          };
         };
         };
         submissions = {
         DnsServer = {
           bind = "[::]:465";
           reconcile = false;
          protocol = "smtp";
          match = null;
           tls.implicit = true
          objects = {
            dns-inwx = variant "Inwx" {
              description = "INWX";
              username = "myuser";
              password = variant "File" {
                filePath = "/etc/stalwart-inwx-password";
              };
              sandbox = false;
            };
           };
         };
         };
         imaps = {
         AcmeProvider = {
           bind = "[::]:993";
           reconcile = false;
          protocol = "imap";
          match = null;
           tls.implicit = true
          objects = {
            acme-letsencrypt = {
              directory = "https://acme-v02.api.letsencrypt.org/directory";
              challengeType = "Dns01";
              contact = [ "postmaster@example.org" ];
            };
           };
         };
         };
         jmap = {
         Domain = {
           bind = "[::]:8080";
           reconcile = true;
          url = "https://mail.example.org";
          match = [ "name" ];
          protocol = "http";
          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;
                }
              );
            };
          };
         };
         };
         management = {
         Account = {
           bind = [ "127.0.0.1:8080" ];
           reconcile = false;
           protocol = "http";
          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";
                }
              ];
            };
          };
         };
         };
       };
       };
     };
     };
    lookup.default = {
      hostname = "mx1.example.org";
      domain = "example.org";
    };
    acme."letsencrypt" = {
      directory = "https://acme-v02.api.letsencrypt.org/directory";
      challenge = "dns-01";
      contact = "user1@example.org";
      domains = [ "example.org" "mx1.example.org" ];
      provider = "cloudflare";
      secret = "%{file:/etc/stalwart/acme-secret}%";
    };
    session.auth = {
      mechanisms = "[plain]";
      directory = "'in-memory'";
    };
    storage.directory = "in-memory";
    session.rcpt.directory = "'in-memory'";
    directory."imap".lookup.domains = [ "example.org" ];
    directory."in-memory" = {
      type = "memory";
      principals = [
        {
          class = "individual";
          name = "User 1";
          secret = "%{file:/etc/stalwart/mail-pw1}%";
          email = [ "user1@example.org" ];
        }
        {
          class = "individual";
          name = "postmaster";
          secret = "%{file:/etc/stalwart/mail-pw1}%";
          email = [ "postmaster@example.org" ];
        }
      ];
    };
    authentication.fallback-admin = {
      user = "admin";
      secret = "%{file:/etc/stalwart/admin-pw}%";
    };
  };
};
};


services.caddy = {
services.caddy = {
   enable = true;
   enable = true;
   virtualHosts = {
   openFirewall = true;
    "webadmin.example.org" = {
  # Let's Encrypt account contact
      extraConfig = ''
  email = "postmaster@example.org";
        reverse_proxy http://127.0.0.1:8080
  virtualHosts."mail.example.org" = {
      '';
    serverAliases = [
      serverAliases = [
      "mta-sts.example.org"
        "mta-sts.example.org"
      "autoconfig.example.org"
        "autoconfig.example.org"
      "autodiscover.example.org"
        "autodiscover.example.org"
      "ua-auto-config.example.org"
        "mail.example.org"
    ];
      ];
     extraConfig = ''
     };
      reverse_proxy http://127.0.0.1:8080
    '';
   };
   };
};}}
};|name=/etc/nixos/configuration.nix|lang=nix}}


TLS key generation is done using DNS-01 challenge through Cloudflare domain provider, see dns-update library for [https://github.com/stalwartlabs/dns-update further providers] or configure [https://stalw.art/docs/server/tls/certificates manual certificates].
Change the user and password of your DNS provider. The password for the <code>info@</code> mailbox and admin user is stored in plain-text here for demonstration purpose, please consider using a secret-management tool [[Comparison of secret managing schemes|such as agenix or sops-nix]].


=== DNS records ===
=== DNS records ===
Before adding required records to the example domain <code>example.org</code>, we need to register the domain on the Stalwart server.<syntaxhighlight lang="shell">
Following DNS records need to be configured manually since they are not managed by Stalwart.
stalwart-cli --url https://webadmin.example.org domain create example.org
</syntaxhighlight>Authenticate using the fallback-admin password.
 
Review the list of which DNS records are required including their values for the mail server to work at https://webadmin.example.org/manage/directory/domains/tuxtux.com.co/view. Especially following records are essential:
 
{| class="wikitable"
{| class="wikitable"
! Record Type
! Record Type
Line 131: Line 237:
| ''IPv6 address of the mail server''
| ''IPv6 address of the mail server''
| Required
| Required
|-
| CNAME
| autoconfig
| example.org
| Mail client autoconfiguration
|-
| CNAME
| autodiscover
| example.org
| Outlook / Exchange compatibility
|-
|-
| CNAME
| CNAME
Line 146: Line 242:
| example.org
| example.org
| Mail host
| Mail host
|-
| CNAME
| mta-sts
| example.org
| MTA-STS
|-
| CNAME
| webadmin
| example.org
| Stalwart web administration interface
|-
| MX
| example.org
| mx1.example.org
| Mail delivery
|-
| SRV
| _imaps._tcp
| ''See Web Admin for exact values''
| IMAPS service
|-
| SRV
| _submissions._tcp
| ''See Web Admin for exact values''
| SMTP Submission service
|-
| TLSA
| _25._tcp.example.org.
| 3 1 1 …
| Only the record starting with <code>3 1 1</code> is required
|-
| TLSA
| _25._tcp.mx1.example.org.
| 3 1 1 …
| Only the record starting with <code>3 1 1</code> is required
|-
| TXT
| 202409e._domainkey
| ''DKIM public key''
| DKIM
|-
| TXT
| 202409r._domainkey
| ''DKIM public key''
| DKIM
|-
| TXT
| _dmarc
| ''DMARC policy''
| DMARC
|-
| TXT
| mx1
| ''SPF or server information''
| Depends on configuration
|-
| TXT
| _smtp._tls
| ''MTA-STS policy''
| SMTP TLS reporting
|-
| TXT
| example.org
| ''SPF record''
| SPF
|}
|}
=== rDNS setup ===
Configure rDNS in your VPS provider configuration dashbord to the IPv4 and IPv6 addresses, used in the DNS records above.


=== DNSSEC ===
=== DNSSEC ===
Line 217: Line 251:


For example, check if DNSSEC is working correctly for your new TLSA record
For example, check if DNSSEC is working correctly for your new TLSA record
  # nix shell nixpkgs#dnsutils --command delv _25._tcp.mx1.example.org TLSA @1.1.1.1
  # nix shell nixpkgs#dnsutils --command delv _25._tcp.mail.example.org TLSA @1.1.1.1
  ; fully validated
  ; fully validated
  _25._tcp.mx1.example.org. 10800 IN TLSA 3 1 1 7f59d873a70e224b184c95a4eb54caa9621e47d48b4a25d312d83d96 e3498238
  _25._tcp.mail.example.org. 10800 IN TLSA 3 1 1 7f59d873a70e224b184c95a4eb54caa9621e47d48b4a25d312d83d96 e3498238
  _25._tcp.mx1.example.org. 10800 IN RRSIG TLSA 13 5 10800 20230601000000 20230511000000 39688 example.org. He9VYZ35xTC3fNo8GJa6swPrZodSnjjIWPG6Th2YbsOEKTV1E8eGtJ2A +eyBd9jgG+B3cA/jw8EJHmpvy/buCw==
  _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.{{file|||3=services.stalwart = {
  settings = {
    server = {
      listener = {
        smtp = {
          protocol = "smtp";
          bind = "[::]:25";
          proxy.trusted-networks = [
            "10.250.0.1/32"
            "fdc9:281f:4d7:9ee9::1/128"
          ];
        };
        [...]
      };
    };
  };
};|name=/etc/nixos/configuration.nix|lang=nix}}In this example we set <code>proxy.trusted-networks</code> with an array of the gateway IP-addresses in the <code>smtp</code> listener section.


== Configuration ==
== Configuration ==
Line 229: Line 281:
Considering the configuration above, we could add a mail alias for <code>user1@example.org</code> by simply adding further addresses to the <code>email</code>-array such as <code>user1real@example.org</code>
Considering the configuration above, we could add a mail alias for <code>user1@example.org</code> by simply adding further addresses to the <code>email</code>-array such as <code>user1real@example.org</code>


{{file|/etc/nixos/configuration.nix|nix|3=services.stalwart-mail = {
{{file|||3=services.stalwart = {
   settings = {
   settings = {
     [...]
     [...]
Line 238: Line 290:
           class = "individual";
           class = "individual";
           name = "User 1";
           name = "User 1";
           secret = "%{file:/etc/stalwart/mail-pw1}%";
           secret = "%{file:/run/credentials/stalwart.service/mail-pw1}%";
           email = [ "user1@example.org" "user1real@example.org ];
           email = [ "user1@example.org" "user1real@example.org ];
         }
         }
Line 244: Line 296:
     };
     };
   };
   };
};}}
};|name=/etc/nixos/configuration.nix|lang=nix}}
=== 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.{{file|||3=services.stalwart = {
  settings = {
    lookup = {
      spam-trap = {
        "malicious_sender1@spamhost.com" = "";
        "malicious_sender2@spamhost.com" = "";
      };
  };
};|name=/etc/nixos/configuration.nix|lang=nix}}


== Tips and tricks ==
== Tips and tricks ==


=== Auto update TLSA records ===
=== Sending from subaddresses ===


Stalwart [https://github.com/stalwartlabs/stalwart/issues/1664 does not yet] automatically update the TLSA record if your ACME certificate changes.
Receiving mails to subaddresses like <code>john+secondary@example.org</code> 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.


Following script is a possible workaounrd. It extracts the ACME cert every five minute, calculates the TLSA hash and compares it with the upstream record. If it doesn't match, it uses [https://github.com/Stenstromen/gotlsaflare gotlsaflare] to update the TLSA record on Cloudflare.
{{file|||3=services.stalwart = {
 
   settings = {
{{file|/etc/nixos/configuration.nix|nix|3=
    [...]
systemd.services.tlsa-cloudflare-update = {
     session.auth.must-match-sender = false;
   description = "Check and update TLSA/DANE record for mx1 from Stalwart ACME Cert";
 
  after = [
    "network-online.target"
    "stalwart-mail.service"
  ];
  wants = [
    "network-online.target"
    "stalwart-mail.service"
  ];
 
  serviceConfig = {
     Type = "oneshot";
    User = "stalwart-mail";
    Group = "stalwart-mail";
    EnvironmentFile = config.age.secrets.stalwart-mail-cloudflare-secret.path;
    RuntimeDirectory = "stalwart-tlsa";
   };
   };
};|name=/etc/nixos/configuration.nix|lang=nix}}


  path = with pkgs; [
A configuration option to customize the pattern of authorized sender addresses is a [https://github.com/stalwartlabs/stalwart/issues/394#issuecomment-3705990056 planned feature].
    bash
    coreutils
    openssl
    dnsutils
    gotlsaflare
    rocksdb.tools
    gawk
  ];
 
  script = ''
    set -eu
 
    DOMAIN="example.org"
    SUBDOMAIN="mail"
    PORT="25"
    ACME_PROVIDER_ID="cloudflare"
    TLSA_RECORD="_$PORT._tcp.$SUBDOMAIN.$DOMAIN"
    DB_PATH="/var/lib/stalwart-mail/db"
    TEMP_RAW="/run/stalwart-tlsa/cert.bundle"
    TEMP_CRT="/run/stalwart-tlsa/cert.crt"
 
    echo "Starting TLSA update process for $DOMAIN"
 
    ldb --db="$DB_PATH" --column_family=s get "acme.$ACME_PROVIDER_ID.cert" | base64 -d > "$TEMP_RAW"
 
    if [ ! -s "$TEMP_RAW" ]; then
      echo "ERROR: ACME certificate extraction failed"
      exit 1
    fi
 
    openssl x509 -in "$TEMP_RAW" -out "$TEMP_CRT"
 
    LOCAL_HASH=$(openssl x509 -in "$TEMP_CRT" -pubkey -noout | openssl pkey -pubin -outform DER | openssl sha256 | awk '{print tolower($2)}')
    echo "Local hash: $LOCAL_HASH"
 
    UPSTREAM_HASH=$(dig +nosplit +short TLSA "$TLSA_RECORD" | awk '{print tolower($4)}' | head -n1)
    echo "Upstream hash: $UPSTREAM_HASH"
 
    if [ "$LOCAL_HASH" = "$UPSTREAM_HASH" ]; then
      echo "Hashes match. DNS is up to date."
      exit 0
    fi
 
    echo "Hashes differ! Updating Cloudflare..."
    gotlsaflare update \
      --url "$DOMAIN" \
      --subdomain "$SUBDOMAIN" \
      --tcp"$PORT" \
      --cert "$TEMP_CRT"
 
    echo "TLSA update completed successfully."
  '';
};
 
systemd.timers.tlsa-cloudflare-update = {
  description = "Run TLSA check and update every 5 minutes";
  wantedBy = [ "timers.target" ];
  timerConfig = {
    OnBootSec = "2m";
    OnUnitActiveSec = "5m";
    Unit = "tlsa-cloudflare-update.service";
  };
};
}}
 
 
=== Test mail server ===
=== Test mail server ===
You can use several online tools to test your mail server configuration:
You can use several online tools to test your mail server configuration:


* [https://en.internet.nl/test-mail en.internet.nl/test-mail]: Test your mail server configuration for validity and security.
* [https://en.internet.nl/test-mail en.internet.nl/test-mail]: Test your mail server configuration for validity and security.
* [https://www.hardenize.com/ hardenize.com]: Test your mail server configuration for validity and security. Checks DANE validity even when not all MX servers support DANE.
* [https://www.mail-tester.com mail-tester.com]: Send a mail to this service and get a rating about the "spaminess" of your mail server.
* [https://www.mail-tester.com 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 <code>echo@univie.ac.at</code>. You should receive a response containing your message in several seconds.
* Send a mail to the echo server <code>echo@univie.ac.at</code>. You should receive a response containing your message in several seconds.


=== Unsecure setup for testing environments ===
=== 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 <code>localhost</code>, listening on port <code>143</code> (IMAP) and <code>587</code> (Submission). Users <code>alice</code> and <code>bob</code> are configured with the password <code>foobar</code>.{{file|/etc/nixos/configuration.nix|nix|3=services.stalwart-mail = {
The following minimal configuration example is unsecure and for testing purpose only. It will run the Stalwart mail server on <code>localhost</code>, listening on port <code>143</code> (IMAP) and <code>587</code> (Submission). Users <code>alice@localhost.localdomain</code> and <code>bob@localhost.localdomain</code> are configured with the password <code>Dev-Mail-Test-2026!</code>.{{file|||3=environment.etc."stalwart-admin-password".text = "test123";
 
services.stalwart = {
   enable = true;
   enable = true;
   settings = {
   package = pkgs.stalwart_0_16;
     server = {
  openFirewall = true;
       hostname = "localhost";
  url = "http://localhost:8080";
       tls.enable = false;
  stateVersion = "26.05";
       listener = {
  admin = {
         "smtp-submission" = {
     enable = true;
           bind = [ "[::]:587" ];
    username = "admin";
           protocol = "smtp";
  };
  credentials.admin = "/etc/stalwart-admin-password";
  provision =
    let
      variant = type: value: { "@type" = type; } // value;
      # Expression as { match, else }.
       expr = else_: {
        match = [ ];
        "else" = else_;
       };
    in
    {
      enable = true;
      package = pkgs..stalwart-cli;
      # Reload action currently broken
      reloadSettings = false;
       url = "http://127.0.0.1:8080";
      singletons = {
         SystemSettings = {
          defaultHostname = "localhost.localdomain";
          defaultDomainId = "#main-domain";
        };
        Imap.allowPlainTextAuth = true;
        Authentication = {
          passwordMinLength = 4;
          passwordMinStrength = "zero";
        };
        MtaStageAuth = {
          saslMechanisms = expr "[plain, login]";
          mustMatchSender = expr "false";
        };
      };
      objects = {
        NetworkListener = {
           reconcile = true;
          match = [ "name" ];
          objects = {
            listener-mgmt = {
              name = "management";
              protocol = "http";
              bind = [ "[::]:8080" ];
              tlsImplicit = false;
            };
            listener-imap = {
              name = "imap";
              protocol = "imap";
              bind = [ "[::]:143" ];
              tlsImplicit = false;
            };
            listener-smtp = {
              name = "submission";
              protocol = "smtp";
              bind = [ "[::]:587" ];
              tlsImplicit = false;
            };
          };
        };
        Domain = {
          reconcile = true;
          match = [ "name" ];
           objects.main-domain = {
            isEnabled = true;
            name = "localhost.localdomain";
            subAddressing = variant "Enabled" { };
            dnsManagement = variant "Manual" { };
            certificateManagement = variant "Manual" { };
            dkimManagement = variant "Manual" { };
          };
         };
         };
         "imap" = {
         Account = {
          bind = [ "[::]:143" ];
          reconcile = false;
          protocol = "imap";
          match = [
            "name"
            "domainId"
          ];
          objects = {
            user-bob = variant "User" {
              name = "bob";
              domainId = "#main-domain";
              roles = variant "User" { };
              permissions = variant "Inherit" { };
              encryptionAtRest = variant "Disabled" { };
              quotas = { };
              aliases = [ ];
              credentials = [ (variant "Password" { secret = "Dev-Mail-Test-2026!"; }) ];
            };
            user-alice = variant "User" {
              name = "alice";
              domainId = "#main-domain";
              roles = variant "User" { };
              permissions = variant "Inherit" { };
              encryptionAtRest = variant "Disabled" { };
              quotas = { };
              aliases = [ ];
              credentials = [ (variant "Password" { secret = "Dev-Mail-Test-2026!"; }) ];
            };
          };
         };
         };
       };
       };
     };
     };
    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" ];
        }
      ];
    };
   };
   };
};}}
 
# First boot only: bind provisioned listeners (needs restart).
systemd.services.stalwart-activate-listeners = {
  after = [ "stalwart-provision.service" ];
  wantedBy = [ "multi-user.target" ];
  unitConfig.ConditionPathExists = "!${config.services.stalwart.dataDir}/.listeners-active";
  serviceConfig = {
    Type = "oneshot";
    ExecStart = "${config.systemd.package}/bin/systemctl restart stalwart.service";
    ExecStartPost = "${pkgs.coreutils}/bin/touch ${config.services.stalwart.dataDir}/.listeners-active";
  };
};|name=/etc/nixos/configuration.nix|lang=nix}}


== See also ==
== See also ==

Revision as of 09:52, 3 October 2026

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.

ⓘ︎
The content of this page are in reference to versions of Stalwart starting from v0.16. For legacy setup and migration instructions please refer follwing Stalwart/Legacy_setup page.

Setup

⚠︎
Warning: The Stalwart module for version the version >= 0.16 is not yet upstream and will be available in the upcoming NixOS 26.11 release.

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.

❄︎ /etc/nixos/configuration.nix
# 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 mail 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.

❄︎ /etc/nixos/configuration.nix
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

❄︎ /etc/nixos/configuration.nix
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.

❄︎ /etc/nixos/configuration.nix
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.

❄︎ /etc/nixos/configuration.nix
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@localhost.localdomain and bob@localhost.localdomain are configured with the password Dev-Mail-Test-2026!.

❄︎ /etc/nixos/configuration.nix
environment.etc."stalwart-admin-password".text = "test123";

services.stalwart = {
  enable = true;
  package = pkgs.stalwart_0_16;
  openFirewall = true;
  url = "http://localhost:8080";
  stateVersion = "26.05";
  admin = {
    enable = true;
    username = "admin";
  };
  credentials.admin = "/etc/stalwart-admin-password";
  provision =
    let
      variant = type: value: { "@type" = type; } // value;
      # Expression as { match, else }.
      expr = else_: {
        match = [ ];
        "else" = else_;
      };
    in
    {
      enable = true;
      package = pkgs..stalwart-cli;
      # Reload action currently broken
      reloadSettings = false;
      url = "http://127.0.0.1:8080";
      singletons = {
        SystemSettings = {
          defaultHostname = "localhost.localdomain";
          defaultDomainId = "#main-domain";
        };
        Imap.allowPlainTextAuth = true;
        Authentication = {
          passwordMinLength = 4;
          passwordMinStrength = "zero";
        };
        MtaStageAuth = {
          saslMechanisms = expr "[plain, login]";
          mustMatchSender = expr "false";
        };
      };
      objects = {
        NetworkListener = {
          reconcile = true;
          match = [ "name" ];
          objects = {
            listener-mgmt = {
              name = "management";
              protocol = "http";
              bind = [ "[::]:8080" ];
              tlsImplicit = false;
            };
            listener-imap = {
              name = "imap";
              protocol = "imap";
              bind = [ "[::]:143" ];
              tlsImplicit = false;
            };
            listener-smtp = {
              name = "submission";
              protocol = "smtp";
              bind = [ "[::]:587" ];
              tlsImplicit = false;
            };
          };
        };
        Domain = {
          reconcile = true;
          match = [ "name" ];
          objects.main-domain = {
            isEnabled = true;
            name = "localhost.localdomain";
            subAddressing = variant "Enabled" { };
            dnsManagement = variant "Manual" { };
            certificateManagement = variant "Manual" { };
            dkimManagement = variant "Manual" { };
          };
        };
        Account = {
          reconcile = false;
          match = [
            "name"
            "domainId"
          ];
          objects = {
            user-bob = variant "User" {
              name = "bob";
              domainId = "#main-domain";
              roles = variant "User" { };
              permissions = variant "Inherit" { };
              encryptionAtRest = variant "Disabled" { };
              quotas = { };
              aliases = [ ];
              credentials = [ (variant "Password" { secret = "Dev-Mail-Test-2026!"; }) ];
            };
            user-alice = variant "User" {
              name = "alice";
              domainId = "#main-domain";
              roles = variant "User" { };
              permissions = variant "Inherit" { };
              encryptionAtRest = variant "Disabled" { };
              quotas = { };
              aliases = [ ];
              credentials = [ (variant "Password" { secret = "Dev-Mail-Test-2026!"; }) ];
            };
          };
        };
      };
    };
  };

# First boot only: bind provisioned listeners (needs restart).
systemd.services.stalwart-activate-listeners = {
  after = [ "stalwart-provision.service" ];
  wantedBy = [ "multi-user.target" ];
  unitConfig.ConditionPathExists = "!${config.services.stalwart.dataDir}/.listeners-active";
  serviceConfig = {
    Type = "oneshot";
    ExecStart = "${config.systemd.package}/bin/systemctl restart stalwart.service";
    ExecStartPost = "${pkgs.coreutils}/bin/touch ${config.services.stalwart.dataDir}/.listeners-active";
  };
};

See also