{ 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}" ''; 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; # Temporary internal listeners while docker-mailserver still owns 25/143/465/587/993. openFirewall = false; settings = { # EHLO / hostname for the server (docs server.hostname). server.hostname = "mail.severijnse.eu"; certificate."mail-severijnse-eu" = { cert = "%{file:${certDir}/mail.severijnse.eu.crt}%"; private-key = "%{file:${certDir}/mail.severijnse.eu.key}%"; }; server.tls = { certificate = "mail-severijnse-eu"; enable = true; implicit = false; }; # Temporary internal listeners (docs server/listener.md + protocol, tls.implicit override). server.listener = { "imap" = { bind = ["127.0.0.1:1143"]; protocol = "imap"; }; "smtp-submission" = { bind = ["127.0.0.1:1587"]; protocol = "smtp"; }; "smtp-submissions" = { bind = ["127.0.0.1:1465"]; protocol = "smtp"; tls.implicit = true; }; "http-management" = { bind = ["127.0.0.1:8080"]; protocol = "http"; }; }; # Auth per inbound/auth.md: not required on the plain SMTP listener (port 25), # required everywhere else (IMAP + submission). Directory is the module default "internal". session.auth.mechanisms = "[plain]"; session.auth.directory = "'internal'"; session.auth.require = [ { "if" = "listener != 'smtp'"; "then" = true; } {"else" = 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 routing: /strategy.md + /routing.md: # local domains → local store, everything else → MX. local/mx are built-in. queue.strategy.route = [ { "if" = "is_local_domain('', rcpt_domain)"; "then" = "'local'"; } {"else" = "'mx'";} ]; }; }; # 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"]; requires = ["tlsa-update.service" "stalwart-admin-secret.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}"; }; }; # 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"; after = ["tlsa-update.service" "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 = "http://127.0.0.1:8080"; 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 - -" ]; }