aboutsummaryrefslogtreecommitdiffstats
path: root/modules
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-02-09 16:24:51 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-03-09 13:44:14 +0100
commitf7ff6a320e216da1b52e335599e42a86106cf908 (patch)
tree8b9dfed4f281c299a31a52c10e676c38955fc8b9 /modules
parentscripts/test_build.sh: Init (diff)
downloadnixos-server-f7ff6a320e216da1b52e335599e42a86106cf908.zip
module/stalwart-mail: Init initial version
Diffstat (limited to '')
-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/"
+ '';
+})