about summary refs log tree commit diff stats
path: root/modules/by-name/st/stalwart-mail/module.nix
diff options
context:
space:
mode:
Diffstat (limited to 'modules/by-name/st/stalwart-mail/module.nix')
-rw-r--r--modules/by-name/st/stalwart-mail/module.nix334
1 files changed, 334 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;
+      };
+  };
+}