{
  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-<report.domain>', 'ed25519-<report.domain>'] (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";
    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 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 - -"
  ];
}
