aboutsummaryrefslogtreecommitdiffstats
path: root/modules/by-name/st
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--modules/by-name/st/stalwart-mail/module.nix428
-rw-r--r--modules/by-name/st/stalwart-mail/settings.nix552
2 files changed, 980 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..f7e44d8
--- /dev/null
+++ b/modules/by-name/st/stalwart-mail/module.nix
@@ -0,0 +1,428 @@
+{
+ lib,
+ config,
+ pkgs,
+ vhackPackages,
+ ...
+}: let
+ cfg = config.vhack.stalwart-mail;
+ topCfg = config.services.stalwart-mail;
+
+ configFormat = pkgs.formats.toml {};
+ configFile = configFormat.generate "stalwart-mail.toml" topCfg.settings;
+in {
+ imports = [
+ ./settings.nix
+ ];
+
+ options.vhack.stalwart-mail = {
+ enable = lib.mkEnableOption "starwart-mail";
+
+ package = lib.mkPackageOption pkgs "stalwart-mail" {};
+
+ admin = lib.mkOption {
+ description = ''
+ Email address to advertise as administrator. This is the address, where dkim, spv
+ etc. refusal reports are sent to.
+
+ The format should be: `mailto:<name>@<domain>`
+ '';
+ type = lib.types.str;
+ example = "mailto:dmarc+rua@example.com";
+ default = "";
+ };
+
+ 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 {
+ default = null;
+ type = lib.types.nullOr (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 = let
+ prefix = pre: lib.types.strMatching "^${lib.strings.escapeRegex pre}.*";
+ in
+ lib.types.oneOf [
+ (prefix "$argon2")
+ (prefix "$pbkdf2")
+ (prefix "$scrypt")
+ (prefix "$2") # bcrypt
+ (prefix "$6$") # sha-512
+ (prefix "$5$") # sha-256
+ (prefix "$sha1")
+ (prefix "$1") # md5
+ (prefix "_") # BSDi crypt
+ (prefix "{SHA}") # base64 sha
+ (prefix "{SSHA}") # base64 salted sha
+
+ # unix crypt
+ (prefix "{CRYPT}")
+ (prefix "{crypt}")
+
+ # Plain text
+ (prefix "{PLAIN}")
+ (prefix "{plain}")
+ (prefix "{CLEAR}")
+ (prefix "{clear}")
+ ];
+ 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 = {
+ verificationMode = lib.mkOption {
+ type = lib.types.enum ["relaxed" "strict"];
+ description = ''
+ Whether to allow invalid signatures/checks or not.
+ '';
+ default = "relaxed";
+ };
+
+ dkimKeys = lib.mkOption {
+ type = lib.types.attrsOf (lib.types.submodule {
+ options = {
+ dkimPublicKey = lib.mkOption {
+ type = lib.types.str;
+ description = ''
+ The base 64 encoded representation of the public dkim key.
+ '';
+ };
+ dkimPrivateKeyPath = lib.mkOption {
+ type = lib.types.path;
+ description = ''
+ The path to the dkim private key agenix file.
+ Generate it via the `./gen_key` script:
+ '';
+ };
+ keyAlgorithm = lib.mkOption {
+ type = lib.types.enum ["ed25519-sha256" "rsa-sha-256" "rsa-sha-1"];
+ description = "The algorithm of the used key";
+ };
+ };
+ });
+ description = ''
+ Which key to use for which domain. The attr keys are the domains
+ '';
+ default = {};
+ };
+ };
+ });
+ description = ''
+ Security options. This should only be set to `null` when testing.
+ '';
+ };
+ };
+
+ config = lib.mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = cfg.admin != "";
+ message = "You need to specify an admin address.";
+ }
+ ];
+
+ vhack.nginx.enable = true;
+ 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 configuration.
+ # However, this decision could obviously be reversed in the future. <2025-02-08>
+ enable = false;
+ inherit (cfg) package;
+ };
+
+ nginx.virtualHosts."${cfg.fqdn}" = {
+ locations."/" = {
+ proxyPass = "http://${builtins.elemAt config.services.stalwart-mail.settings.server.listener.http.bind 0}";
+ recommendedProxySettings = true;
+ };
+
+ useACMEHost = "${cfg.fqdn}";
+ forceSSL = true;
+ };
+
+ 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";
+ };
+ };
+ };
+ };
+ };
+ security.acme.certs = {
+ "${cfg.fqdn}" = {
+ domain = cfg.fqdn;
+ group = "stalwart-mail-certificates";
+ };
+ };
+
+ age.secrets = let
+ keys =
+ lib.mapAttrs' (
+ keyDomain: keyConfig:
+ lib.nameValuePair "stalwartMail${keyDomain}"
+ {
+ file = keyConfig.dkimPrivateKeyPath;
+ mode = "600";
+ owner = "stalwart-mail";
+ group = "stalwart-mail";
+ }
+ )
+ cfg.security.dkimKeys;
+ in
+ lib.mkIf (cfg.security != null) keys;
+
+ 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-stalwart-mail";
+ mode = "0770";
+ }
+ ];
+
+ # 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 = {
+ gid = config.vhack.constants.ids.gids.stalwart-mail;
+ };
+ stalwart-mail-certificates = {
+ gid = config.vhack.constants.ids.gids.stalwart-mail-certificates;
+ };
+ redis-stalwart-mail = {
+ gid = config.vhack.constants.ids.gids.redis-stalwart-mail;
+ };
+ };
+ users = {
+ nginx = {
+ extraGroups = ["stalwart-mail-certificates"];
+ };
+ stalwart-mail = {
+ isSystemUser = true;
+ group = "stalwart-mail";
+ uid = config.vhack.constants.ids.uids.stalwart-mail;
+ extraGroups = ["stalwart-mail-certificates"];
+ };
+ redis-stalwart-mail = {
+ group = "redis-stalwart-mail";
+ uid = config.vhack.constants.ids.uids.redis-stalwart-mail;
+ };
+ };
+ };
+
+ systemd.tmpfiles.rules = [
+ "d '${cfg.dataDirectory}' - stalwart-mail stalwart-mail - -"
+ ];
+
+ systemd = {
+ services.stalwart-mail = {
+ wantedBy = ["multi-user.target"];
+ requires =
+ [
+ "redis-stalwart-mail.service"
+ "network-online.target"
+ ]
+ ++ (lib.optional (cfg.security != null) "acme-${cfg.fqdn}.service");
+ after = [
+ "local-fs.target"
+ "network.target"
+ "network-online.target"
+ "redis-stalwart-mail.service"
+ "acme-${cfg.fqdn}.service"
+ ];
+ conflicts = [
+ "postfix.service"
+ "sendmail.service"
+ "exim4.service"
+ ];
+ description = "Stalwart Mail Server";
+
+ environment = {
+ SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt";
+ NIX_SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt";
+ };
+
+ 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 = pkgs.writers.writeDash "start-stalwart-mail" ''
+ ${lib.getExe cfg.package} --config="$CACHE_DIRECTORY/mutable_config_file.toml"
+ '';
+
+ Restart = "no";
+
+ KillMode = "process";
+ KillSignal = "SIGINT";
+
+ Type = "simple";
+ LimitNOFILE = 65536;
+
+ StandardOutput = "journal";
+ StandardError = "journal";
+
+ ReadWritePaths = [
+ cfg.dataDirectory
+ ];
+ CacheDirectory = "stalwart-mail";
+ StateDirectory = "stalwart-mail";
+
+ User = "stalwart-mail";
+ Group = "stalwart-mail";
+
+ SyslogIdentifier = "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";
+ };
+ };
+ };
+
+ # 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/settings.nix b/modules/by-name/st/stalwart-mail/settings.nix
new file mode 100644
index 0000000..dfaf63d
--- /dev/null
+++ b/modules/by-name/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;
+ };
+ };
+ };
+}