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.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
75 # Default config: all local
76 services.stalwart-mail.settings = {
77 tracer.stdout = {
78 type = lib.mkDefault "stdout";
79 level = lib.mkDefault "info";
80 ansi = lib.mkDefault false; # no colour markers to journald
81 enable = lib.mkDefault true;
82 };
83 store =
84 if useLegacyStorage then
85 {
86 # structured data in SQLite, blobs on filesystem
87 db.type = lib.mkDefault "sqlite";
88 db.path = lib.mkDefault "${cfg.dataDir}/data/index.sqlite3";
89 fs.type = lib.mkDefault "fs";
90 fs.path = lib.mkDefault "${cfg.dataDir}/data/blobs";
91 }
92 else
93 {
94 # everything in RocksDB
95 db.type = lib.mkDefault "rocksdb";
96 db.path = lib.mkDefault "${cfg.dataDir}/db";
97 db.compression = lib.mkDefault "lz4";
98 };
99 storage.data = lib.mkDefault "db";
100 storage.fts = lib.mkDefault "db";
101 storage.lookup = lib.mkDefault "db";
102 storage.blob = lib.mkDefault (if useLegacyStorage then "fs" else "db");
103 directory.internal.type = lib.mkDefault "internal";
104 directory.internal.store = lib.mkDefault "db";
105 storage.directory = lib.mkDefault "internal";
106 resolver.type = lib.mkDefault "system";
107 resolver.public-suffix = lib.mkDefault [
108 "file://${pkgs.publicsuffix-list}/share/publicsuffix/public_suffix_list.dat"
109 ];
110 config = {
111 spam-filter.resource = lib.mkDefault "file://${cfg.package}/etc/stalwart/spamfilter.toml";
112 webadmin =
113 let
114 hasHttpListener = builtins.any (listener: listener.protocol == "http") (
115 lib.attrValues cfg.settings.server.listener
116 );
117 in
118 {
119 path = "/var/cache/stalwart-mail";
120 }
121 // lib.optionalAttrs ((builtins.hasAttr "listener" cfg.settings.server) && hasHttpListener) {
122 resource = lib.mkDefault "file://${cfg.package.webadmin}/webadmin.zip";
123 };
124 };
125 };
126
127 # This service stores a potentially large amount of data.
128 # Running it as a dynamic user would force chown to be run everytime the
129 # service is restarted on a potentially large number of files.
130 # That would cause unnecessary and unwanted delays.
131 users = {
132 groups.stalwart-mail = { };
133 users.stalwart-mail = {
134 isSystemUser = true;
135 group = "stalwart-mail";
136 };
137 };
138
139 systemd.tmpfiles.rules = [
140 "d '${cfg.dataDir}' - stalwart-mail stalwart-mail - -"
141 ];
142
143 systemd = {
144 packages = [ cfg.package ];
145 services.stalwart-mail = {
146 wantedBy = [ "multi-user.target" ];
147 after = [
148 "local-fs.target"
149 "network.target"
150 ];
151
152 preStart =
153 if useLegacyStorage then
154 ''
155 mkdir -p ${cfg.dataDir}/data/blobs
156 ''
157 else
158 ''
159 mkdir -p ${cfg.dataDir}/db
160 '';
161
162 serviceConfig = {
163 ExecStart = [
164 ""
165 "${cfg.package}/bin/stalwart-mail --config=${configFile}"
166 ];
167 LoadCredential = lib.mapAttrsToList (key: value: "${key}:${value}") cfg.credentials;
168
169 StandardOutput = "journal";
170 StandardError = "journal";
171
172 ReadWritePaths = [
173 cfg.dataDir
174 ];
175 CacheDirectory = "stalwart-mail";
176 StateDirectory = "stalwart-mail";
177
178 # Bind standard privileged ports
179 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
180 CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
181
182 # Hardening
183 DeviceAllow = [ "" ];
184 LockPersonality = true;
185 MemoryDenyWriteExecute = true;
186 PrivateDevices = true;
187 PrivateUsers = false; # incompatible with CAP_NET_BIND_SERVICE
188 ProcSubset = "pid";
189 PrivateTmp = true;
190 ProtectClock = true;
191 ProtectControlGroups = true;
192 ProtectHome = true;
193 ProtectHostname = true;
194 ProtectKernelLogs = true;
195 ProtectKernelModules = true;
196 ProtectKernelTunables = true;
197 ProtectProc = "invisible";
198 ProtectSystem = "strict";
199 RestrictAddressFamilies = [
200 "AF_INET"
201 "AF_INET6"
202 ];
203 RestrictNamespaces = true;
204 RestrictRealtime = true;
205 RestrictSUIDSGID = true;
206 SystemCallArchitectures = "native";
207 SystemCallFilter = [
208 "@system-service"
209 "~@privileged"
210 ];
211 UMask = "0077";
212 };
213 unitConfig.ConditionPathExists = [
214 ""
215 "${configFile}"
216 ];
217 };
218 };
219
220 # Make admin commands available in the shell
221 environment.systemPackages = [ cfg.package ];
222
223 networking.firewall =
224 lib.mkIf (cfg.openFirewall && (builtins.hasAttr "listener" cfg.settings.server))
225 {
226 allowedTCPPorts = parsePorts cfg.settings.server.listener;
227 };
228 };
229
230 meta = {
231 maintainers = with lib.maintainers; [
232 happysalada
233 euxane
234 onny
235 ];
236 };
237}