1 { config, pkgs, lib, ... }: # mailman.nix
7 cfg = config.services.mailman;
9 inherit (pkgs.mailmanPackages.buildEnvs { withHyperkitty = cfg.hyperkitty.enable; withLDAP = cfg.ldap.enable; })
12 withPostgresql = config.services.postgresql.enable;
14 # This deliberately doesn't use recursiveUpdate so users can
15 # override the defaults.
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";
25 disable_existing_loggers = true;
26 handlers.console.class = "logging.StreamHandler";
28 handlers = [ "console" ];
32 HAYSTACK_CONNECTIONS.default = {
33 ENGINE = "haystack.backends.whoosh_backend.WhooshEngine";
34 PATH = "/var/lib/mailman-web/fulltext-index";
38 webSettingsJSON = pkgs.writeText "settings.json" (builtins.toJSON webSettings);
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" ''
43 postmap_command: ${pkgs.postfix}/bin/postmap
44 transport_file_type: hash
47 mailmanCfg = lib.generators.toINI {}
48 (recursiveUpdate cfg.settings
49 ((optionalAttrs (cfg.restApiPassFile != null) {
50 webservice.admin_pass = "#NIXOS_MAILMAN_REST_API_PASS_SECRET#";
53 mailmanCfgFile = pkgs.writeText "mailman-raw.cfg" mailmanCfg;
55 mailmanHyperkittyCfg = pkgs.writeText "mailman-hyperkitty.cfg" ''
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}
63 # Shared API key, must be the identical to the value in HyperKitty's
73 (mkRenamedOptionModule [ "services" "mailman" "hyperkittyBaseUrl" ]
74 [ "services" "mailman" "hyperkitty" "baseUrl" ])
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.
81 (mkRemovedOptionModule [ "services" "mailman" "package" ] ''
82 Didn't have an effect for several years.
93 description = lib.mdDoc "Enable Mailman on this host. Requires an active MTA on the host (e.g. Postfix).";
97 enable = mkEnableOption (lib.mdDoc "LDAP auth");
98 serverUri = mkOption {
100 example = "ldaps://ldap.host";
101 description = lib.mdDoc ''
102 LDAP host to connect against.
107 example = "cn=root,dc=nixos,dc=org";
108 description = lib.mdDoc ''
109 Service account to bind against.
112 bindPasswordFile = mkOption {
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).
120 superUserGroup = mkOption {
121 type = types.nullOr types.str;
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.
131 example = "(&(objectClass=inetOrgPerson)(|(uid=%(user)s)(mail=%(user)s)))";
132 description = lib.mdDoc ''
133 Query to find a user in the LDAP database.
138 example = "ou=users,dc=nixos,dc=org";
139 description = lib.mdDoc ''
140 Organizational unit to look up a user.
147 "posixGroup" "groupOfNames" "memberDNGroup" "nestedMemberDNGroup" "nestedGroupOfNames"
148 "groupOfUniqueNames" "nestedGroupOfUniqueNames" "activeDirectoryGroup" "nestedActiveDirectoryGroup"
149 "organizationalRoleGroup" "nestedOrganizationalRoleGroup"
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.
159 example = "(objectClass=groupOfNames)";
160 description = lib.mdDoc ''
161 Query to find a group associated to a user in the LDAP database.
166 example = "ou=groups,dc=nixos,dc=org";
167 description = lib.mdDoc ''
168 Organizational unit to look up a group.
173 username = mkOption {
176 description = lib.mdDoc ''
177 LDAP-attribute that corresponds to the `username`-attribute in mailman.
180 firstName = mkOption {
181 default = "givenName";
183 description = lib.mdDoc ''
184 LDAP-attribute that corresponds to the `firstName`-attribute in mailman.
187 lastName = mkOption {
190 description = lib.mdDoc ''
191 LDAP-attribute that corresponds to the `lastName`-attribute in mailman.
197 description = lib.mdDoc ''
198 LDAP-attribute that corresponds to the `email`-attribute in mailman.
204 enablePostfix = mkOption {
208 description = lib.mdDoc ''
209 Enable Postfix integration. Requires an active Postfix installation.
211 If you want to use another MTA, set this option to false and configure
212 settings in services.mailman.settings.mta.
214 Refer to the Mailman manual for more info.
218 siteOwner = mkOption {
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.
228 webHosts = mkOption {
229 type = types.listOf types.str;
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
242 default = "mailman-web";
243 description = lib.mdDoc ''
244 User to run mailman-web as
248 webSettings = mkOption {
251 description = lib.mdDoc ''
252 Overrides for the default mailman-web Django settings.
256 restApiPassFile = mkOption {
258 type = types.nullOr types.str;
259 description = lib.mdDoc ''
260 Path to the file containing the value for `MAILMAN_REST_API_PASS`.
265 enable = mkEnableOption (lib.mdDoc "Automatic nginx and uwsgi setup for mailman-web");
267 virtualRoot = mkOption {
269 example = lib.literalExpression "/lists";
271 description = lib.mdDoc ''
272 Path to mount the mailman-web django application on.
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;
283 settings = mkOption {
284 description = lib.mdDoc "Settings for mailman.cfg";
285 type = types.attrsOf (types.attrsOf types.str);
290 enable = mkEnableOption (lib.mdDoc "the Hyperkitty archiver for Mailman");
294 default = "http://localhost:18507/archives/";
295 description = lib.mdDoc ''
296 Where can Mailman connect to Hyperkitty's internal API, preferably on
305 ###### implementation
307 config = mkIf cfg.enable {
309 services.mailman.settings = {
310 mailman.site_owner = lib.mkDefault cfg.siteOwner;
311 mailman.layout = "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";
321 pid_file = "/run/mailman/master.pid";
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.");
326 "archiver.hyperkitty" = lib.mkIf cfg.hyperkitty.enable {
327 class = "mailman_hyperkitty.Archiver";
329 configuration = "/var/lib/mailman/mailman-hyperkitty.cfg";
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"; })
338 inherit (config.services) postfix;
340 requirePostfixHash = optionPath: dataFile:
343 expected = "hash:/var/lib/mailman/data/${dataFile}";
344 value = attrByPath optionPath [] postfix;
346 { assertion = postfix.enable -> isList value && elem expected value;
348 services.postfix.${concatStringsSep "." optionPath} must contain
350 See <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>.
354 { assertion = cfg.webHosts != [];
356 services.mailman.serve.enable requires there to be at least one entry
357 in services.mailman.webHosts.
360 ] ++ (lib.optionals cfg.enablePostfix [
361 { assertion = postfix.enable;
363 Mailman's default NixOS configuration requires Postfix to be enabled.
365 If you want to use another MTA, set services.mailman.enablePostfix
366 to false and configure settings in services.mailman.settings.mta.
368 Refer to <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>
372 (requirePostfixHash [ "relayDomains" ] "postfix_domains")
373 (requirePostfixHash [ "config" "transport_maps" ] "postfix_lmtp")
374 (requirePostfixHash [ "config" "local_recipient_maps" ] "postfix_lmtp")
377 users.users.mailman = {
378 description = "GNU Mailman";
382 users.users.mailman-web = lib.mkIf (cfg.webUser == "mailman-web") {
383 description = "GNU Mailman web interface";
387 users.groups.mailman = {};
389 environment.etc."mailman3/settings.py".text = ''
392 # Required by mailman_web.settings, but will be overridden when
393 # settings_local.json is loaded.
394 os.environ["SECRET_KEY"] = ""
396 from mailman_web.settings.base import *
397 from mailman_web.settings.mailman import *
401 with open('${webSettingsJSON}') as f:
402 globals().update(json.load(f))
404 with open('/var/lib/mailman-web/settings_local.json') as f:
405 globals().update(json.load(f))
407 ${optionalString (cfg.restApiPassFile != null) ''
408 with open('${cfg.restApiPassFile}') as f:
409 MAILMAN_REST_API_PASS = f.read().rstrip('\n')
412 ${optionalString (cfg.ldap.enable) ''
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}",
429 ${optionalString (cfg.ldap.superUserGroup != null) ''
430 AUTH_LDAP_USER_FLAGS_BY_GROUP = {
431 "is_superuser": "${cfg.ldap.superUserGroup}"
434 AUTHENTICATION_BACKENDS = (
435 "django_auth_ldap.backend.LDAPBackend",
436 "django.contrib.auth.backends.ModelBackend"
441 services.nginx = mkIf (cfg.serve.enable && cfg.webHosts != []) {
442 enable = mkDefault true;
443 virtualHosts = lib.genAttrs cfg.webHosts (webHost: {
445 ${cfg.serve.virtualRoot}.extraConfig = "uwsgi_pass unix:/run/mailman-web.socket;";
446 "${cfg.serve.virtualRoot}/static/".alias = webSettings.STATIC_ROOT + "/";
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
460 ignoreCollisions = true;
462 find $out/bin/ -mindepth 1 -not -name "mailman*" -delete
466 services.postfix = lib.mkIf cfg.enablePostfix {
467 recipientDelimiter = "+"; # bake recipient addresses in mail envelopes via VERP
469 owner_request_special = "no"; # Mailman handles -owner addresses on its own
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";
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" ];
489 ExecStart = "${mailmanEnv}/bin/mailman start";
490 ExecStop = "${mailmanEnv}/bin/mailman stop";
494 RuntimeDirectory = "mailman";
495 LogsDirectory = "mailman";
496 PIDFile = "/run/mailman/master.pid";
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";
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} \
517 mailmanDir=/var/lib/mailman
518 mailmanWebDir=/var/lib/mailman-web
520 mailmanCfg=$mailmanDir/mailman-hyperkitty.cfg
521 mailmanWebCfg=$mailmanWebDir/settings_local.json
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
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)
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" \
536 chown root:mailman "$mailmanWebCfgTmp"
537 chmod 440 "$mailmanWebCfgTmp"
538 mv -n "$mailmanWebCfgTmp" "$mailmanWebCfg"
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"
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 ];
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
564 WorkingDirectory = "/var/lib/mailman-web";
568 mailman-uwsgi = mkIf cfg.serve.enable (let
569 uwsgiConfig.uwsgi = {
571 plugins = ["python3"];
573 manage-script-name = true;
574 mount = "${cfg.serve.virtualRoot}=mailman_web.wsgi:application";
575 http = "127.0.0.1:18507";
577 uwsgiConfigFile = pkgs.writeText "uwsgi-mailman.json" (builtins.toJSON uwsgiConfig);
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 ];
585 # Since the mailman-web settings.py obstinately creates a logs
586 # dir in the cwd, change to the (writable) runtime directory before
588 ExecStart = "${pkgs.coreutils}/bin/env -C $RUNTIME_DIRECTORY ${pkgs.uwsgi.override { plugins = ["python3"]; }}/bin/uwsgi --json ${uwsgiConfigFile}";
591 RuntimeDirectory = "mailman-uwsgi";
596 description = "Trigger daily Mailman events";
598 restartTriggers = [ mailmanCfgFile ];
600 ExecStart = "${mailmanEnv}/bin/mailman digests --send";
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" ];
612 ExecStart = "${webEnv}/bin/mailman-web qcluster";
615 WorkingDirectory = "/var/lib/mailman-web";
618 } // flip lib.mapAttrs' {
619 "minutely" = "minutely";
620 "quarter_hourly" = "*:00/15";
626 lib.nameValuePair "hyperkitty-${name}" (lib.mkIf cfg.hyperkitty.enable {
627 description = "Trigger ${name} Hyperkitty events";
629 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
631 ExecStart = "${webEnv}/bin/mailman-web runjobs ${name}";
634 WorkingDirectory = "/var/lib/mailman-web";
640 maintainers = with lib.maintainers; [ lheckemann qyliss ma27 ];