{ 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 vhackPackages "stalwart-mail-free" {}; 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:@` ''; 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 { 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 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 = {}; }; allowInsecureSmtp = lib.mkEnableOption '' insecure SMTP listener (on port 25). This is important, if an legacy mail server might want to send you mail. ''; }; }); 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; # dataDir = cfg.dataDirectory; }; # FIXME(@bpeetz): This is currently needed for a successful acme http-01 challenge. # We could also use the DNS challenge. <2025-03-01> nginx.virtualHosts."${cfg.fqdn}" = { enableACME = false; extraConfig = # This is copied directly from the nixos nginx module. # Rule for legitimate ACME Challenge requests (like /.well-known/acme-challenge/xxxxxxxxx) # We use ^~ here, so that we don't check any regexes (which could # otherwise easily override this intended match accidentally). '' location ^~ /.well-known/acme-challenge/ { root ${config.security.acme.certs.${cfg.fqdn}.webroot}; auth_basic off; auth_request off; } ''; }; 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"; }; }; 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"; 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 = {}; users.stalwart-mail = { isSystemUser = true; group = "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" "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 = "on-failure"; RestartSec = 5; 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; }; }; }