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}