1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6
7 name = "maddy";
8
9 cfg = config.services.maddy;
10
11 defaultConfig = ''
12 # Minimal configuration with TLS disabled, adapted from upstream example
13 # configuration here https://github.com/foxcpp/maddy/blob/master/maddy.conf
14 # Do not use this in production!
15
16 auth.pass_table local_authdb {
17 table sql_table {
18 driver sqlite3
19 dsn credentials.db
20 table_name passwords
21 }
22 }
23
24 storage.imapsql local_mailboxes {
25 driver sqlite3
26 dsn imapsql.db
27 }
28
29 table.chain local_rewrites {
30 optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
31 optional_step static {
32 entry postmaster postmaster@$(primary_domain)
33 }
34 optional_step file /etc/maddy/aliases
35 }
36
37 msgpipeline local_routing {
38 destination postmaster $(local_domains) {
39 modify {
40 replace_rcpt &local_rewrites
41 }
42 deliver_to &local_mailboxes
43 }
44 default_destination {
45 reject 550 5.1.1 "User doesn't exist"
46 }
47 }
48
49 smtp tcp://0.0.0.0:25 {
50 limits {
51 all rate 20 1s
52 all concurrency 10
53 }
54 dmarc yes
55 check {
56 require_mx_record
57 dkim
58 spf
59 }
60 source $(local_domains) {
61 reject 501 5.1.8 "Use Submission for outgoing SMTP"
62 }
63 default_source {
64 destination postmaster $(local_domains) {
65 deliver_to &local_routing
66 }
67 default_destination {
68 reject 550 5.1.1 "User doesn't exist"
69 }
70 }
71 }
72
73 submission tcp://0.0.0.0:587 {
74 limits {
75 all rate 50 1s
76 }
77 auth &local_authdb
78 source $(local_domains) {
79 check {
80 authorize_sender {
81 prepare_email &local_rewrites
82 user_to_email identity
83 }
84 }
85 destination postmaster $(local_domains) {
86 deliver_to &local_routing
87 }
88 default_destination {
89 modify {
90 dkim $(primary_domain) $(local_domains) default
91 }
92 deliver_to &remote_queue
93 }
94 }
95 default_source {
96 reject 501 5.1.8 "Non-local sender domain"
97 }
98 }
99
100 target.remote outbound_delivery {
101 limits {
102 destination rate 20 1s
103 destination concurrency 10
104 }
105 mx_auth {
106 dane
107 mtasts {
108 cache fs
109 fs_dir mtasts_cache/
110 }
111 local_policy {
112 min_tls_level encrypted
113 min_mx_level none
114 }
115 }
116 }
117
118 target.queue remote_queue {
119 target &outbound_delivery
120 autogenerated_msg_domain $(primary_domain)
121 bounce {
122 destination postmaster $(local_domains) {
123 deliver_to &local_routing
124 }
125 default_destination {
126 reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
127 }
128 }
129 }
130
131 imap tcp://0.0.0.0:143 {
132 auth &local_authdb
133 storage &local_mailboxes
134 }
135 '';
136
137in {
138 options = {
139 services.maddy = {
140
141 enable = mkEnableOption "Maddy, a free an open source mail server";
142
143 user = mkOption {
144 default = "maddy";
145 type = with types; uniq str;
146 description = ''
147 User account under which maddy runs.
148
149 ::: {.note}
150 If left as the default value this user will automatically be created
151 on system activation, otherwise the sysadmin is responsible for
152 ensuring the user exists before the maddy service starts.
153 :::
154 '';
155 };
156
157 group = mkOption {
158 default = "maddy";
159 type = with types; uniq str;
160 description = ''
161 Group account under which maddy runs.
162
163 ::: {.note}
164 If left as the default value this group will automatically be created
165 on system activation, otherwise the sysadmin is responsible for
166 ensuring the group exists before the maddy service starts.
167 :::
168 '';
169 };
170
171 hostname = mkOption {
172 default = "localhost";
173 type = with types; uniq str;
174 example = ''example.com'';
175 description = ''
176 Hostname to use. It should be FQDN.
177 '';
178 };
179
180 primaryDomain = mkOption {
181 default = "localhost";
182 type = with types; uniq str;
183 example = ''mail.example.com'';
184 description = ''
185 Primary MX domain to use. It should be FQDN.
186 '';
187 };
188
189 localDomains = mkOption {
190 type = with types; listOf str;
191 default = ["$(primary_domain)"];
192 example = [
193 "$(primary_domain)"
194 "example.com"
195 "other.example.com"
196 ];
197 description = ''
198 Define list of allowed domains.
199 '';
200 };
201
202 config = mkOption {
203 type = with types; nullOr lines;
204 default = defaultConfig;
205 description = ''
206 Server configuration, see
207 [https://maddy.email](https://maddy.email) for
208 more information. The default configuration of this module will setup
209 minimal Maddy instance for mail transfer without TLS encryption.
210
211 ::: {.note}
212 This should not be used in a production environment.
213 :::
214 '';
215 };
216
217 tls = {
218 loader = mkOption {
219 type = with types; nullOr (enum [ "off" "file" "acme" ]);
220 default = "off";
221 description = ''
222 TLS certificates are obtained by modules called "certificate
223 loaders".
224
225 The `file` loader module reads certificates from files specified by
226 the `certificates` option.
227
228 Alternatively the `acme` module can be used to automatically obtain
229 certificates using the ACME protocol.
230
231 Module configuration is done via the `tls.extraConfig` option.
232
233 Secrets such as API keys or passwords should not be supplied in
234 plaintext. Instead the `secrets` option can be used to read secrets
235 at runtime as environment variables. Secrets can be referenced with
236 `{env:VAR}`.
237 '';
238 };
239
240 certificates = mkOption {
241 type = with types; listOf (submodule {
242 options = {
243 keyPath = mkOption {
244 type = types.path;
245 example = "/etc/ssl/mx1.example.org.key";
246 description = ''
247 Path to the private key used for TLS.
248 '';
249 };
250 certPath = mkOption {
251 type = types.path;
252 example = "/etc/ssl/mx1.example.org.crt";
253 description = ''
254 Path to the certificate used for TLS.
255 '';
256 };
257 };
258 });
259 default = [];
260 example = lib.literalExpression ''
261 [{
262 keyPath = "/etc/ssl/mx1.example.org.key";
263 certPath = "/etc/ssl/mx1.example.org.crt";
264 }]
265 '';
266 description = ''
267 A list of attribute sets containing paths to TLS certificates and
268 keys. Maddy will use SNI if multiple pairs are selected.
269 '';
270 };
271
272 extraConfig = mkOption {
273 type = with types; nullOr lines;
274 description = ''
275 Arguments for the specified certificate loader.
276
277 In case the `tls` loader is set, the defaults are considered secure
278 and there is no need to change anything in most cases.
279 For available options see [upstream manual](https://maddy.email/reference/tls/).
280
281 For ACME configuration, see [following page](https://maddy.email/reference/tls-acme).
282 '';
283 default = "";
284 };
285 };
286
287 openFirewall = mkOption {
288 type = types.bool;
289 default = false;
290 description = ''
291 Open the configured incoming and outgoing mail server ports.
292 '';
293 };
294
295 ensureAccounts = mkOption {
296 type = with types; listOf str;
297 default = [];
298 description = ''
299 List of IMAP accounts which get automatically created. Note that for
300 a complete setup, user credentials for these accounts are required
301 and can be created using the `ensureCredentials` option.
302 This option does not delete accounts which are not (anymore) listed.
303 '';
304 example = [
305 "user1@localhost"
306 "user2@localhost"
307 ];
308 };
309
310 ensureCredentials = mkOption {
311 default = {};
312 description = ''
313 List of user accounts which get automatically created if they don't
314 exist yet. Note that for a complete setup, corresponding mail boxes
315 have to get created using the `ensureAccounts` option.
316 This option does not delete accounts which are not (anymore) listed.
317 '';
318 example = {
319 "user1@localhost".passwordFile = /secrets/user1-localhost;
320 "user2@localhost".passwordFile = /secrets/user2-localhost;
321 };
322 type = types.attrsOf (types.submodule {
323 options = {
324 passwordFile = mkOption {
325 type = types.path;
326 example = "/path/to/file";
327 default = null;
328 description = ''
329 Specifies the path to a file containing the
330 clear text password for the user.
331 '';
332 };
333 };
334 });
335 };
336
337 secrets = lib.mkOption {
338 type = with types; listOf path;
339 description = ''
340 A list of files containing the various secrets. Should be in the format
341 expected by systemd's `EnvironmentFile` directory. Secrets can be
342 referenced in the format `{env:VAR}`.
343 '';
344 default = [ ];
345 };
346
347 };
348 };
349
350 config = mkIf cfg.enable {
351
352 assertions = [
353 {
354 assertion = cfg.tls.loader == "file" -> cfg.tls.certificates != [];
355 message = ''
356 If Maddy is configured to use TLS, tls.certificates with attribute sets
357 of certPath and keyPath must be provided.
358 Read more about obtaining TLS certificates here:
359 https://maddy.email/tutorials/setting-up/#tls-certificates
360 '';
361 }
362 {
363 assertion = cfg.tls.loader == "acme" -> cfg.tls.extraConfig != "";
364 message = ''
365 If Maddy is configured to obtain TLS certificates using the ACME
366 loader, extra configuration options must be supplied via
367 tls.extraConfig option.
368 See upstream documentation for more details:
369 https://maddy.email/reference/tls-acme
370 '';
371 }
372 ];
373
374 systemd = {
375
376 packages = [ pkgs.maddy ];
377 services = {
378 maddy = {
379 serviceConfig = {
380 User = cfg.user;
381 Group = cfg.group;
382 StateDirectory = [ "maddy" ];
383 EnvironmentFile = cfg.secrets;
384 };
385 restartTriggers = [ config.environment.etc."maddy/maddy.conf".source ];
386 wantedBy = [ "multi-user.target" ];
387 };
388 maddy-ensure-accounts = {
389 script = ''
390 ${optionalString (cfg.ensureAccounts != []) ''
391 ${concatMapStrings (account: ''
392 if ! ${pkgs.maddy}/bin/maddyctl imap-acct list | grep "${account}"; then
393 ${pkgs.maddy}/bin/maddyctl imap-acct create ${account}
394 fi
395 '') cfg.ensureAccounts}
396 ''}
397 ${optionalString (cfg.ensureCredentials != {}) ''
398 ${concatStringsSep "\n" (mapAttrsToList (name: cfg: ''
399 if ! ${pkgs.maddy}/bin/maddyctl creds list | grep "${name}"; then
400 ${pkgs.maddy}/bin/maddyctl creds create --password $(cat ${escapeShellArg cfg.passwordFile}) ${name}
401 fi
402 '') cfg.ensureCredentials)}
403 ''}
404 '';
405 serviceConfig = {
406 Type = "oneshot";
407 User= "maddy";
408 };
409 after = [ "maddy.service" ];
410 wantedBy = [ "multi-user.target" ];
411 };
412
413 };
414
415 };
416
417 environment.etc."maddy/maddy.conf" = {
418 text = ''
419 $(hostname) = ${cfg.hostname}
420 $(primary_domain) = ${cfg.primaryDomain}
421 $(local_domains) = ${toString cfg.localDomains}
422 hostname ${cfg.hostname}
423
424 ${if (cfg.tls.loader == "file") then ''
425 tls file ${concatStringsSep " " (
426 map (x: x.certPath + " " + x.keyPath
427 ) cfg.tls.certificates)} ${optionalString (cfg.tls.extraConfig != "") ''
428 { ${cfg.tls.extraConfig} }
429 ''}
430 '' else if (cfg.tls.loader == "acme") then ''
431 tls {
432 loader acme {
433 ${cfg.tls.extraConfig}
434 }
435 }
436 '' else if (cfg.tls.loader == "off") then ''
437 tls off
438 '' else ""}
439
440 ${cfg.config}
441 '';
442 };
443
444 users.users = optionalAttrs (cfg.user == name) {
445 ${name} = {
446 isSystemUser = true;
447 group = cfg.group;
448 description = "Maddy mail transfer agent user";
449 };
450 };
451
452 users.groups = optionalAttrs (cfg.group == name) {
453 ${cfg.group} = { };
454 };
455
456 networking.firewall = mkIf cfg.openFirewall {
457 allowedTCPPorts = [ 25 143 587 ];
458 };
459
460 environment.systemPackages = [
461 pkgs.maddy
462 ];
463 };
464}