about summary refs log tree commit diff stats
path: root/modules/by-name/st/stalwart-mail
diff options
context:
space:
mode:
Diffstat (limited to 'modules/by-name/st/stalwart-mail')
-rw-r--r--modules/by-name/st/stalwart-mail/module.nix334
-rw-r--r--modules/by-name/st/stalwart-mail/patches/build-crates-main-Cargo.toml-Activate-appropriate-de.patch26
-rw-r--r--modules/by-name/st/stalwart-mail/patches/fix-crates-directory-Guard-all-enterprise-only-featu.patch86
-rw-r--r--modules/by-name/st/stalwart-mail/settings.nix477
-rw-r--r--modules/by-name/st/stalwart-mail/spam-filter.nix24
5 files changed, 947 insertions, 0 deletions
diff --git a/modules/by-name/st/stalwart-mail/module.nix b/modules/by-name/st/stalwart-mail/module.nix
new file mode 100644
index 0000000..e5a681e
--- /dev/null
+++ b/modules/by-name/st/stalwart-mail/module.nix
@@ -0,0 +1,334 @@
+{
+  lib,
+  config,
+  pkgs,
+  pkgsUnstable,
+  nixLib,
+  ...
+}: let
+  cfg = config.vhack.stalwart-mail;
+  topCfg = config.services.stalwart-mail;
+
+  configFormat = pkgs.formats.toml {};
+  configFile = configFormat.generate "stalwart-mail.toml" topCfg.settings;
+
+  spamfilter = pkgs.callPackage ./spam-filter.nix {};
+
+  stalwart-mail = pkgsUnstable.stalwart-mail.overrideAttrs (final: prev: {
+    passthru = nixLib.warnMerge (prev.passthru or {}) {
+      inherit spamfilter;
+    } "stalwart-mail passthru";
+
+    checkFlags =
+      (prev.checkFlags or [])
+      ++ [
+        # This started to fail?
+        # TODO(@bpeetz): Find out why. <2025-02-08>
+        "--skip=smtp::outbound::lmtp::lmtp_delivery"
+      ];
+
+    # `stalwart-mail` does enable their `enterprise` feature per default.
+    # We want a AGPL only build (i.e., without unfree dependencies), therefore disable the
+    # `enterprise` feature here.
+    # We cannot use the `buildFeatures` attribute because it does not actually change the
+    # correct features. As such we simply patch the correct `Cargo.toml` file.
+    patches =
+      (prev.patches or [])
+      ++ [
+        ./patches/build-crates-main-Cargo.toml-Activate-appropriate-de.patch
+        ./patches/fix-crates-directory-Guard-all-enterprise-only-featu.patch
+      ];
+
+    # Check that the enterprise feature is really disabled.
+    postCheck =
+      (prev.postCheck or "")
+      +
+      # bash
+      ''
+        if grep "enterprise" ./target/*/release/stalwart-mail.d; then
+          echo "ERROR: Proprietary 'enterprise' feature active."
+          exit 1
+        fi
+      '';
+  });
+in {
+  imports = [
+    ./settings.nix
+  ];
+
+  options.vhack.stalwart-mail = {
+    enable = lib.mkEnableOption "starwart-mail";
+
+    # package = lib.mkPackageOption pkgsUnstable "stalwart-mail" {pkgsText = "pkgsUnstable";};
+    package = lib.mkOption {
+      description = "The stalwart-mail package to use";
+      type = lib.types.package;
+      default = stalwart-mail;
+    };
+
+    fqdn = lib.mkOption {
+      type = lib.types.str;
+      example = "mail.foss-syndicate.org";
+      description = ''
+        The fully qualified domain name for this mail server.
+      '';
+    };
+
+    principals = lib.mkOption {
+      type = lib.types.listOf (lib.types.submodule {
+        options = {
+          name = lib.mkOption {
+            type = lib.types.str;
+            description = "Specifies the username of the account";
+          };
+
+          class = lib.mkOption {
+            type = lib.types.enum ["individual" "admin"];
+            description = "Specifies the account type";
+          };
+
+          description = lib.mkOption {
+            type = lib.types.str;
+            description = "Provides a description or full name for the user";
+            default = "";
+          };
+
+          secret = lib.mkOption {
+            type = lib.types.str;
+            description = ''
+              Sets the password for the user account.
+              Passwords can be stored hashed or in plain text (not recommended).
+              See <https://stalw.art/docs/auth/authentication/password/> for a description
+              of password encoding.
+            '';
+          };
+          email = lib.mkOption {
+            type = lib.types.listOf lib.types.str;
+            description = ''
+              A list of email addresses associated with the user.
+              The first address in the list is considered the primary address.
+            '';
+          };
+        };
+      });
+    };
+
+    dataDirectory = lib.mkOption {
+      description = ''
+        The directory in which to store all storage things.
+      '';
+      default = "/var/lib/stalwart-mail";
+      type = lib.types.path;
+      readOnly = true;
+    };
+
+    openFirewall = lib.mkOption {
+      type = lib.types.bool;
+      default = false;
+      description = ''
+        Whether to open TCP firewall ports, which are specified in
+        {option}`services.stalwart-mail.settings.listener` on all interfaces.
+      '';
+    };
+
+    security = lib.mkOption {
+      type = lib.types.nullOr (lib.types.submodule {
+        options = {
+          dkimPrivateKeyPath = lib.mkOption {
+            type = lib.types.path;
+            description = ''
+              The path to the dkim private key agenix file.
+            '';
+          };
+        };
+      });
+      description = ''
+        Security options. This should only be set to `null` when testing.
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    services.stalwart-mail = {
+      # NOTE(@bpeetz): We do not use the nixos service, as it comes with too much
+      # bothersome default configuration and not really any useful config.
+      # However, this decision could obviously be reverse in the future. <2025-02-08>
+      enable = false;
+      inherit (cfg) package;
+      # dataDir = cfg.dataDirectory;
+    };
+
+    security.acme.certs = {
+      "${cfg.fqdn}" = {
+        domain = cfg.fqdn;
+        group = "stalwart-mail";
+      };
+    };
+
+    age.secrets = lib.mkIf (cfg.security != null) {
+      stalwartMailDkim = {
+        file = cfg.security.dkimPrivateKeyPath;
+        mode = "600";
+        owner = "stalwart-mail";
+        group = "stalwart-mail";
+      };
+    };
+
+    vhack.persist.directories = [
+      {
+        directory = "${cfg.dataDirectory}";
+        user = "stalwart-mail";
+        group = "stalwart-mail";
+        mode = "0700";
+      }
+      {
+        directory = "${config.services.redis.servers."stalwart-mail".settings.dir}";
+        user = "stalwart-mail";
+        group = "redis";
+        mode = "0770";
+      }
+    ];
+
+    services.redis = {
+      servers = {
+        "stalwart-mail" = {
+          enable = true;
+
+          user = "stalwart-mail";
+
+          # Disable TCP listening. (We have a UNIX socket)
+          port = 0;
+          bind = null;
+
+          settings = {
+            protected-mode = true;
+            enable-protected-configs = false;
+            enable-debug-command = false;
+            enable-module-command = false;
+
+            supervised = "systemd";
+            stop-writes-on-bgsave-error = true;
+            sanitize-dump-payload = "clients";
+          };
+        };
+      };
+    };
+
+    # This service stores a potentially large amount of data.
+    # Running it as a dynamic user would force chown to be run every time the
+    # service is restarted on a potentially large number of files.
+    # That would cause unnecessary and unwanted delays.
+    users = {
+      groups.stalwart-mail = {};
+      users.stalwart-mail = {
+        isSystemUser = true;
+        group = "stalwart-mail";
+      };
+    };
+
+    systemd.tmpfiles.rules = [
+      "d '${cfg.dataDirectory}' - stalwart-mail stalwart-mail - -"
+    ];
+
+    systemd = {
+      packages = [cfg.package];
+      services.stalwart-mail = {
+        wantedBy = ["multi-user.target"];
+        after = [
+          "local-fs.target"
+          "network.target"
+        ];
+
+        preStart = let
+          esa = lib.strings.escapeShellArg;
+          mkTmpFile = path: "[ -d ${esa path} ] || mkdir --parents ${esa path}";
+
+          # Create the directories for stalwart
+          storageDirectories = lib.lists.filter (v: v != null) (lib.attrsets.mapAttrsToList (_: {path ? null, ...}:
+            if (path != null)
+            then mkTmpFile path
+            else null)
+          topCfg.settings.store);
+        in
+          ''
+            # Stalwart actually wants to store _data_ (e.g., blocked ips) in it's own config file.
+            # Thus we need to make it writable.
+            cat ${esa configFile} >$CACHE_DIRECTORY/mutable_config_file.toml
+          ''
+          + (builtins.concatStringsSep "\n" storageDirectories);
+
+        serviceConfig = {
+          ExecStart = [
+            ""
+            "${cfg.package}/bin/stalwart-mail --config=$CACHE_DIRECTORY/mutable_config_file.toml"
+          ];
+
+          StandardOutput = "journal";
+          StandardError = "journal";
+
+          ReadWritePaths = [
+            cfg.dataDirectory
+          ];
+          CacheDirectory = "stalwart-mail";
+          StateDirectory = "stalwart-mail";
+
+          # Bind standard privileged ports
+          AmbientCapabilities = ["CAP_NET_BIND_SERVICE"];
+          CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"];
+
+          # Hardening
+          DeviceAllow = [""];
+          LockPersonality = true;
+          MemoryDenyWriteExecute = true;
+          PrivateDevices = true;
+          PrivateUsers = false; # incompatible with CAP_NET_BIND_SERVICE
+          ProcSubset = "pid";
+          PrivateTmp = true;
+          ProtectClock = true;
+          ProtectControlGroups = true;
+          ProtectHome = true;
+          ProtectHostname = true;
+          ProtectKernelLogs = true;
+          ProtectKernelModules = true;
+          ProtectKernelTunables = true;
+          ProtectProc = "invisible";
+          ProtectSystem = "strict";
+          RestrictAddressFamilies = [
+            "AF_INET"
+            "AF_INET6"
+            "AF_UNIX"
+          ];
+          RestrictNamespaces = true;
+          RestrictRealtime = true;
+          RestrictSUIDSGID = true;
+          SystemCallArchitectures = "native";
+          SystemCallFilter = [
+            "@system-service"
+            "~@privileged"
+          ];
+          UMask = "0077";
+        };
+        unitConfig.ConditionPathExists = [
+          ""
+          "${configFile}"
+        ];
+      };
+    };
+
+    # Make admin commands available in the shell
+    environment.systemPackages = [cfg.package];
+
+    networking.firewall = let
+      parsePorts = listeners: let
+        parseAddresses = listeners: lib.flatten (lib.mapAttrsToList (name: value: value.bind) listeners);
+        splitAddress = addr: lib.splitString ":" addr;
+        extractPort = addr: lib.toInt (builtins.foldl' (a: b: b) "" (splitAddress addr));
+      in
+        builtins.map extractPort (parseAddresses listeners);
+    in
+      lib.mkIf (cfg.openFirewall && (builtins.hasAttr "listener" topCfg.settings.server))
+      {
+        allowedTCPPorts = parsePorts topCfg.settings.server.listener;
+      };
+  };
+}
diff --git a/modules/by-name/st/stalwart-mail/patches/build-crates-main-Cargo.toml-Activate-appropriate-de.patch b/modules/by-name/st/stalwart-mail/patches/build-crates-main-Cargo.toml-Activate-appropriate-de.patch
new file mode 100644
index 0000000..bfea568
--- /dev/null
+++ b/modules/by-name/st/stalwart-mail/patches/build-crates-main-Cargo.toml-Activate-appropriate-de.patch
@@ -0,0 +1,26 @@
+From 42cbd55c21196bdb57ac9795a938b94a781bb1e9 Mon Sep 17 00:00:00 2001
+From: Benedikt Peetz <benedikt.peetz@b-peetz.de>
+Date: Sun, 9 Feb 2025 00:17:52 +0100
+Subject: [PATCH] build(crates/main/Cargo.toml): Activate appropriate default
+ features
+
+---
+ crates/main/Cargo.toml | 14 +++++++-------
+ 1 file changed, 7 insertions(+), 7 deletions(-)
+
+diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml
+index 036d2dcf..baa320ef 100644
+--- a/crates/main/Cargo.toml
++++ b/crates/main/Cargo.toml
+@@ -35,7 +35,7 @@ jemallocator = "0.5.0"
+ 
+ [features]
+ #default = ["sqlite", "postgres", "mysql", "rocks", "elastic", "s3", "redis", "azure", "enterprise"]
+-default = ["rocks", "enterprise"]
++default = ["rocks", "redis"]
+ sqlite = ["store/sqlite"]
+ foundationdb = ["store/foundation", "common/foundation"]
+ postgres = ["store/postgres"]
+-- 
+2.47.1
+
diff --git a/modules/by-name/st/stalwart-mail/patches/fix-crates-directory-Guard-all-enterprise-only-featu.patch b/modules/by-name/st/stalwart-mail/patches/fix-crates-directory-Guard-all-enterprise-only-featu.patch
new file mode 100644
index 0000000..80c4b60
--- /dev/null
+++ b/modules/by-name/st/stalwart-mail/patches/fix-crates-directory-Guard-all-enterprise-only-featu.patch
@@ -0,0 +1,86 @@
+From 3d20702a481bfa4ecc692cd07a7f1fe0a66bb5d9 Mon Sep 17 00:00:00 2001
+From: Benedikt Peetz <benedikt.peetz@b-peetz.de>
+Date: Sun, 9 Feb 2025 00:38:13 +0100
+Subject: [PATCH] fix(crates/directory): Guard all enterprise only features
+ behind a `enterprise` cfg block
+
+---
+ crates/directory/src/core/config.rs   | 1 +
+ crates/directory/src/core/dispatch.rs | 7 +++++++
+ 2 files changed, 8 insertions(+)
+
+diff --git a/crates/directory/src/core/config.rs b/crates/directory/src/core/config.rs
+index dfb7bf9b..0c3ce96a 100644
+--- a/crates/directory/src/core/config.rs
++++ b/crates/directory/src/core/config.rs
+@@ -84,6 +84,7 @@ impl Directories {
+                 "memory" => MemoryDirectory::from_config(config, prefix, data_store.clone())
+                     .await
+                     .map(DirectoryInner::Memory),
++                #[cfg(feature = "enterprise")]
+                 "oidc" => OpenIdDirectory::from_config(config, prefix, data_store.clone())
+                     .map(DirectoryInner::OpenId),
+                 unknown => {
+diff --git a/crates/directory/src/core/dispatch.rs b/crates/directory/src/core/dispatch.rs
+index a99e54fe..062f29c9 100644
+--- a/crates/directory/src/core/dispatch.rs
++++ b/crates/directory/src/core/dispatch.rs
+@@ -24,6 +24,7 @@ impl Directory {
+             DirectoryInner::Imap(store) => store.query(by).await,
+             DirectoryInner::Smtp(store) => store.query(by).await,
+             DirectoryInner::Memory(store) => store.query(by).await,
++            #[cfg(feature = "enterprise")]
+             DirectoryInner::OpenId(store) => store.query(by, return_member_of).await,
+         }
+         .caused_by(trc::location!())
+@@ -37,6 +38,7 @@ impl Directory {
+             DirectoryInner::Imap(store) => store.email_to_id(address).await,
+             DirectoryInner::Smtp(store) => store.email_to_id(address).await,
+             DirectoryInner::Memory(store) => store.email_to_id(address).await,
++            #[cfg(feature = "enterprise")]
+             DirectoryInner::OpenId(store) => store.email_to_id(address).await,
+         }
+         .caused_by(trc::location!())
+@@ -57,6 +59,7 @@ impl Directory {
+             DirectoryInner::Imap(store) => store.is_local_domain(domain).await,
+             DirectoryInner::Smtp(store) => store.is_local_domain(domain).await,
+             DirectoryInner::Memory(store) => store.is_local_domain(domain).await,
++            #[cfg(feature = "enterprise")]
+             DirectoryInner::OpenId(store) => store.is_local_domain(domain).await,
+         }
+         .caused_by(trc::location!())?;
+@@ -84,6 +87,7 @@ impl Directory {
+             DirectoryInner::Imap(store) => store.rcpt(email).await,
+             DirectoryInner::Smtp(store) => store.rcpt(email).await,
+             DirectoryInner::Memory(store) => store.rcpt(email).await,
++            #[cfg(feature = "enterprise")]
+             DirectoryInner::OpenId(store) => store.rcpt(email).await,
+         }
+         .caused_by(trc::location!())?;
+@@ -104,6 +108,7 @@ impl Directory {
+             DirectoryInner::Imap(store) => store.vrfy(address).await,
+             DirectoryInner::Smtp(store) => store.vrfy(address).await,
+             DirectoryInner::Memory(store) => store.vrfy(address).await,
++            #[cfg(feature = "enterprise")]
+             DirectoryInner::OpenId(store) => store.vrfy(address).await,
+         }
+         .caused_by(trc::location!())
+@@ -117,6 +122,7 @@ impl Directory {
+             DirectoryInner::Imap(store) => store.expn(address).await,
+             DirectoryInner::Smtp(store) => store.expn(address).await,
+             DirectoryInner::Memory(store) => store.expn(address).await,
++            #[cfg(feature = "enterprise")]
+             DirectoryInner::OpenId(store) => store.expn(address).await,
+         }
+         .caused_by(trc::location!())
+@@ -130,6 +136,7 @@ impl Directory {
+             | DirectoryInner::Imap(_)
+             | DirectoryInner::Smtp(_)
+             | DirectoryInner::Memory(_) => false,
++            #[cfg(feature = "enterprise")]
+             DirectoryInner::OpenId(_) => true,
+         }
+     }
+-- 
+2.47.1
+
diff --git a/modules/by-name/st/stalwart-mail/settings.nix b/modules/by-name/st/stalwart-mail/settings.nix
new file mode 100644
index 0000000..0f382ee
--- /dev/null
+++ b/modules/by-name/st/stalwart-mail/settings.nix
@@ -0,0 +1,477 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}: let
+  cfg = config.vhack.stalwart-mail;
+in {
+  config.services.stalwart-mail.settings = lib.mkIf cfg.enable {
+    signature."ed25519" = lib.mkIf (cfg.security != null) {
+      private-key = "%{file:${config.age.secrets.stalwartMailDkim.path}}%";
+      domain = "${cfg.fqdn}";
+      selector = "ed-default";
+      headers = ["From" "To" "Cc" "Date" "Subject" "Message-ID" "Organization" "MIME-Version" "Content-Type" "In-Reply-To" "References" "List-Id" "User-Agent" "Thread-Topic" "Thread-Index"];
+      algorithm = "ed25519-sha256";
+      canonicalization = "relaxed/relaxed"; # TODO: What does this do? <2025-02-07>
+      expire = "50d";
+      report = true;
+    };
+
+    auth = {
+      iprev = {
+        verify = "relaxed";
+      };
+      spf = {
+        verify = {
+          ehlo = "relaxed";
+          mail-from = "relaxed";
+        };
+      };
+      dmarc = {
+        verify = "relaxed";
+      };
+      arc = {
+        seal = lib.mkIf (cfg.security != null) "ed25519";
+        verify = "relaxed";
+      };
+      dkim = {
+        verify = "relaxed";
+
+        # Ignore insecure dkim signed messages (i.e., messages containing both
+        # signed and appended not-signed content.)
+        strict = true;
+
+        sign = lib.mkIf (cfg.security != null) "['ed25519']";
+      };
+    };
+    report = {
+      domain = "${cfg.fqdn}";
+      submitter = "'${cfg.fqdn}'";
+      analysis = {
+        addresses = ["dmarc@*" "abuse@*"];
+        forward = true;
+        store = "30d";
+      };
+      tls.aggregate = {
+        from-name = "'TLS Report'";
+        from-address = "'noreply-tls@${cfg.fqdn}'";
+        org-name = "'Foss Syndicate Mail Handling'";
+        contact-info = "'admin@vhack.eu'";
+        send = "daily";
+        max-size = 26214400; # 25 MiB
+        sign = lib.mkIf (cfg.security != null) "['ed25519']";
+      };
+      dmarc = {
+        aggregate = {
+          from-name = "'DMARC Report'";
+          from-address = "'noreply-dmarc@${cfg.fqdn}'";
+          org-name = "'Foss Syndicate Mail Handling'";
+          contact-info = "'admin@vhack.eu'";
+          send = "weekly";
+          max-size = 26214400; # 25MiB
+          sign = lib.mkIf (cfg.security != null) "['ed25519']";
+        };
+        from-name = "'Report Subsystem'";
+        from-address = "'noreply-dmarc@${cfg.fqdn}'";
+        subject = "'DMARC Authentication Failure Report'";
+        send = "1/1d";
+        sign = lib.mkIf (cfg.security != null) "['ed25519']";
+      };
+      spf = {
+        from-name = "'Report Subsystem'";
+        from-address = "'noreply-spf@${cfg.fqdn}'";
+        subject = "'SPF Authentication Failure Report'";
+        send = "1/1d";
+        sign = lib.mkIf (cfg.security != null) "['ed25519']";
+      };
+      dkim = {
+        from-name = "'Report Subsystem'";
+        from-address = "'noreply-dkim@${cfg.fqdn}'";
+        subject = "'DKIM Authentication Failure Report'";
+        send = "1/1d";
+        sign = lib.mkIf (cfg.security != null) "['ed25519']";
+      };
+      dsn = {
+        from-name = "'Mail Delivery Subsystem'";
+        from-address = "'MAILER-DAEMON@${cfg.fqdn}'";
+        sign = lib.mkIf (cfg.security != null) "['ed25519']";
+      };
+    };
+    queue = {
+      schedule = {
+        retry = "[2m, 5m, 10m, 15m, 30m, 1h, 2h]";
+        notify = "[2h, 7h, 1d, 3d]";
+        expire = "5d";
+      };
+      outbound = {
+        tls = {
+          starttls = "require";
+          allow-invalid-certs = false;
+          ip-strategy = "ipv6_then_ipv4";
+        };
+      };
+    };
+    resolver = {
+      type = "system";
+      preserve-intermediates = true;
+      concurrency = 2;
+      timeout = "5s";
+      attempts = 2;
+      try-tcp-on-error = true;
+      public-suffix = [
+        "file://${pkgs.publicsuffix-list}/share/publicsuffix/public_suffix_list.dat"
+      ];
+    };
+
+    spam-filter = {
+      enable = true;
+      header = {
+        status = {
+          enable = true;
+          name = "X-Spam-Status";
+        };
+        result = {
+          enable = true;
+          name = "X-Spam-Result";
+        };
+      };
+      bayes = {
+        enable = true;
+
+        # Learn from users putting mail into JUNK or taking mail out of it.
+        account = {
+          enable = true;
+        };
+      };
+
+      # Fetch the newest spam-filter rules not from github, but from the nix
+      # package.
+      resource = "file://${cfg.package.passthru.spamfilter}/spam-filter.toml";
+      auto-update = false;
+    };
+
+    webadmin = {
+      # Fetch the newest webadmin bundle not from github, but from the nix
+      # package.
+      resource = "file://${cfg.package.passthru.webadmin}/webadmin.zip";
+      auto-update = false;
+      path = "/var/cache/stalwart-mail";
+    };
+
+    session = {
+      milter = {
+        # TODO: Add this <2025-02-07>
+        # "clamav" = {
+        #   enable = true;
+        #   hostname = "127.0.0.1";
+        #   port = 15112;
+        #   tls = false;
+        #   allow-invalid-certs = false;
+        # };
+      };
+      ehlo = {
+        require = true;
+      };
+      rcpt = {
+        directory = "'in-memory'";
+        catch-all = true;
+        subaddressing = true;
+      };
+      data = {
+        spam-filter = true;
+        add-headers = {
+          received = true;
+          received-spf = true;
+          auth-results = true;
+          message-id = true;
+          date = true;
+          return-path = true;
+          delivered-to = true;
+        };
+        auth = {
+          mechanisms = ["LOGIN" "PLAIN"];
+          directory = "'in-memory'";
+          require = true;
+          must-match-sender = true;
+          errors = {
+            total = 3;
+            wait = "5s";
+          };
+        };
+      };
+      extensions = {
+        pipelining = true;
+        chunking = true;
+        requiretls = true;
+        no-soliciting = "";
+        dsn = [
+          {
+            "if" = "!is_empty(authenticated_as)";
+            "then" = true;
+          }
+          {"else" = false;}
+        ];
+        future-release = [
+          {
+            "if" = "!is_empty(authenticated_as)";
+            "then" = "7d";
+          }
+          {"else" = false;}
+        ];
+        deliver-by = [
+          {
+            "if" = "!is_empty(authenticated_as)";
+            "then" = "15d";
+          }
+          {"else" = false;}
+        ];
+        mt-priority = [
+          {
+            "if" = "!is_empty(authenticated_as)";
+            "then" = "mixer";
+          }
+          {"else" = false;}
+        ];
+        vrfy = [
+          {
+            "if" = "!is_empty(authenticated_as)";
+            "then" = true;
+          }
+          {"else" = false;}
+        ];
+        expn = [
+          {
+            "if" = "!is_empty(authenticated_as)";
+            "then" = true;
+          }
+          {"else" = false;}
+        ];
+      };
+    };
+
+    jmap = {
+      account = {
+        purge.frequency = "0 0 *";
+      };
+      protocol = {
+        changes.max-history = "14d";
+      };
+      email = {
+        # NOTE(@bpeetz): We probably want to enable the auto-deletion of emails in
+        # the "Junk" and "Deleted" items mail folders, but this should be
+        # communicated to the users. <2025-02-07>
+        auto-expunge = false;
+      };
+      mailbox = {
+        max-depth = 50;
+        max-name-length = 255;
+      };
+      folders = let
+        mkFolder = name: {
+          inherit name;
+          create = true;
+          subscribe = true;
+        };
+      in {
+        inbox = mkFolder "inbox";
+        drafts = mkFolder "drafts";
+        sent = mkFolder "sent";
+        trash = mkFolder "trash";
+        archive = mkFolder "archive";
+        junk = mkFolder "junk";
+        shared = {name = "shared";};
+      };
+    };
+    imap = {
+      auth = {
+        # Allow password login over non tls connection
+        allow-plain-text = false;
+      };
+    };
+
+    server = {
+      hostname = cfg.fqdn;
+
+      listener = {
+        # TODO(@bpeetz): Add this <2025-02-08>
+        # # HTTP (used for jmap)
+        # "http" = {
+        #   bind = ["[::]:8080"];
+        #   protocol = "http";
+        #   tls.implicit = true;
+        # };
+
+        # IMAP
+        "imap" = {
+          bind = ["[::]:993"];
+          protocol = "imap";
+          tls.implicit = true;
+        };
+        # SMTP
+        "smtp" = {
+          bind = ["[::]:465"];
+          protocol = "smtp";
+          tls.implicit = true;
+        };
+
+        # # POP3 (should be disabled, unless there is a real reason to use it)
+        # "pop3" = {
+        #   bind = ["[::]:995"];
+        #   protocol = "pop3";
+        #   tls.implicit = true;
+        # };
+
+        # # LMTP
+        # "lmtp" = {
+        #   bind = ["[::]:24"];
+        #   protocol = "lmtp";
+        # };
+
+        # ManageSieve
+        "managesieve" = {
+          bind = ["[::]:4190"];
+          protocol = "managesieve";
+          tls.implicit = true;
+        };
+      };
+
+      tls = {
+        enable = true;
+
+        # Expect the client connection to be encrypted from the start (i.e.,
+        # without STARTTLS)
+        implicit = true;
+
+        certificate = "default";
+      };
+
+      # TODO(@bpeetz): Configure that <2025-02-07>
+      # http = {
+      #   url = "";
+      #   allowed-endpoint = ["404"];
+      # };
+
+      auto-ban = {
+        # Ban if the same IP fails to login 10 times in a day
+        rate = "10/1d";
+
+        # Ban the login for an user account, if different IP-Addresses tried and
+        # failed to login 100 times in single day
+        auth.rate = "100/1d";
+
+        abuse.rate = "35/1d";
+
+        loiter.rate = "150/1d";
+
+        scan.rate = "150/1d";
+      };
+
+      cache = let
+        MiB = 1024 * 1024;
+      in {
+        access-token.size = 10 * MiB;
+        http-auth.size = 1 * MiB;
+        permission.size = 5 * MiB;
+        account.size = 10 * MiB;
+        mailbox.size = 10 * MiB;
+        thread.size = 10 * MiB;
+        bayes.size = 10 * MiB;
+        dns = {
+          txt.size = 5 * MiB;
+          mx.size = 5 * MiB;
+          ptr.size = 1 * MiB;
+          ipv4.size = 5 * MiB;
+          ipv6.size = 5 * MiB;
+          tlsa.size = 1 * MiB;
+          mta-sts.size = 1 * MiB;
+          rbl.size = 5 * MiB;
+        };
+      };
+    };
+
+    tracer = {
+      # NOTE(@bpeetz):
+      # We are using the console logger, because that has nice color output.
+      # Simply using the console should be fine, as systemd pipes that to the journal
+      # either way. <2025-02-08>
+      console = {
+        enable = true;
+        ansi = true;
+        level = "info";
+        type = "console";
+      };
+    };
+
+    store = {
+      "rocksdb-data" = {
+        type = "rocksdb";
+        path = "${cfg.dataDirectory}/storage/data";
+        compression = "lz4";
+
+        # Perform “maintenance” every day at 3 am local time.
+        purge.frequency = "0 3 *";
+      };
+      "rocksdb-full-text-search" = {
+        type = "rocksdb";
+        path = "${cfg.dataDirectory}/storage/full-text-search";
+        compression = "lz4";
+
+        # Perform “maintenance” every day at 2 am local time.
+        purge.frequency = "0 2 *";
+      };
+      "file-system" = {
+        type = "fs";
+        path = "${cfg.dataDirectory}/storage/blobs";
+        depth = 2;
+        compression = "lz4";
+
+        # Perform “maintenance” every day at 5:30 am local time.
+        purge.frequency = "30 5 *";
+      };
+      "redis" = {
+        type = "redis";
+        redis-type = "single";
+        urls = "unix://${config.services.redis.servers."stalwart-mail".unixSocket}";
+        timeout = "10s";
+
+        # Perform “maintenance” every day at 2:30 am local time.
+        purge.frequency = "30 2 *";
+      };
+    };
+    storage = {
+      # PostgreSQL is an option, but this is recommended for single node
+      # configurations.
+      data = "rocksdb-data";
+
+      # We could also re-use the data storage backend for that.
+      blob = "file-system";
+
+      full-text.default-language = "en";
+      fts = "rocksdb-full-text-search";
+
+      directory = "in-memory";
+
+      lookup = "redis";
+
+      # NOTE(@bpeetz): This will encrypt all emails with the users pgp key (if it
+      # can be determined.) This is a wonderful feature, but quite tiresome, if
+      # the user intends to read their email without a their pgp key present (for
+      # example via their smartphone.) <2025-02-07>
+      encryption.enable = false;
+    };
+
+    directory."in-memory" = {
+      type = "memory";
+      inherit (cfg) principals;
+    };
+
+    certificate = {
+      "default" = {
+        cert = "%{file:${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem}%";
+        private-key = "%{file:${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem}%";
+        default = true;
+      };
+    };
+  };
+}
diff --git a/modules/by-name/st/stalwart-mail/spam-filter.nix b/modules/by-name/st/stalwart-mail/spam-filter.nix
new file mode 100644
index 0000000..ce3466d
--- /dev/null
+++ b/modules/by-name/st/stalwart-mail/spam-filter.nix
@@ -0,0 +1,24 @@
+{
+  stdenv,
+  fetchFromGitHub,
+}:
+stdenv.mkDerivation (finalAttrs: {
+  pname = "spam-filter";
+  version = "2.0.2";
+
+  src = fetchFromGitHub {
+    owner = "stalwartlabs";
+    repo = "spam-filter";
+    tag = "v${finalAttrs.version}";
+    hash = "sha256-p2F0bwYBNWeoHjp9csWWaqeOISk9dNQT28OqkhUr7ew=";
+  };
+
+  buildPhase = ''
+    bash ./build.sh
+  '';
+
+  installPhase = ''
+    mkdir --parents "$out"
+    cp ./spam-filter.toml "$out/"
+  '';
+})