1 { config, pkgs, lib, ... }:
4 cfg = config.services.mailman;
6 inherit (pkgs.mailmanPackages.buildEnvs { withHyperkitty = cfg.hyperkitty.enable; withLDAP = cfg.ldap.enable; })
9 withPostgresql = config.services.postgresql.enable;
11 # This deliberately doesn't use recursiveUpdate so users can
12 # override the defaults.
14 DEFAULT_FROM_EMAIL = cfg.siteOwner;
15 SERVER_EMAIL = cfg.siteOwner;
16 ALLOWED_HOSTS = [ "localhost" "127.0.0.1" ] ++ cfg.webHosts;
17 COMPRESS_OFFLINE = true;
18 STATIC_ROOT = "/var/lib/mailman-web-static";
19 MEDIA_ROOT = "/var/lib/mailman-web/media";
22 disable_existing_loggers = true;
23 handlers.console.class = "logging.StreamHandler";
25 handlers = [ "console" ];
29 HAYSTACK_CONNECTIONS.default = {
30 ENGINE = "haystack.backends.whoosh_backend.WhooshEngine";
31 PATH = "/var/lib/mailman-web/fulltext-index";
33 } // lib.optionalAttrs cfg.enablePostfix {
34 EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend";
35 EMAIL_HOST = "127.0.0.1";
39 webSettingsJSON = pkgs.writeText "settings.json" (builtins.toJSON webSettings);
41 # TODO: Should this be RFC42-ised so that users can set additional options without modifying the module?
42 postfixMtaConfig = pkgs.writeText "mailman-postfix.cfg" ''
44 postmap_command: ${pkgs.postfix}/bin/postmap
45 transport_file_type: hash
48 mailmanCfg = lib.generators.toINI {} (lib.recursiveUpdate cfg.settings {
49 webservice.admin_pass = "#NIXOS_MAILMAN_REST_API_PASS_SECRET#";
52 mailmanCfgFile = pkgs.writeText "mailman-raw.cfg" mailmanCfg;
54 mailmanHyperkittyCfg = pkgs.writeText "mailman-hyperkitty.cfg" ''
56 # This is your HyperKitty installation, preferably on the localhost. This
57 # address will be used by Mailman to forward incoming emails to HyperKitty
58 # for archiving. It does not need to be publicly available, in fact it's
59 # better if it is not.
60 base_url: ${cfg.hyperkitty.baseUrl}
62 # Shared API key, must be the identical to the value in HyperKitty's
72 (lib.mkRenamedOptionModule [ "services" "mailman" "hyperkittyBaseUrl" ]
73 [ "services" "mailman" "hyperkitty" "baseUrl" ])
75 (lib.mkRemovedOptionModule [ "services" "mailman" "hyperkittyApiKey" ] ''
76 The Hyperkitty API key is now generated on first run, and not
77 stored in the world-readable Nix store. To continue using
78 Hyperkitty, you must set services.mailman.hyperkitty.enable = true.
80 (lib.mkRemovedOptionModule [ "services" "mailman" "package" ] ''
81 Didn't have an effect for several years.
89 enable = lib.mkOption {
90 type = lib.types.bool;
92 description = "Enable Mailman on this host. Requires an active MTA on the host (e.g. Postfix).";
96 enable = lib.mkEnableOption "LDAP auth";
97 serverUri = lib.mkOption {
99 example = "ldaps://ldap.host";
101 LDAP host to connect against.
104 bindDn = lib.mkOption {
105 type = lib.types.str;
106 example = "cn=root,dc=nixos,dc=org";
108 Service account to bind against.
111 bindPasswordFile = lib.mkOption {
112 type = lib.types.str;
113 example = "/run/secrets/ldap-bind";
115 Path to the file containing the bind password of the service account
116 defined by [](#opt-services.mailman.ldap.bindDn).
119 superUserGroup = lib.mkOption {
120 type = lib.types.nullOr lib.types.str;
122 example = "cn=admin,ou=groups,dc=nixos,dc=org";
124 Group where a user must be a member of to gain superuser rights.
128 query = lib.mkOption {
129 type = lib.types.str;
130 example = "(&(objectClass=inetOrgPerson)(|(uid=%(user)s)(mail=%(user)s)))";
132 Query to find a user in the LDAP database.
136 type = lib.types.str;
137 example = "ou=users,dc=nixos,dc=org";
139 Organizational unit to look up a user.
144 type = lib.mkOption {
145 type = lib.types.enum [
146 "posixGroup" "groupOfNames" "memberDNGroup" "nestedMemberDNGroup" "nestedGroupOfNames"
147 "groupOfUniqueNames" "nestedGroupOfUniqueNames" "activeDirectoryGroup" "nestedActiveDirectoryGroup"
148 "organizationalRoleGroup" "nestedOrganizationalRoleGroup"
150 default = "posixGroup";
151 apply = v: "${lib.toUpper (lib.substring 0 1 v)}${lib.substring 1 (lib.stringLength v) v}Type";
153 Type of group to perform a group search against.
156 query = lib.mkOption {
157 type = lib.types.str;
158 example = "(objectClass=groupOfNames)";
160 Query to find a group associated to a user in the LDAP database.
164 type = lib.types.str;
165 example = "ou=groups,dc=nixos,dc=org";
167 Organizational unit to look up a group.
172 username = lib.mkOption {
174 type = lib.types.str;
176 LDAP-attribute that corresponds to the `username`-attribute in mailman.
179 firstName = lib.mkOption {
180 default = "givenName";
181 type = lib.types.str;
183 LDAP-attribute that corresponds to the `firstName`-attribute in mailman.
186 lastName = lib.mkOption {
188 type = lib.types.str;
190 LDAP-attribute that corresponds to the `lastName`-attribute in mailman.
193 email = lib.mkOption {
195 type = lib.types.str;
197 LDAP-attribute that corresponds to the `email`-attribute in mailman.
203 enablePostfix = lib.mkOption {
204 type = lib.types.bool;
208 Enable Postfix integration. Requires an active Postfix installation.
210 If you want to use another MTA, set this option to false and configure
211 settings in services.mailman.settings.mta.
213 Refer to the Mailman manual for more info.
217 siteOwner = lib.mkOption {
218 type = lib.types.str;
219 example = "postmaster@example.org";
221 Certain messages that must be delivered to a human, but which can't
222 be delivered to a list owner (e.g. a bounce from a list owner), will
223 be sent to this address. It should point to a human.
227 webHosts = lib.mkOption {
228 type = lib.types.listOf lib.types.str;
231 The list of hostnames and/or IP addresses from which the Mailman Web
232 UI will accept requests. By default, "localhost" and "127.0.0.1" are
233 enabled. All additional names under which your web server accepts
234 requests for the UI must be listed here or incoming requests will be
239 webUser = lib.mkOption {
240 type = lib.types.str;
241 default = "mailman-web";
243 User to run mailman-web as
247 webSettings = lib.mkOption {
248 type = lib.types.attrs;
251 Overrides for the default mailman-web Django settings.
255 restApiPassFile = lib.mkOption {
257 type = lib.types.nullOr lib.types.str;
259 Path to the file containing the value for `MAILMAN_REST_API_PASS`.
264 enable = lib.mkEnableOption "automatic nginx and uwsgi setup for mailman-web";
266 virtualRoot = lib.mkOption {
268 example = lib.literalExpression "/lists";
269 type = lib.types.str;
271 Path to mount the mailman-web django application on.
276 extraPythonPackages = lib.mkOption {
277 description = "Packages to add to the python environment used by mailman and mailman-web";
278 type = lib.types.listOf lib.types.package;
282 settings = lib.mkOption {
283 description = "Settings for mailman.cfg";
284 type = lib.types.attrsOf (lib.types.attrsOf lib.types.str);
289 enable = lib.mkEnableOption "the Hyperkitty archiver for Mailman";
291 baseUrl = lib.mkOption {
292 type = lib.types.str;
293 default = "http://localhost:18507/archives/";
295 Where can Mailman connect to Hyperkitty's internal API, preferably on
304 ###### implementation
306 config = lib.mkIf cfg.enable {
308 services.mailman.settings = {
309 mailman.site_owner = lib.mkDefault cfg.siteOwner;
310 mailman.layout = "fhs";
313 bin_dir = "${pkgs.mailmanPackages.mailman}/bin";
314 var_dir = "/var/lib/mailman";
315 queue_dir = "$var_dir/queue";
316 template_dir = "$var_dir/templates";
317 log_dir = "/var/log/mailman";
318 lock_dir = "/run/mailman/lock";
320 pid_file = "/run/mailman/master.pid";
323 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 "archiver.hyperkitty" = lib.mkIf cfg.hyperkitty.enable {
326 class = "mailman_hyperkitty.Archiver";
328 configuration = "/var/lib/mailman/mailman-hyperkitty.cfg";
331 loggerNames = ["root" "archiver" "bounce" "config" "database" "debug" "error" "fromusenet" "http" "locks" "mischief" "plugins" "runner" "smtp"];
332 loggerSectionNames = map (n: "logging.${n}") loggerNames;
333 in lib.genAttrs loggerSectionNames(name: { handler = "stderr"; })
337 inherit (config.services) postfix;
339 requirePostfixHash = optionPath: dataFile:
341 expected = "hash:/var/lib/mailman/data/${dataFile}";
342 value = lib.attrByPath optionPath [] postfix;
344 { assertion = postfix.enable -> lib.isList value && lib.elem expected value;
346 services.postfix.${lib.concatStringsSep "." optionPath} must contain
348 See <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>.
352 { assertion = cfg.webHosts != [];
354 services.mailman.serve.enable requires there to be at least one entry
355 in services.mailman.webHosts.
358 ] ++ (lib.optionals cfg.enablePostfix [
359 { assertion = postfix.enable;
361 Mailman's default NixOS configuration requires Postfix to be enabled.
363 If you want to use another MTA, set services.mailman.enablePostfix
364 to false and configure settings in services.mailman.settings.mta.
366 Refer to <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>
370 (requirePostfixHash [ "config" "relay_domains" ] "postfix_domains")
371 (requirePostfixHash [ "config" "transport_maps" ] "postfix_lmtp")
372 (requirePostfixHash [ "config" "local_recipient_maps" ] "postfix_lmtp")
375 users.users.mailman = {
376 description = "GNU Mailman";
380 users.users.mailman-web = lib.mkIf (cfg.webUser == "mailman-web") {
381 description = "GNU Mailman web interface";
385 users.groups.mailman = {};
387 environment.etc."mailman3/settings.py".text = ''
389 from configparser import ConfigParser
391 # Required by mailman_web.settings, but will be overridden when
392 # settings_local.json is loaded.
393 os.environ["SECRET_KEY"] = ""
395 from mailman_web.settings.base import *
396 from mailman_web.settings.mailman import *
400 with open('${webSettingsJSON}') as f:
401 globals().update(json.load(f))
403 with open('/var/lib/mailman-web/settings_local.json') as f:
404 globals().update(json.load(f))
406 with open('/etc/mailman.cfg') as f:
407 config = ConfigParser()
409 MAILMAN_REST_API_PASS = config['webservice']['admin_pass']
411 ${lib.optionalString (cfg.ldap.enable) ''
413 from django_auth_ldap.config import LDAPSearch, ${cfg.ldap.groupSearch.type}
414 AUTH_LDAP_SERVER_URI = "${cfg.ldap.serverUri}"
415 AUTH_LDAP_BIND_DN = "${cfg.ldap.bindDn}"
416 with open("${cfg.ldap.bindPasswordFile}") as f:
417 AUTH_LDAP_BIND_PASSWORD = f.read().rstrip('\n')
418 AUTH_LDAP_USER_SEARCH = LDAPSearch("${cfg.ldap.userSearch.ou}",
419 ldap.SCOPE_SUBTREE, "${cfg.ldap.userSearch.query}")
420 AUTH_LDAP_GROUP_TYPE = ${cfg.ldap.groupSearch.type}()
421 AUTH_LDAP_GROUP_SEARCH = LDAPSearch("${cfg.ldap.groupSearch.ou}",
422 ldap.SCOPE_SUBTREE, "${cfg.ldap.groupSearch.query}")
423 AUTH_LDAP_USER_ATTR_MAP = {
424 ${lib.concatStrings (lib.flip lib.mapAttrsToList cfg.ldap.attrMap (key: value: ''
425 "${key}": "${value}",
428 ${lib.optionalString (cfg.ldap.superUserGroup != null) ''
429 AUTH_LDAP_USER_FLAGS_BY_GROUP = {
430 "is_superuser": "${cfg.ldap.superUserGroup}"
433 AUTHENTICATION_BACKENDS = (
434 "django_auth_ldap.backend.LDAPBackend",
435 "django.contrib.auth.backends.ModelBackend"
440 services.nginx = lib.mkIf (cfg.serve.enable && cfg.webHosts != []) {
441 enable = lib.mkDefault true;
442 virtualHosts = lib.genAttrs cfg.webHosts (webHost: {
444 ${cfg.serve.virtualRoot}.extraConfig = "uwsgi_pass unix:/run/mailman-web.socket;";
445 "${lib.removeSuffix "/" cfg.serve.virtualRoot}/static/".alias = webSettings.STATIC_ROOT + "/";
448 proxyTimeout = lib.mkDefault "120s";
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 = lib.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";
497 Restart = "on-failure";
498 TimeoutStartSec = 180;
499 TimeoutStopSec = 180;
504 description = "Generate settings files (including secrets) for Mailman";
505 before = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ];
506 requiredBy = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ];
507 path = with pkgs; [ jq ];
508 after = lib.optional withPostgresql "postgresql.service";
509 requires = lib.optional withPostgresql "postgresql.service";
510 serviceConfig.RemainAfterExit = true;
511 serviceConfig.Type = "oneshot";
513 install -m0750 -o mailman -g mailman ${mailmanCfgFile} /etc/mailman.cfg
514 ${if cfg.restApiPassFile == null then ''
515 sed -i "s/#NIXOS_MAILMAN_REST_API_PASS_SECRET#/$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)/g" \
518 ${pkgs.replace-secret}/bin/replace-secret \
519 '#NIXOS_MAILMAN_REST_API_PASS_SECRET#' \
520 ${cfg.restApiPassFile} \
524 mailmanDir=/var/lib/mailman
525 mailmanWebDir=/var/lib/mailman-web
527 mailmanCfg=$mailmanDir/mailman-hyperkitty.cfg
528 mailmanWebCfg=$mailmanWebDir/settings_local.json
530 install -m 0775 -o mailman -g mailman -d /var/lib/mailman-web-static
531 install -m 0770 -o mailman -g mailman -d $mailmanDir
532 install -m 0770 -o ${cfg.webUser} -g mailman -d $mailmanWebDir
534 if [ ! -e $mailmanWebCfg ]; then
535 hyperkittyApiKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)
536 secretKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)
538 install -m 0440 -o root -g mailman \
539 <(jq -n '.MAILMAN_ARCHIVER_KEY=$archiver_key | .SECRET_KEY=$secret_key' \
540 --arg archiver_key "$hyperkittyApiKey" \
541 --arg secret_key "$secretKey") \
545 hyperkittyApiKey="$(jq -r .MAILMAN_ARCHIVER_KEY "$mailmanWebCfg")"
546 mailmanCfgTmp=$(mktemp)
547 sed "s/@API_KEY@/$hyperkittyApiKey/g" ${mailmanHyperkittyCfg} >"$mailmanCfgTmp"
548 chown mailman:mailman "$mailmanCfgTmp"
549 mv "$mailmanCfgTmp" "$mailmanCfg"
553 mailman-web-setup = {
554 description = "Prepare mailman-web files and database";
555 before = [ "mailman-uwsgi.service" ];
556 requiredBy = [ "mailman-uwsgi.service" ];
557 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
559 [[ -e "${webSettings.STATIC_ROOT}" ]] && find "${webSettings.STATIC_ROOT}/" -mindepth 1 -delete
560 ${webEnv}/bin/mailman-web migrate
561 ${webEnv}/bin/mailman-web collectstatic
562 ${webEnv}/bin/mailman-web compress
568 WorkingDirectory = "/var/lib/mailman-web";
572 mailman-uwsgi = lib.mkIf cfg.serve.enable (let
573 uwsgiConfig.uwsgi = {
575 plugins = ["python3"];
577 http = "127.0.0.1:18507";
579 // (if cfg.serve.virtualRoot == "/"
580 then { module = "mailman_web.wsgi:application"; }
582 mount = "${cfg.serve.virtualRoot}=mailman_web.wsgi:application";
583 manage-script-name = true;
585 uwsgiConfigFile = pkgs.writeText "uwsgi-mailman.json" (builtins.toJSON uwsgiConfig);
587 wantedBy = ["multi-user.target"];
588 after = lib.optional withPostgresql "postgresql.service";
589 requires = ["mailman-uwsgi.socket" "mailman-web-setup.service"]
590 ++ lib.optional withPostgresql "postgresql.service";
591 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
593 # Since the mailman-web settings.py obstinately creates a logs
594 # dir in the cwd, change to the (writable) runtime directory before
596 ExecStart = "${pkgs.coreutils}/bin/env -C $RUNTIME_DIRECTORY ${pkgs.uwsgi.override { plugins = ["python3"]; python3 = webEnv.python; }}/bin/uwsgi --json ${uwsgiConfigFile}";
599 RuntimeDirectory = "mailman-uwsgi";
600 Restart = "on-failure";
605 description = "Trigger daily Mailman events";
607 restartTriggers = [ mailmanCfgFile ];
609 ExecStart = "${mailmanEnv}/bin/mailman digests --send";
615 hyperkitty = lib.mkIf cfg.hyperkitty.enable {
616 description = "GNU Hyperkitty QCluster Process";
617 after = [ "network.target" ];
618 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
619 wantedBy = [ "mailman.service" "multi-user.target" ];
621 ExecStart = "${webEnv}/bin/mailman-web qcluster";
624 WorkingDirectory = "/var/lib/mailman-web";
625 Restart = "on-failure";
628 } // lib.flip lib.mapAttrs' {
629 "minutely" = "minutely";
630 "quarter_hourly" = "*:00/15";
636 lib.nameValuePair "hyperkitty-${name}" (lib.mkIf cfg.hyperkitty.enable {
637 description = "Trigger ${name} Hyperkitty events";
639 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
641 ExecStart = "${webEnv}/bin/mailman-web runjobs ${name}";
644 WorkingDirectory = "/var/lib/mailman-web";
650 maintainers = with lib.maintainers; [ qyliss ];