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 (lib.mdDoc "Maddy, a free an open source mail server");
142
143 user = mkOption {
144 default = "maddy";
145 type = with types; uniq string;
146 description = lib.mdDoc ''
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 string;
160 description = lib.mdDoc ''
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 string;
174 example = ''example.com'';
175 description = lib.mdDoc ''
176 Hostname to use. It should be FQDN.
177 '';
178 };
179
180 primaryDomain = mkOption {
181 default = "localhost";
182 type = with types; uniq string;
183 example = ''mail.example.com'';
184 description = lib.mdDoc ''
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 = lib.mdDoc ''
198 Define list of allowed domains.
199 '';
200 };
201
202 config = mkOption {
203 type = with types; nullOr lines;
204 default = defaultConfig;
205 description = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.mdDoc ''
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 = lib.types.path;
339 description = lib.mdDoc ''
340 A file 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 };
345
346 };
347 };
348
349 config = mkIf cfg.enable {
350
351 assertions = [
352 {
353 assertion = cfg.tls.loader == "file" -> cfg.tls.certificates != [];
354 message = ''
355 If Maddy is configured to use TLS, tls.certificates with attribute sets
356 of certPath and keyPath must be provided.
357 Read more about obtaining TLS certificates here:
358 https://maddy.email/tutorials/setting-up/#tls-certificates
359 '';
360 }
361 {
362 assertion = cfg.tls.loader == "acme" -> cfg.tls.extraConfig != "";
363 message = ''
364 If Maddy is configured to obtain TLS certificates using the ACME
365 loader, extra configuration options must be supplied via
366 tls.extraConfig option.
367 See upstream documentation for more details:
368 https://maddy.email/reference/tls-acme
369 '';
370 }
371 ];
372
373 systemd = {
374
375 packages = [ pkgs.maddy ];
376 services = {
377 maddy = {
378 serviceConfig = {
379 User = cfg.user;
380 Group = cfg.group;
381 StateDirectory = [ "maddy" ];
382 EnvironmentFile = lib.mkIf (cfg.secrets != null) "${cfg.secrets}";
383 };
384 restartTriggers = [ config.environment.etc."maddy/maddy.conf".source ];
385 wantedBy = [ "multi-user.target" ];
386 };
387 maddy-ensure-accounts = {
388 script = ''
389 ${optionalString (cfg.ensureAccounts != []) ''
390 ${concatMapStrings (account: ''
391 if ! ${pkgs.maddy}/bin/maddyctl imap-acct list | grep "${account}"; then
392 ${pkgs.maddy}/bin/maddyctl imap-acct create ${account}
393 fi
394 '') cfg.ensureAccounts}
395 ''}
396 ${optionalString (cfg.ensureCredentials != {}) ''
397 ${concatStringsSep "\n" (mapAttrsToList (name: cfg: ''
398 if ! ${pkgs.maddy}/bin/maddyctl creds list | grep "${name}"; then
399 ${pkgs.maddy}/bin/maddyctl creds create --password $(cat ${escapeShellArg cfg.passwordFile}) ${name}
400 fi
401 '') cfg.ensureCredentials)}
402 ''}
403 '';
404 serviceConfig = {
405 Type = "oneshot";
406 User= "maddy";
407 };
408 after = [ "maddy.service" ];
409 wantedBy = [ "multi-user.target" ];
410 };
411
412 };
413
414 };
415
416 environment.etc."maddy/maddy.conf" = {
417 text = ''
418 $(hostname) = ${cfg.hostname}
419 $(primary_domain) = ${cfg.primaryDomain}
420 $(local_domains) = ${toString cfg.localDomains}
421 hostname ${cfg.hostname}
422
423 ${if (cfg.tls.loader == "file") then ''
424 tls file ${concatStringsSep " " (
425 map (x: x.certPath + " " + x.keyPath
426 ) cfg.tls.certificates)} ${optionalString (cfg.tls.extraConfig != "") ''
427 { ${cfg.tls.extraConfig} }
428 ''}
429 '' else if (cfg.tls.loader == "acme") then ''
430 tls {
431 loader acme {
432 ${cfg.tls.extraConfig}
433 }
434 }
435 '' else if (cfg.tls.loader == "off") then ''
436 tls off
437 '' else ""}
438
439 ${cfg.config}
440 '';
441 };
442
443 users.users = optionalAttrs (cfg.user == name) {
444 ${name} = {
445 isSystemUser = true;
446 group = cfg.group;
447 description = "Maddy mail transfer agent user";
448 };
449 };
450
451 users.groups = optionalAttrs (cfg.group == name) {
452 ${cfg.group} = { };
453 };
454
455 networking.firewall = mkIf cfg.openFirewall {
456 allowedTCPPorts = [ 25 143 587 ];
457 };
458
459 environment.systemPackages = [
460 pkgs.maddy
461 ];
462 };
463}