1{ config, pkgs, lib, ... }: # mailman.nix
2
3with lib;
4
5let
6
7 cfg = config.services.mailman;
8
9 inherit (pkgs.mailmanPackages.buildEnvs { withHyperkitty = cfg.hyperkitty.enable; withLDAP = cfg.ldap.enable; })
10 mailmanEnv webEnv;
11
12 withPostgresql = config.services.postgresql.enable;
13
14 # This deliberately doesn't use recursiveUpdate so users can
15 # override the defaults.
16 webSettings = {
17 DEFAULT_FROM_EMAIL = cfg.siteOwner;
18 SERVER_EMAIL = cfg.siteOwner;
19 ALLOWED_HOSTS = [ "localhost" "127.0.0.1" ] ++ cfg.webHosts;
20 COMPRESS_OFFLINE = true;
21 STATIC_ROOT = "/var/lib/mailman-web-static";
22 MEDIA_ROOT = "/var/lib/mailman-web/media";
23 LOGGING = {
24 version = 1;
25 disable_existing_loggers = true;
26 handlers.console.class = "logging.StreamHandler";
27 loggers.django = {
28 handlers = [ "console" ];
29 level = "INFO";
30 };
31 };
32 HAYSTACK_CONNECTIONS.default = {
33 ENGINE = "haystack.backends.whoosh_backend.WhooshEngine";
34 PATH = "/var/lib/mailman-web/fulltext-index";
35 };
36 } // cfg.webSettings;
37
38 webSettingsJSON = pkgs.writeText "settings.json" (builtins.toJSON webSettings);
39
40 # TODO: Should this be RFC42-ised so that users can set additional options without modifying the module?
41 postfixMtaConfig = pkgs.writeText "mailman-postfix.cfg" ''
42 [postfix]
43 postmap_command: ${pkgs.postfix}/bin/postmap
44 transport_file_type: hash
45 '';
46
47 mailmanCfg = lib.generators.toINI {}
48 (recursiveUpdate cfg.settings
49 ((optionalAttrs (cfg.restApiPassFile != null) {
50 webservice.admin_pass = "#NIXOS_MAILMAN_REST_API_PASS_SECRET#";
51 })));
52
53 mailmanCfgFile = pkgs.writeText "mailman-raw.cfg" mailmanCfg;
54
55 mailmanHyperkittyCfg = pkgs.writeText "mailman-hyperkitty.cfg" ''
56 [general]
57 # This is your HyperKitty installation, preferably on the localhost. This
58 # address will be used by Mailman to forward incoming emails to HyperKitty
59 # for archiving. It does not need to be publicly available, in fact it's
60 # better if it is not.
61 base_url: ${cfg.hyperkitty.baseUrl}
62
63 # Shared API key, must be the identical to the value in HyperKitty's
64 # settings.
65 api_key: @API_KEY@
66 '';
67
68in {
69
70 ###### interface
71
72 imports = [
73 (mkRenamedOptionModule [ "services" "mailman" "hyperkittyBaseUrl" ]
74 [ "services" "mailman" "hyperkitty" "baseUrl" ])
75
76 (mkRemovedOptionModule [ "services" "mailman" "hyperkittyApiKey" ] ''
77 The Hyperkitty API key is now generated on first run, and not
78 stored in the world-readable Nix store. To continue using
79 Hyperkitty, you must set services.mailman.hyperkitty.enable = true.
80 '')
81 (mkRemovedOptionModule [ "services" "mailman" "package" ] ''
82 Didn't have an effect for several years.
83 '')
84 ];
85
86 options = {
87
88 services.mailman = {
89
90 enable = mkOption {
91 type = types.bool;
92 default = false;
93 description = lib.mdDoc "Enable Mailman on this host. Requires an active MTA on the host (e.g. Postfix).";
94 };
95
96 ldap = {
97 enable = mkEnableOption (lib.mdDoc "LDAP auth");
98 serverUri = mkOption {
99 type = types.str;
100 example = "ldaps://ldap.host";
101 description = lib.mdDoc ''
102 LDAP host to connect against.
103 '';
104 };
105 bindDn = mkOption {
106 type = types.str;
107 example = "cn=root,dc=nixos,dc=org";
108 description = lib.mdDoc ''
109 Service account to bind against.
110 '';
111 };
112 bindPasswordFile = mkOption {
113 type = types.str;
114 example = "/run/secrets/ldap-bind";
115 description = lib.mdDoc ''
116 Path to the file containing the bind password of the servie account
117 defined by [](#opt-services.mailman.ldap.bindDn).
118 '';
119 };
120 superUserGroup = mkOption {
121 type = types.nullOr types.str;
122 default = null;
123 example = "cn=admin,ou=groups,dc=nixos,dc=org";
124 description = lib.mdDoc ''
125 Group where a user must be a member of to gain superuser rights.
126 '';
127 };
128 userSearch = {
129 query = mkOption {
130 type = types.str;
131 example = "(&(objectClass=inetOrgPerson)(|(uid=%(user)s)(mail=%(user)s)))";
132 description = lib.mdDoc ''
133 Query to find a user in the LDAP database.
134 '';
135 };
136 ou = mkOption {
137 type = types.str;
138 example = "ou=users,dc=nixos,dc=org";
139 description = lib.mdDoc ''
140 Organizational unit to look up a user.
141 '';
142 };
143 };
144 groupSearch = {
145 type = mkOption {
146 type = types.enum [
147 "posixGroup" "groupOfNames" "memberDNGroup" "nestedMemberDNGroup" "nestedGroupOfNames"
148 "groupOfUniqueNames" "nestedGroupOfUniqueNames" "activeDirectoryGroup" "nestedActiveDirectoryGroup"
149 "organizationalRoleGroup" "nestedOrganizationalRoleGroup"
150 ];
151 default = "posixGroup";
152 apply = v: "${toUpper (substring 0 1 v)}${substring 1 (stringLength v) v}Type";
153 description = lib.mdDoc ''
154 Type of group to perform a group search against.
155 '';
156 };
157 query = mkOption {
158 type = types.str;
159 example = "(objectClass=groupOfNames)";
160 description = lib.mdDoc ''
161 Query to find a group associated to a user in the LDAP database.
162 '';
163 };
164 ou = mkOption {
165 type = types.str;
166 example = "ou=groups,dc=nixos,dc=org";
167 description = lib.mdDoc ''
168 Organizational unit to look up a group.
169 '';
170 };
171 };
172 attrMap = {
173 username = mkOption {
174 default = "uid";
175 type = types.str;
176 description = lib.mdDoc ''
177 LDAP-attribute that corresponds to the `username`-attribute in mailman.
178 '';
179 };
180 firstName = mkOption {
181 default = "givenName";
182 type = types.str;
183 description = lib.mdDoc ''
184 LDAP-attribute that corresponds to the `firstName`-attribute in mailman.
185 '';
186 };
187 lastName = mkOption {
188 default = "sn";
189 type = types.str;
190 description = lib.mdDoc ''
191 LDAP-attribute that corresponds to the `lastName`-attribute in mailman.
192 '';
193 };
194 email = mkOption {
195 default = "mail";
196 type = types.str;
197 description = lib.mdDoc ''
198 LDAP-attribute that corresponds to the `email`-attribute in mailman.
199 '';
200 };
201 };
202 };
203
204 enablePostfix = mkOption {
205 type = types.bool;
206 default = true;
207 example = false;
208 description = lib.mdDoc ''
209 Enable Postfix integration. Requires an active Postfix installation.
210
211 If you want to use another MTA, set this option to false and configure
212 settings in services.mailman.settings.mta.
213
214 Refer to the Mailman manual for more info.
215 '';
216 };
217
218 siteOwner = mkOption {
219 type = types.str;
220 example = "postmaster@example.org";
221 description = lib.mdDoc ''
222 Certain messages that must be delivered to a human, but which can't
223 be delivered to a list owner (e.g. a bounce from a list owner), will
224 be sent to this address. It should point to a human.
225 '';
226 };
227
228 webHosts = mkOption {
229 type = types.listOf types.str;
230 default = [];
231 description = lib.mdDoc ''
232 The list of hostnames and/or IP addresses from which the Mailman Web
233 UI will accept requests. By default, "localhost" and "127.0.0.1" are
234 enabled. All additional names under which your web server accepts
235 requests for the UI must be listed here or incoming requests will be
236 rejected.
237 '';
238 };
239
240 webUser = mkOption {
241 type = types.str;
242 default = "mailman-web";
243 description = lib.mdDoc ''
244 User to run mailman-web as
245 '';
246 };
247
248 webSettings = mkOption {
249 type = types.attrs;
250 default = {};
251 description = lib.mdDoc ''
252 Overrides for the default mailman-web Django settings.
253 '';
254 };
255
256 restApiPassFile = mkOption {
257 default = null;
258 type = types.nullOr types.str;
259 description = lib.mdDoc ''
260 Path to the file containing the value for `MAILMAN_REST_API_PASS`.
261 '';
262 };
263
264 serve = {
265 enable = mkEnableOption (lib.mdDoc "Automatic nginx and uwsgi setup for mailman-web");
266
267 virtualRoot = mkOption {
268 default = "/";
269 example = lib.literalExpression "/lists";
270 type = types.str;
271 description = lib.mdDoc ''
272 Path to mount the mailman-web django application on.
273 '';
274 };
275 };
276
277 extraPythonPackages = mkOption {
278 description = lib.mdDoc "Packages to add to the python environment used by mailman and mailman-web";
279 type = types.listOf types.package;
280 default = [];
281 };
282
283 settings = mkOption {
284 description = lib.mdDoc "Settings for mailman.cfg";
285 type = types.attrsOf (types.attrsOf types.str);
286 default = {};
287 };
288
289 hyperkitty = {
290 enable = mkEnableOption (lib.mdDoc "the Hyperkitty archiver for Mailman");
291
292 baseUrl = mkOption {
293 type = types.str;
294 default = "http://localhost:18507/archives/";
295 description = lib.mdDoc ''
296 Where can Mailman connect to Hyperkitty's internal API, preferably on
297 localhost?
298 '';
299 };
300 };
301
302 };
303 };
304
305 ###### implementation
306
307 config = mkIf cfg.enable {
308
309 services.mailman.settings = {
310 mailman.site_owner = lib.mkDefault cfg.siteOwner;
311 mailman.layout = "fhs";
312
313 "paths.fhs" = {
314 bin_dir = "${pkgs.mailmanPackages.mailman}/bin";
315 var_dir = "/var/lib/mailman";
316 queue_dir = "$var_dir/queue";
317 template_dir = "$var_dir/templates";
318 log_dir = "/var/log/mailman";
319 lock_dir = "$var_dir/lock";
320 etc_dir = "/etc";
321 pid_file = "/run/mailman/master.pid";
322 };
323
324 mta.configuration = lib.mkDefault (if cfg.enablePostfix then "${postfixMtaConfig}" else throw "When Mailman Postfix integration is disabled, set `services.mailman.settings.mta.configuration` to the path of the config file required to integrate with your MTA.");
325
326 "archiver.hyperkitty" = lib.mkIf cfg.hyperkitty.enable {
327 class = "mailman_hyperkitty.Archiver";
328 enable = "yes";
329 configuration = "/var/lib/mailman/mailman-hyperkitty.cfg";
330 };
331 } // (let
332 loggerNames = ["root" "archiver" "bounce" "config" "database" "debug" "error" "fromusenet" "http" "locks" "mischief" "plugins" "runner" "smtp"];
333 loggerSectionNames = map (n: "logging.${n}") loggerNames;
334 in lib.genAttrs loggerSectionNames(name: { handler = "stderr"; })
335 );
336
337 assertions = let
338 inherit (config.services) postfix;
339
340 requirePostfixHash = optionPath: dataFile:
341 with lib;
342 let
343 expected = "hash:/var/lib/mailman/data/${dataFile}";
344 value = attrByPath optionPath [] postfix;
345 in
346 { assertion = postfix.enable -> isList value && elem expected value;
347 message = ''
348 services.postfix.${concatStringsSep "." optionPath} must contain
349 "${expected}".
350 See <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>.
351 '';
352 };
353 in [
354 { assertion = cfg.webHosts != [];
355 message = ''
356 services.mailman.serve.enable requires there to be at least one entry
357 in services.mailman.webHosts.
358 '';
359 }
360 ] ++ (lib.optionals cfg.enablePostfix [
361 { assertion = postfix.enable;
362 message = ''
363 Mailman's default NixOS configuration requires Postfix to be enabled.
364
365 If you want to use another MTA, set services.mailman.enablePostfix
366 to false and configure settings in services.mailman.settings.mta.
367
368 Refer to <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>
369 for more info.
370 '';
371 }
372 (requirePostfixHash [ "relayDomains" ] "postfix_domains")
373 (requirePostfixHash [ "config" "transport_maps" ] "postfix_lmtp")
374 (requirePostfixHash [ "config" "local_recipient_maps" ] "postfix_lmtp")
375 ]);
376
377 users.users.mailman = {
378 description = "GNU Mailman";
379 isSystemUser = true;
380 group = "mailman";
381 };
382 users.users.mailman-web = lib.mkIf (cfg.webUser == "mailman-web") {
383 description = "GNU Mailman web interface";
384 isSystemUser = true;
385 group = "mailman";
386 };
387 users.groups.mailman = {};
388
389 environment.etc."mailman3/settings.py".text = ''
390 import os
391
392 # Required by mailman_web.settings, but will be overridden when
393 # settings_local.json is loaded.
394 os.environ["SECRET_KEY"] = ""
395
396 from mailman_web.settings.base import *
397 from mailman_web.settings.mailman import *
398
399 import json
400
401 with open('${webSettingsJSON}') as f:
402 globals().update(json.load(f))
403
404 with open('/var/lib/mailman-web/settings_local.json') as f:
405 globals().update(json.load(f))
406
407 ${optionalString (cfg.restApiPassFile != null) ''
408 with open('${cfg.restApiPassFile}') as f:
409 MAILMAN_REST_API_PASS = f.read().rstrip('\n')
410 ''}
411
412 ${optionalString (cfg.ldap.enable) ''
413 import ldap
414 from django_auth_ldap.config import LDAPSearch, ${cfg.ldap.groupSearch.type}
415 AUTH_LDAP_SERVER_URI = "${cfg.ldap.serverUri}"
416 AUTH_LDAP_BIND_DN = "${cfg.ldap.bindDn}"
417 with open("${cfg.ldap.bindPasswordFile}") as f:
418 AUTH_LDAP_BIND_PASSWORD = f.read().rstrip('\n')
419 AUTH_LDAP_USER_SEARCH = LDAPSearch("${cfg.ldap.userSearch.ou}",
420 ldap.SCOPE_SUBTREE, "${cfg.ldap.userSearch.query}")
421 AUTH_LDAP_GROUP_TYPE = ${cfg.ldap.groupSearch.type}()
422 AUTH_LDAP_GROUP_SEARCH = LDAPSearch("${cfg.ldap.groupSearch.ou}",
423 ldap.SCOPE_SUBTREE, "${cfg.ldap.groupSearch.query}")
424 AUTH_LDAP_USER_ATTR_MAP = {
425 ${concatStrings (flip mapAttrsToList cfg.ldap.attrMap (key: value: ''
426 "${key}": "${value}",
427 ''))}
428 }
429 ${optionalString (cfg.ldap.superUserGroup != null) ''
430 AUTH_LDAP_USER_FLAGS_BY_GROUP = {
431 "is_superuser": "${cfg.ldap.superUserGroup}"
432 }
433 ''}
434 AUTHENTICATION_BACKENDS = (
435 "django_auth_ldap.backend.LDAPBackend",
436 "django.contrib.auth.backends.ModelBackend"
437 )
438 ''}
439 '';
440
441 services.nginx = mkIf (cfg.serve.enable && cfg.webHosts != []) {
442 enable = mkDefault true;
443 virtualHosts = lib.genAttrs cfg.webHosts (webHost: {
444 locations = {
445 ${cfg.serve.virtualRoot}.extraConfig = "uwsgi_pass unix:/run/mailman-web.socket;";
446 "${cfg.serve.virtualRoot}/static/".alias = webSettings.STATIC_ROOT + "/";
447 };
448 });
449 };
450
451 environment.systemPackages = [ (pkgs.buildEnv {
452 name = "mailman-tools";
453 # We don't want to pollute the system PATH with a python
454 # interpreter etc. so let's pick only the stuff we actually
455 # want from {web,mailman}Env
456 pathsToLink = ["/bin"];
457 paths = [ mailmanEnv webEnv ];
458 # Only mailman-related stuff is installed, the rest is removed
459 # in `postBuild`.
460 ignoreCollisions = true;
461 postBuild = ''
462 find $out/bin/ -mindepth 1 -not -name "mailman*" -delete
463 '';
464 }) ];
465
466 services.postfix = lib.mkIf cfg.enablePostfix {
467 recipientDelimiter = "+"; # bake recipient addresses in mail envelopes via VERP
468 config = {
469 owner_request_special = "no"; # Mailman handles -owner addresses on its own
470 };
471 };
472
473 systemd.sockets.mailman-uwsgi = lib.mkIf cfg.serve.enable {
474 wantedBy = ["sockets.target"];
475 before = ["nginx.service"];
476 socketConfig.ListenStream = "/run/mailman-web.socket";
477 };
478 systemd.services = {
479 mailman = {
480 description = "GNU Mailman Master Process";
481 before = lib.optional cfg.enablePostfix "postfix.service";
482 after = [ "network.target" ]
483 ++ lib.optional cfg.enablePostfix "postfix-setup.service"
484 ++ lib.optional withPostgresql "postgresql.service";
485 restartTriggers = [ mailmanCfgFile ];
486 requires = optional withPostgresql "postgresql.service";
487 wantedBy = [ "multi-user.target" ];
488 serviceConfig = {
489 ExecStart = "${mailmanEnv}/bin/mailman start";
490 ExecStop = "${mailmanEnv}/bin/mailman stop";
491 User = "mailman";
492 Group = "mailman";
493 Type = "forking";
494 RuntimeDirectory = "mailman";
495 LogsDirectory = "mailman";
496 PIDFile = "/run/mailman/master.pid";
497 };
498 };
499
500 mailman-settings = {
501 description = "Generate settings files (including secrets) for Mailman";
502 before = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ];
503 requiredBy = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ];
504 path = with pkgs; [ jq ];
505 after = optional withPostgresql "postgresql.service";
506 requires = optional withPostgresql "postgresql.service";
507 serviceConfig.Type = "oneshot";
508 script = ''
509 install -m0750 -o mailman -g mailman ${mailmanCfgFile} /etc/mailman.cfg
510 ${optionalString (cfg.restApiPassFile != null) ''
511 ${pkgs.replace-secret}/bin/replace-secret \
512 '#NIXOS_MAILMAN_REST_API_PASS_SECRET#' \
513 ${cfg.restApiPassFile} \
514 /etc/mailman.cfg
515 ''}
516
517 mailmanDir=/var/lib/mailman
518 mailmanWebDir=/var/lib/mailman-web
519
520 mailmanCfg=$mailmanDir/mailman-hyperkitty.cfg
521 mailmanWebCfg=$mailmanWebDir/settings_local.json
522
523 install -m 0775 -o mailman -g mailman -d /var/lib/mailman-web-static
524 install -m 0770 -o mailman -g mailman -d $mailmanDir
525 install -m 0770 -o ${cfg.webUser} -g mailman -d $mailmanWebDir
526
527 if [ ! -e $mailmanWebCfg ]; then
528 hyperkittyApiKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)
529 secretKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)
530
531 mailmanWebCfgTmp=$(mktemp)
532 jq -n '.MAILMAN_ARCHIVER_KEY=$archiver_key | .SECRET_KEY=$secret_key' \
533 --arg archiver_key "$hyperkittyApiKey" \
534 --arg secret_key "$secretKey" \
535 >"$mailmanWebCfgTmp"
536 chown root:mailman "$mailmanWebCfgTmp"
537 chmod 440 "$mailmanWebCfgTmp"
538 mv -n "$mailmanWebCfgTmp" "$mailmanWebCfg"
539 fi
540
541 hyperkittyApiKey="$(jq -r .MAILMAN_ARCHIVER_KEY "$mailmanWebCfg")"
542 mailmanCfgTmp=$(mktemp)
543 sed "s/@API_KEY@/$hyperkittyApiKey/g" ${mailmanHyperkittyCfg} >"$mailmanCfgTmp"
544 chown mailman:mailman "$mailmanCfgTmp"
545 mv "$mailmanCfgTmp" "$mailmanCfg"
546 '';
547 };
548
549 mailman-web-setup = {
550 description = "Prepare mailman-web files and database";
551 before = [ "mailman-uwsgi.service" ];
552 requiredBy = [ "mailman-uwsgi.service" ];
553 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
554 script = ''
555 [[ -e "${webSettings.STATIC_ROOT}" ]] && find "${webSettings.STATIC_ROOT}/" -mindepth 1 -delete
556 ${webEnv}/bin/mailman-web migrate
557 ${webEnv}/bin/mailman-web collectstatic
558 ${webEnv}/bin/mailman-web compress
559 '';
560 serviceConfig = {
561 User = cfg.webUser;
562 Group = "mailman";
563 Type = "oneshot";
564 WorkingDirectory = "/var/lib/mailman-web";
565 };
566 };
567
568 mailman-uwsgi = mkIf cfg.serve.enable (let
569 uwsgiConfig.uwsgi = {
570 type = "normal";
571 plugins = ["python3"];
572 home = webEnv;
573 manage-script-name = true;
574 mount = "${cfg.serve.virtualRoot}=mailman_web.wsgi:application";
575 http = "127.0.0.1:18507";
576 };
577 uwsgiConfigFile = pkgs.writeText "uwsgi-mailman.json" (builtins.toJSON uwsgiConfig);
578 in {
579 wantedBy = ["multi-user.target"];
580 after = optional withPostgresql "postgresql.service";
581 requires = ["mailman-uwsgi.socket" "mailman-web-setup.service"]
582 ++ optional withPostgresql "postgresql.service";
583 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
584 serviceConfig = {
585 # Since the mailman-web settings.py obstinately creates a logs
586 # dir in the cwd, change to the (writable) runtime directory before
587 # starting uwsgi.
588 ExecStart = "${pkgs.coreutils}/bin/env -C $RUNTIME_DIRECTORY ${pkgs.uwsgi.override { plugins = ["python3"]; }}/bin/uwsgi --json ${uwsgiConfigFile}";
589 User = cfg.webUser;
590 Group = "mailman";
591 RuntimeDirectory = "mailman-uwsgi";
592 };
593 });
594
595 mailman-daily = {
596 description = "Trigger daily Mailman events";
597 startAt = "daily";
598 restartTriggers = [ mailmanCfgFile ];
599 serviceConfig = {
600 ExecStart = "${mailmanEnv}/bin/mailman digests --send";
601 User = "mailman";
602 Group = "mailman";
603 };
604 };
605
606 hyperkitty = lib.mkIf cfg.hyperkitty.enable {
607 description = "GNU Hyperkitty QCluster Process";
608 after = [ "network.target" ];
609 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
610 wantedBy = [ "mailman.service" "multi-user.target" ];
611 serviceConfig = {
612 ExecStart = "${webEnv}/bin/mailman-web qcluster";
613 User = cfg.webUser;
614 Group = "mailman";
615 WorkingDirectory = "/var/lib/mailman-web";
616 };
617 };
618 } // flip lib.mapAttrs' {
619 "minutely" = "minutely";
620 "quarter_hourly" = "*:00/15";
621 "hourly" = "hourly";
622 "daily" = "daily";
623 "weekly" = "weekly";
624 "yearly" = "yearly";
625 } (name: startAt:
626 lib.nameValuePair "hyperkitty-${name}" (lib.mkIf cfg.hyperkitty.enable {
627 description = "Trigger ${name} Hyperkitty events";
628 inherit startAt;
629 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
630 serviceConfig = {
631 ExecStart = "${webEnv}/bin/mailman-web runjobs ${name}";
632 User = cfg.webUser;
633 Group = "mailman";
634 WorkingDirectory = "/var/lib/mailman-web";
635 };
636 }));
637 };
638
639 meta = {
640 maintainers = with lib.maintainers; [ lheckemann qyliss ma27 ];
641 doc = ./mailman.xml;
642 };
643
644}