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