at master 8.0 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 ... 6}: 7let 8 cfg = config.services.stalwart-mail; 9 configFormat = pkgs.formats.toml { }; 10 configFile = configFormat.generate "stalwart-mail.toml" cfg.settings; 11 useLegacyStorage = lib.versionOlder config.system.stateVersion "24.11"; 12 13 parsePorts = 14 listeners: 15 let 16 parseAddresses = listeners: lib.flatten (lib.mapAttrsToList (name: value: value.bind) listeners); 17 splitAddress = addr: lib.splitString ":" addr; 18 extractPort = addr: lib.toInt (builtins.foldl' (a: b: b) "" (splitAddress addr)); 19 in 20 builtins.map (address: extractPort address) (parseAddresses listeners); 21 22in 23{ 24 options.services.stalwart-mail = { 25 enable = lib.mkEnableOption "the Stalwart all-in-one email server"; 26 27 package = lib.mkPackageOption pkgs "stalwart-mail" { }; 28 29 openFirewall = lib.mkOption { 30 type = lib.types.bool; 31 default = false; 32 description = '' 33 Whether to open TCP firewall ports, which are specified in 34 {option}`services.stalwart-mail.settings.server.listener` on all interfaces. 35 ''; 36 }; 37 38 settings = lib.mkOption { 39 inherit (configFormat) type; 40 default = { }; 41 description = '' 42 Configuration options for the Stalwart email server. 43 See <https://stalw.art/docs/category/configuration> for available options. 44 45 By default, the module is configured to store everything locally. 46 ''; 47 }; 48 49 dataDir = lib.mkOption { 50 type = lib.types.path; 51 default = "/var/lib/stalwart-mail"; 52 description = '' 53 Data directory for stalwart 54 ''; 55 }; 56 57 credentials = lib.mkOption { 58 description = '' 59 Credentials envs used to configure Stalwart-Mail secrets. 60 These secrets can be accessed in configuration values with 61 the macros such as 62 `%{file:/run/credentials/stalwart-mail.service/VAR_NAME}%`. 63 ''; 64 type = lib.types.attrsOf lib.types.str; 65 default = { }; 66 example = { 67 user_admin_password = "/run/keys/stalwart_admin_password"; 68 }; 69 }; 70 71 }; 72 73 config = lib.mkIf cfg.enable { 74 assertions = [ 75 { 76 assertion = 77 !( 78 (lib.hasAttrByPath [ "settings" "queue" ] cfg) 79 && (builtins.any (lib.hasAttrByPath [ 80 "value" 81 "next-hop" 82 ]) (lib.attrsToList cfg.settings.queue)) 83 ); 84 message = '' 85 Stalwart deprecated `next-hop` in favor of "virtual queues" `queue.strategy.route` \ 86 with v0.13.0 see [Outbound Strategy](https://stalw.art/docs/mta/outbound/strategy/#configuration) \ 87 and [release announcement](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING.md#upgrading-from-v012x-and-v011x-to-v013x). 88 ''; 89 } 90 ]; 91 92 # Default config: all local 93 services.stalwart-mail.settings = { 94 tracer.stdout = { 95 type = lib.mkDefault "stdout"; 96 level = lib.mkDefault "info"; 97 ansi = lib.mkDefault false; # no colour markers to journald 98 enable = lib.mkDefault true; 99 }; 100 store = 101 if useLegacyStorage then 102 { 103 # structured data in SQLite, blobs on filesystem 104 db.type = lib.mkDefault "sqlite"; 105 db.path = lib.mkDefault "${cfg.dataDir}/data/index.sqlite3"; 106 fs.type = lib.mkDefault "fs"; 107 fs.path = lib.mkDefault "${cfg.dataDir}/data/blobs"; 108 } 109 else 110 { 111 # everything in RocksDB 112 db.type = lib.mkDefault "rocksdb"; 113 db.path = lib.mkDefault "${cfg.dataDir}/db"; 114 db.compression = lib.mkDefault "lz4"; 115 }; 116 storage.data = lib.mkDefault "db"; 117 storage.fts = lib.mkDefault "db"; 118 storage.lookup = lib.mkDefault "db"; 119 storage.blob = lib.mkDefault (if useLegacyStorage then "fs" else "db"); 120 directory.internal.type = lib.mkDefault "internal"; 121 directory.internal.store = lib.mkDefault "db"; 122 storage.directory = lib.mkDefault "internal"; 123 resolver.type = lib.mkDefault "system"; 124 resolver.public-suffix = lib.mkDefault [ 125 "file://${pkgs.publicsuffix-list}/share/publicsuffix/public_suffix_list.dat" 126 ]; 127 spam-filter.resource = lib.mkDefault "file://${cfg.package.spam-filter}/spam-filter.toml"; 128 webadmin = 129 let 130 hasHttpListener = builtins.any (listener: listener.protocol == "http") ( 131 lib.attrValues (cfg.settings.server.listener or { }) 132 ); 133 in 134 { 135 path = "/var/cache/stalwart-mail"; 136 resource = lib.mkIf (hasHttpListener) (lib.mkDefault "file://${cfg.package.webadmin}/webadmin.zip"); 137 }; 138 }; 139 140 # This service stores a potentially large amount of data. 141 # Running it as a dynamic user would force chown to be run everytime the 142 # service is restarted on a potentially large number of files. 143 # That would cause unnecessary and unwanted delays. 144 users = { 145 groups.stalwart-mail = { }; 146 users.stalwart-mail = { 147 isSystemUser = true; 148 group = "stalwart-mail"; 149 }; 150 }; 151 152 systemd.tmpfiles.rules = [ 153 "d '${cfg.dataDir}' - stalwart-mail stalwart-mail - -" 154 ]; 155 156 systemd = { 157 packages = [ cfg.package ]; 158 services.stalwart-mail = { 159 wantedBy = [ "multi-user.target" ]; 160 after = [ 161 "local-fs.target" 162 "network.target" 163 ]; 164 165 preStart = 166 if useLegacyStorage then 167 '' 168 mkdir -p ${cfg.dataDir}/data/blobs 169 '' 170 else 171 '' 172 mkdir -p ${cfg.dataDir}/db 173 ''; 174 175 serviceConfig = { 176 ExecStart = [ 177 "" 178 "${lib.getExe cfg.package} --config=${configFile}" 179 ]; 180 LoadCredential = lib.mapAttrsToList (key: value: "${key}:${value}") cfg.credentials; 181 182 StandardOutput = "journal"; 183 StandardError = "journal"; 184 185 ReadWritePaths = [ 186 cfg.dataDir 187 ]; 188 CacheDirectory = "stalwart-mail"; 189 StateDirectory = "stalwart-mail"; 190 191 # Upstream uses "stalwart" as the username since 0.12.0 192 User = "stalwart-mail"; 193 Group = "stalwart-mail"; 194 195 # Bind standard privileged ports 196 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; 197 CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; 198 199 # Hardening 200 DeviceAllow = [ "" ]; 201 LockPersonality = true; 202 MemoryDenyWriteExecute = true; 203 PrivateDevices = true; 204 PrivateUsers = false; # incompatible with CAP_NET_BIND_SERVICE 205 ProcSubset = "pid"; 206 PrivateTmp = true; 207 ProtectClock = true; 208 ProtectControlGroups = true; 209 ProtectHome = true; 210 ProtectHostname = true; 211 ProtectKernelLogs = true; 212 ProtectKernelModules = true; 213 ProtectKernelTunables = true; 214 ProtectProc = "invisible"; 215 ProtectSystem = "strict"; 216 RestrictAddressFamilies = [ 217 "AF_INET" 218 "AF_INET6" 219 ]; 220 RestrictNamespaces = true; 221 RestrictRealtime = true; 222 RestrictSUIDSGID = true; 223 SystemCallArchitectures = "native"; 224 SystemCallFilter = [ 225 "@system-service" 226 "~@privileged" 227 ]; 228 UMask = "0077"; 229 }; 230 unitConfig.ConditionPathExists = [ 231 "" 232 "${configFile}" 233 ]; 234 }; 235 }; 236 237 # Make admin commands available in the shell 238 environment.systemPackages = [ cfg.package ]; 239 240 networking.firewall = 241 lib.mkIf (cfg.openFirewall && (builtins.hasAttr "listener" cfg.settings.server)) 242 { 243 allowedTCPPorts = parsePorts cfg.settings.server.listener; 244 }; 245 }; 246 247 meta = { 248 maintainers = with lib.maintainers; [ 249 happysalada 250 euxane 251 onny 252 norpol 253 ]; 254 }; 255}