{ lib, pkgs, unstablePkgs, ... }: let # Caddy's dist dir (see tlsa-updater.nix): cert 0644, key 0640 root:root. # The Stalwart service runs as "stalwart"; grant it read access to the key. certDir = "/var/lib/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu"; # sops-encrypted secrets (single file holds all service secrets), same as backup.nix. secretsFile = ../../secrets/secrets.yaml; # Root-only runtime file holding the fallback-admin password hash (0600 root). adminHashFile = "/var/lib/stalwart/.admin-hash"; # Materialize the fallback-admin password hash from sops into a root-only file. writeAdminHash = pkgs.writeShellScript "stalwart-write-admin-hash" '' set -euo pipefail install -d -o root -g root -m 0755 "$(dirname ${adminHashFile})" ${pkgs.sops}/bin/sops \ --decrypt --extract '["stalwart_admin_hash"]' \ --input-type yaml --output-type yaml ${secretsFile} \ | tr -d '\n' > "${adminHashFile}" chmod 0600 "${adminHashFile}" ''; # The outbound DKIM key is the existing opendkim key from docker-mailserver # (selector "mail", domain severijnse.eu, PKCS#8 RSA 2048). Install it into the # stalwart-owned data dir so the server can read it and it is covered by the # /var/lib/stalwart backup. dkimKeyDir = "/var/lib/stalwart/dkim/severijnse.eu"; dkimKeySrc = "/home/admin/dms/config/opendkim/keys/severijnse.eu/mail.private"; writeDkimKey = pkgs.writeShellScript "stalwart-write-dkim-key" '' set -euo pipefail install -d -o stalwart -g stalwart -m 0750 ${dkimKeyDir} install -o stalwart -g stalwart -m 0640 ${dkimKeySrc} ${dkimKeyDir}/mail.private ''; in { # The hetzner host is built with nixos-24.05, which ships its own # `services.stalwart-mail` module (for the old 0.8.x package). We want the # 0.15.5 module from the locked nixpkgs-unstable instead, so we must exclude # the 24.05 default module (which defines the same option namespace) to avoid # the rename-based infinite recursion, and import the unstable one in its place. disabledModules = [ "services/mail/stalwart-mail.nix" ]; imports = [ "${unstablePkgs.path}/nixos/modules/services/mail/stalwart.nix" ]; services.stalwart = { enable = true; stateVersion = "26.05"; package = unstablePkgs.stalwart; # Public listeners. The host firewall (networking.nix) already allows # 25/143/465/587/993, so openFirewall stays off: the module would also open # every other listener port incl. the 8080 webadmin. openFirewall = false; settings = { # EHLO / hostname for the server (docs server.hostname). server.hostname = "mail.severijnse.eu"; # Public origin the JMAP/webadmin API is served from, advertised in the # JMAP session (docs server/core/network.md "http.url"). Value must be a # JScript expression, hence the single-quoted string literal. Without this # Stalwart advertises http://mail.severijnse.eu:8080, which the browser # refuses to fetch and breaks Bulwark webmail. http.url = "'https://mail.severijnse.eu'"; certificate."mail-severijnse-eu" = { cert = "%{file:${certDir}/mail.severijnse.eu.crt}%"; private-key = "%{file:${certDir}/mail.severijnse.eu.key}%"; # Docs server/tls/certificates.md: used when the client sends no SNI. default = true; }; server.tls = { certificate = "mail-severijnse-eu"; enable = true; implicit = false; }; # Public listeners (docs server/listener.md + protocol, tls.implicit override). # Bind "[::]:port" for dual-stack IPv4+IPv6 (docs: "to bind a listener to # all interfaces"); listing both 0.0.0.0 and [::] makes the [::] bind fail # with EADDRINUSE on kernels with net.ipv6.bindv6only=0. # 143/587 use STARTTLS (server.tls.implicit=false default), 993/465 the # implicit-TLS variants, 25 the plain (STARTTLS) MX port. server.listener = { "imap" = { bind = ["[::]:143"]; protocol = "imap"; }; "imaps" = { bind = ["[::]:993"]; protocol = "imap"; tls.implicit = true; }; "smtp" = { bind = ["[::]:25"]; protocol = "smtp"; }; "smtp-submission" = { bind = ["[::]:587"]; protocol = "smtp"; }; "smtp-submissions" = { bind = ["[::]:465"]; protocol = "smtp"; tls.implicit = true; }; "http-management" = { bind = ["127.0.0.1:8080"]; protocol = "http"; }; }; # Auth per docs mta/inbound/auth.md (AUTH stage): authentication is # disabled on the plain SMTP listener (port 25) and required everywhere # else (IMAP + submission). Only offer PLAIN/LOGIN over TLS, so clear-text # listeners (143/587 pre-STARTTLS) advertise no SASL mechanisms. This # mirrors the code defaults (crates/common/src/config/smtp/session.rs). # The JMAP/webadmin "http-management" listener (bind 127.0.0.1:8080) is # plain HTTP and reachable only from localhost, where bulwark connects; # its basic-auth uses the "plain" mechanism, so it must be exempted from # the is_tls gate or webmail logins fail with "Authentication not allowed". session.auth.mechanisms = [ { "if" = "local_port != 25 && (is_tls || listener == 'http-management')"; "then" = "[plain, login]"; } {"else" = false;} ]; session.auth.directory = [ { "if" = "listener != 'smtp'"; "then" = "'internal'"; } {"else" = false;} ]; session.auth.require = [ { "if" = "listener != 'smtp'"; "then" = true; } {"else" = false;} ]; # Outbound DKIM signing (docs mta/authentication/dkim/sign): sign with the # "mail" signature on everything submitted via non-25 listeners; do not sign # inbound mail received on the plain "smtp" listener. auth.dkim.sign = [ { "if" = "listener != 'smtp'"; "then" = "['mail']"; } {"else" = false;} ]; # ARC sealing uses the same "mail" signature (docs mta/authentication/arc). # The code default ('rsa-' + report.domain) would reference a signature # name that does not exist and log "ARC sealer not found". auth.arc.seal = "'mail'"; # Every *downstream* sign rule defaults to signing with # ['rsa-', 'ed25519-'] (queue.rs/report.rs), # names that do not exist here and log "DKIM signer not found" on DSNs and # reports. Point them all at the real "mail" signature. report.dsn.sign = "['mail']"; report.spf.sign = "['mail']"; report.dmarc.sign = "['mail']"; report.dmarc.aggregate.sign = "['mail']"; report.tls.aggregate.sign = "['mail']"; # Reuse the existing opendkim key (selector mail) so no DNS change is needed. signature.mail = { private-key = "%{file:/var/lib/stalwart/dkim/severijnse.eu/mail.private}%"; domain = "severijnse.eu"; selector = "mail"; headers = ["From" "To" "Date" "Subject" "Message-Id"]; algorithm = "rsa-sha256"; canonicalization = "relaxed/relaxed"; set-body-length = false; }; # Fallback admin (auth/authorization/administrator.md): bootstrap admin with # every permission, used to create the internal-directory accounts via the # management REST API / CLI. Secret is a SHA-512-crypt hash, injected via # LoadCredential (services.stalwart.credentials) so no secret lands in the # Nix store. authentication."fallback-admin" = { user = "admin"; secret = "%{file:/run/credentials/stalwart.service/stalwart-admin}%"; }; # Route docs mta/outbound/routing.md + strategy.md: local domains → local # store, everything else → MX. The strategy names are defined explicitly # (docs define queue.route.mx/local; the built-in fallback in # core.rs:get_route_or_default only kicks in for undeclared names). queue.strategy.route = [ { "if" = "is_local_domain('', rcpt_domain)"; "then" = "'local'"; } {"else" = "'mx'";} ]; queue.route."mx" = { type = "mx"; ip-lookup = "ipv4_then_ipv6"; }; queue.route."local" = { type = "local"; }; }; }; # The module's service runs as user/group "stalwart" (ProtectHome=true, # ProtectSystem=strict). The TLS key tlsa-updater installs is 0640 root:root; # regrant it to the stalwart group after every cert sync so stalwart can serve TLS. systemd.services.stalwart = { after = ["tlsa-update.service" "stalwart-admin-secret.service" "stalwart-dkim.service"]; requires = ["tlsa-update.service" "stalwart-admin-secret.service" "stalwart-dkim.service"]; }; # Make the management CLI available for account creation and maildir import # (docs management/cli/). Version-pinned to the locked unstable nixpkgs. environment.systemPackages = [unstablePkgs.stalwart-cli]; # Materialize the fallback-admin password hash from sops before stalwart starts. # The admin hash is a SHA-512-crypt value, safe to pass through a root-only file. systemd.services.stalwart-admin-secret = { description = "Materialize Stalwart fallback-admin password hash from sops"; wantedBy = ["multi-user.target"]; before = ["stalwart.service"]; # The age key lives in /etc/age/keys.txt; the service must know where it is # and needs a HOME for age to report its user config directory. environment.SOPS_AGE_KEY_FILE = "/etc/age/keys.txt"; serviceConfig = { Type = "oneshot"; Environment = ["HOME=/root"]; ExecStart = "${writeAdminHash}"; }; }; # Install the outbound DKIM key into the stalwart data dir before the service starts. systemd.services.stalwart-dkim = { description = "Install Stalwart outbound DKIM key"; wantedBy = ["multi-user.target"]; before = ["stalwart.service"]; serviceConfig = { Type = "oneshot"; ExecStart = "${writeDkimKey}"; }; }; # LoadCredential: expose the materialized hash to stalwart only at # /run/credentials/stalwart.service/stalwart-admin (see `credentials` option # in the upstream module; the value is the source path on disk). services.stalwart.credentials.stalwart-admin = adminHashFile; systemd.services.stalwart-cert-perm = { description = "Grant stalwart read access to its TLS private key"; # Belt-and-suspenders: tlsa-update already chgrps the key after every sync; # this guarantees the group grant also exists at first boot, before stalwart # starts (previously ordered after stalwart, so a fresh sync could leave a # root:root key and webadmin reload would fail with EACCES). after = ["tlsa-update.service"]; before = ["stalwart.service"]; partOf = ["tlsa-update.service"]; wantedBy = ["multi-user.target"]; path = [pkgs.coreutils]; serviceConfig = { Type = "oneshot"; ExecStart = "${pkgs.coreutils}/bin/chgrp stalwart ${certDir}/mail.severijnse.eu.key"; ExecStartPost = "${pkgs.coreutils}/bin/chmod 0640 ${certDir}/mail.severijnse.eu.key"; }; }; # Bulwark webmail (self-hosted JMAP webmail for Stalwart). Serving on # mail.severijnse.eu behind Caddy (see caddy.nix). It connects to Stalwart's # JMAP endpoint at 127.0.0.1:8080, so uses host networking. Next.js defaults # to POST_SIZE/etc via env; JMAP_SERVER_URL points at the Stalwart http # listener which serves JMAP at /jmap. virtualisation.oci-containers.containers.bulwark = { image = "ghcr.io/bulwarkmail/webmail:latest"; autoStart = true; volumes = [ "/var/lib/bulwark:/app/data:Z" ]; environment = { # JMAP_SERVER_URL is the public origin (the browser uses it verbatim for # /.well-known/jmap + the session apiUrl). HOSTNAME stays 127.0.0.1 so # Next.js binds to loopback (caddy reverse_proxy's 127.0.0.1:3002); binding # to the public hostname made the container unreachable for caddy (502). JMAP_SERVER_URL = "https://mail.severijnse.eu"; HOSTNAME = "127.0.0.1"; PORT = "3002"; }; extraOptions = [ "--network=host" "--label=com.centurylinklabs.watchtower.enable=true" ]; }; systemd.tmpfiles.rules = [ "d /var/lib/bulwark 0755 1001 1001 - -" ]; }