{ config, lib, pkgs, ... }: let cfg = config.vhack.stalwart-mail; signaturesByDomain = (builtins.map ({name, ...}: { "if" = "sender_domain = '${name}'"; "then" = "'${name}'"; }) (lib.attrsToList cfg.security.dkimKeys)) ++ [{"else" = false;}]; maybeVerificationMode = if cfg.security != null then cfg.security.verificationMode else "disable"; in { config.services.stalwart-mail.settings = lib.mkIf cfg.enable { # https://www.rfc-editor.org/rfc/rfc6376.html#section-3.3 signature = let signatures = lib.mapAttrs (keyDomain: keyConfig: { private-key = "%{file:${config.age.secrets."stalwartMail${keyDomain}".path}}%"; domain = keyDomain; selector = "mail"; 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 = keyConfig.keyAlgorithm; # How do we canonicalize the headers/body? # https://www.rfc-editor.org/rfc/rfc6376.html#section-3.4 canonicalization = "simple/simple"; expire = "50d"; report = true; }) cfg.security.dkimKeys; in lib.mkIf (cfg.security != null) signatures; auth = let # NOTE(@bpeetz): We disable all the checks if the `listener` is submissions, because the # user's email client will obviously not have the right IP address to pass SPF or # IPREV. It will also not be able to sign the message with DKIM (as we has to key). <2025-02-25> ifNotSmpt = valueTrue: valueFalse: [ { "if" = "listener != 'submissions'"; "then" = valueTrue; } {"else" = valueFalse;} ]; in { iprev = { verify = ifNotSmpt maybeVerificationMode "disable"; }; spf = { verify = { ehlo = ifNotSmpt maybeVerificationMode "disable"; mail-from = ifNotSmpt maybeVerificationMode "disable"; }; }; dmarc = { verify = ifNotSmpt maybeVerificationMode "disable"; }; arc = { seal = lib.mkIf (cfg.security != null) signaturesByDomain; verify = ifNotSmpt maybeVerificationMode "disable"; }; dkim = { verify = ifNotSmpt maybeVerificationMode "disable"; # Ignore insecure dkim signed messages (i.e., messages containing both # signed and appended not-signed content.) strict = true; sign = lib.mkIf (cfg.security != null) signaturesByDomain; }; }; 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 = "'${cfg.admin}'"; send = "daily"; max-size = 26214400; # 25 MiB sign = lib.mkIf (cfg.security != null) "'${cfg.fqdn}'"; }; dmarc = { aggregate = { from-name = "'DMARC Report'"; from-address = "'noreply-dmarc@${cfg.fqdn}'"; org-name = "'Foss Syndicate Mail Handling'"; contact-info = "'${cfg.admin}'"; send = "weekly"; max-size = 26214400; # 25MiB sign = lib.mkIf (cfg.security != null) "'${cfg.fqdn}'"; }; from-name = "'Report Subsystem'"; from-address = "'noreply-dmarc@${cfg.fqdn}'"; subject = "'DMARC Authentication Failure Report'"; send = "1/1d"; sign = lib.mkIf (cfg.security != null) signaturesByDomain; }; 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) signaturesByDomain; }; 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) signaturesByDomain; }; dsn = { from-name = "'Mail Delivery Subsystem'"; from-address = "'MAILER-DAEMON@${cfg.fqdn}'"; sign = lib.mkIf (cfg.security != null) signaturesByDomain; }; }; queue = { schedule = { retry = "[2m, 5m, 10m, 15m, 30m, 1h, 2h]"; notify = "[2h, 7h, 1d, 3d]"; expire = "5d"; }; outbound = { tls = { starttls = if maybeVerificationMode == "strict" then "require" else "optional"; allow-invalid-certs = false; ip-strategy = "ipv6_then_ipv4"; mta-sts = if maybeVerificationMode == "strict" then "require" else "optional"; }; }; }; 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 "submissions" = { bind = ["[::]:465"]; protocol = "smtp"; tls.implicit = true; }; "input" = { bind = ["[::]:25"]; protocol = "smtp"; tls = { enable = true; # Require an explicit `STARTTLS` implicit = false; }; }; # # 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; }; }; }; }