aboutsummaryrefslogtreecommitdiffstats
path: root/modules/vhack/st/stalwart-mail/settings.nix
diff options
context:
space:
mode:
Diffstat (limited to 'modules/vhack/st/stalwart-mail/settings.nix')
-rw-r--r--modules/vhack/st/stalwart-mail/settings.nix552
1 files changed, 552 insertions, 0 deletions
diff --git a/modules/vhack/st/stalwart-mail/settings.nix b/modules/vhack/st/stalwart-mail/settings.nix
new file mode 100644
index 0000000..dfaf63d
--- /dev/null
+++ b/modules/vhack/st/stalwart-mail/settings.nix
@@ -0,0 +1,552 @@
+{
+ 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";
+
+ directory =
+ if cfg.principals == null
+ then "internal"
+ else "in-memory";
+in {
+ config.services.stalwart.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.spam-filter}/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 = "'${directory}'";
+ 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 = "'${directory}'";
+ 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 = {
+ # HTTP (used for jmap)
+ "http" = {
+ bind = ["127.0.0.1:8112"];
+ protocol = "http";
+ # handled by ngnix
+ tls.implicit = false;
+ };
+
+ # 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";
+ };
+
+ http = {
+ url = "protocol + '://' + config_get('server.hostname') + ':' + local_port";
+
+ # We are behind a nginx proxy, and can thus trust this header.
+ use-x-forwarded = true;
+ };
+
+ 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-directory" = lib.mkIf (cfg.principals == null) {
+ type = "rocksdb";
+ path = "${cfg.dataDirectory}/storage/directory";
+ compression = "lz4";
+
+ # Perform “maintenance” every day at 1 am local time.
+ purge.frequency = "0 1 *";
+ };
+ "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 = "${directory}";
+
+ 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" = lib.mkIf (cfg.principals != null) {
+ type = "memory";
+ inherit (cfg) principals;
+ };
+ "internal" = lib.mkIf (cfg.principals == null) {
+ type = "internal";
+ store = "rocksdb-directory";
+ };
+ };
+
+ 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;
+ };
+ };
+ };
+}