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 uwsgiSettings = lib.mkOption {
268 example = { uwsgi.buffer-size = 8192; };
269 inherit (pkgs.formats.json {}) type;
271 Extra configuration to merge into uwsgi config.
275 virtualRoot = lib.mkOption {
277 example = lib.literalExpression "/lists";
278 type = lib.types.str;
280 Path to mount the mailman-web django application on.
285 extraPythonPackages = lib.mkOption {
286 description = "Packages to add to the python environment used by mailman and mailman-web";
287 type = lib.types.listOf lib.types.package;
291 settings = lib.mkOption {
292 description = "Settings for mailman.cfg";
293 type = lib.types.attrsOf (lib.types.attrsOf lib.types.str);
298 enable = lib.mkEnableOption "the Hyperkitty archiver for Mailman";
300 baseUrl = lib.mkOption {
301 type = lib.types.str;
302 default = "http://localhost:18507/archives/";
304 Where can Mailman connect to Hyperkitty's internal API, preferably on
313 ###### implementation
315 config = lib.mkIf cfg.enable {
317 services.mailman.settings = {
318 mailman.site_owner = lib.mkDefault cfg.siteOwner;
319 mailman.layout = "fhs";
322 bin_dir = "${pkgs.mailmanPackages.mailman}/bin";
323 var_dir = "/var/lib/mailman";
324 queue_dir = "$var_dir/queue";
325 template_dir = "$var_dir/templates";
326 log_dir = "/var/log/mailman";
327 lock_dir = "/run/mailman/lock";
329 pid_file = "/run/mailman/master.pid";
332 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.");
334 "archiver.hyperkitty" = lib.mkIf cfg.hyperkitty.enable {
335 class = "mailman_hyperkitty.Archiver";
337 configuration = "/var/lib/mailman/mailman-hyperkitty.cfg";
340 loggerNames = ["root" "archiver" "bounce" "config" "database" "debug" "error" "fromusenet" "http" "locks" "mischief" "plugins" "runner" "smtp"];
341 loggerSectionNames = map (n: "logging.${n}") loggerNames;
342 in lib.genAttrs loggerSectionNames(name: { handler = "stderr"; })
346 inherit (config.services) postfix;
348 requirePostfixHash = optionPath: dataFile:
350 expected = "hash:/var/lib/mailman/data/${dataFile}";
351 value = lib.attrByPath optionPath [] postfix;
353 { assertion = postfix.enable -> lib.isList value && lib.elem expected value;
355 services.postfix.${lib.concatStringsSep "." optionPath} must contain
357 See <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>.
361 { assertion = cfg.webHosts != [];
363 services.mailman.serve.enable requires there to be at least one entry
364 in services.mailman.webHosts.
367 ] ++ (lib.optionals cfg.enablePostfix [
368 { assertion = postfix.enable;
370 Mailman's default NixOS configuration requires Postfix to be enabled.
372 If you want to use another MTA, set services.mailman.enablePostfix
373 to false and configure settings in services.mailman.settings.mta.
375 Refer to <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>
379 (requirePostfixHash [ "config" "relay_domains" ] "postfix_domains")
380 (requirePostfixHash [ "config" "transport_maps" ] "postfix_lmtp")
381 (requirePostfixHash [ "config" "local_recipient_maps" ] "postfix_lmtp")
384 users.users.mailman = {
385 description = "GNU Mailman";
389 users.users.mailman-web = lib.mkIf (cfg.webUser == "mailman-web") {
390 description = "GNU Mailman web interface";
394 users.groups.mailman = {};
396 environment.etc."mailman3/settings.py".text = ''
398 from configparser import ConfigParser
400 # Required by mailman_web.settings, but will be overridden when
401 # settings_local.json is loaded.
402 os.environ["SECRET_KEY"] = ""
404 from mailman_web.settings.base import *
405 from mailman_web.settings.mailman import *
409 with open('${webSettingsJSON}') as f:
410 globals().update(json.load(f))
412 with open('/var/lib/mailman-web/settings_local.json') as f:
413 globals().update(json.load(f))
415 with open('/etc/mailman.cfg') as f:
416 config = ConfigParser()
418 MAILMAN_REST_API_PASS = config['webservice']['admin_pass']
420 ${lib.optionalString (cfg.ldap.enable) ''
422 from django_auth_ldap.config import LDAPSearch, ${cfg.ldap.groupSearch.type}
423 AUTH_LDAP_SERVER_URI = "${cfg.ldap.serverUri}"
424 AUTH_LDAP_BIND_DN = "${cfg.ldap.bindDn}"
425 with open("${cfg.ldap.bindPasswordFile}") as f:
426 AUTH_LDAP_BIND_PASSWORD = f.read().rstrip('\n')
427 AUTH_LDAP_USER_SEARCH = LDAPSearch("${cfg.ldap.userSearch.ou}",
428 ldap.SCOPE_SUBTREE, "${cfg.ldap.userSearch.query}")
429 AUTH_LDAP_GROUP_TYPE = ${cfg.ldap.groupSearch.type}()
430 AUTH_LDAP_GROUP_SEARCH = LDAPSearch("${cfg.ldap.groupSearch.ou}",
431 ldap.SCOPE_SUBTREE, "${cfg.ldap.groupSearch.query}")
432 AUTH_LDAP_USER_ATTR_MAP = {
433 ${lib.concatStrings (lib.flip lib.mapAttrsToList cfg.ldap.attrMap (key: value: ''
434 "${key}": "${value}",
437 ${lib.optionalString (cfg.ldap.superUserGroup != null) ''
438 AUTH_LDAP_USER_FLAGS_BY_GROUP = {
439 "is_superuser": "${cfg.ldap.superUserGroup}"
442 AUTHENTICATION_BACKENDS = (
443 "django_auth_ldap.backend.LDAPBackend",
444 "django.contrib.auth.backends.ModelBackend"
449 services.nginx = lib.mkIf (cfg.serve.enable && cfg.webHosts != []) {
450 enable = lib.mkDefault true;
451 virtualHosts = lib.genAttrs cfg.webHosts (webHost: {
453 ${cfg.serve.virtualRoot}.extraConfig = "uwsgi_pass unix:/run/mailman-web.socket;";
454 "${lib.removeSuffix "/" cfg.serve.virtualRoot}/static/".alias = webSettings.STATIC_ROOT + "/";
457 proxyTimeout = lib.mkDefault "120s";
460 environment.systemPackages = [ (pkgs.buildEnv {
461 name = "mailman-tools";
462 # We don't want to pollute the system PATH with a python
463 # interpreter etc. so let's pick only the stuff we actually
464 # want from {web,mailman}Env
465 pathsToLink = ["/bin"];
466 paths = [ mailmanEnv webEnv ];
467 # Only mailman-related stuff is installed, the rest is removed
469 ignoreCollisions = true;
471 find $out/bin/ -mindepth 1 -not -name "mailman*" -delete
472 '' + lib.optionalString config.security.sudo.enable ''
473 mv $out/bin/mailman $out/bin/.mailman-wrapped
474 echo '#!${pkgs.runtimeShell}
476 if [[ "$USER" != mailman ]]; then
477 sudo="exec /run/wrappers/bin/sudo -u mailman"
479 $sudo ${placeholder "out"}/bin/.mailman-wrapped "$@"
481 chmod +x $out/bin/mailman
485 services.postfix = lib.mkIf cfg.enablePostfix {
486 recipientDelimiter = "+"; # bake recipient addresses in mail envelopes via VERP
488 owner_request_special = "no"; # Mailman handles -owner addresses on its own
492 systemd.sockets.mailman-uwsgi = lib.mkIf cfg.serve.enable {
493 wantedBy = ["sockets.target"];
494 before = ["nginx.service"];
495 socketConfig.ListenStream = "/run/mailman-web.socket";
499 description = "GNU Mailman Master Process";
500 before = lib.optional cfg.enablePostfix "postfix.service";
501 after = [ "network.target" ]
502 ++ lib.optional cfg.enablePostfix "postfix-setup.service"
503 ++ lib.optional withPostgresql "postgresql.service";
504 restartTriggers = [ mailmanCfgFile ];
505 requires = lib.optional withPostgresql "postgresql.service";
506 wantedBy = [ "multi-user.target" ];
508 ExecStart = "${mailmanEnv}/bin/mailman start";
509 ExecStop = "${mailmanEnv}/bin/mailman stop";
513 RuntimeDirectory = "mailman";
514 LogsDirectory = "mailman";
515 PIDFile = "/run/mailman/master.pid";
516 Restart = "on-failure";
517 TimeoutStartSec = 180;
518 TimeoutStopSec = 180;
523 description = "Generate settings files (including secrets) for Mailman";
524 before = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ];
525 requiredBy = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ];
526 path = with pkgs; [ jq ];
527 after = lib.optional withPostgresql "postgresql.service";
528 requires = lib.optional withPostgresql "postgresql.service";
529 serviceConfig.RemainAfterExit = true;
530 serviceConfig.Type = "oneshot";
532 install -m0750 -o mailman -g mailman ${mailmanCfgFile} /etc/mailman.cfg
533 ${if cfg.restApiPassFile == null then ''
534 sed -i "s/#NIXOS_MAILMAN_REST_API_PASS_SECRET#/$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)/g" \
537 ${pkgs.replace-secret}/bin/replace-secret \
538 '#NIXOS_MAILMAN_REST_API_PASS_SECRET#' \
539 ${cfg.restApiPassFile} \
543 mailmanDir=/var/lib/mailman
544 mailmanWebDir=/var/lib/mailman-web
546 mailmanCfg=$mailmanDir/mailman-hyperkitty.cfg
547 mailmanWebCfg=$mailmanWebDir/settings_local.json
549 install -m 0775 -o mailman -g mailman -d /var/lib/mailman-web-static
550 install -m 0770 -o mailman -g mailman -d $mailmanDir
551 install -m 0770 -o ${cfg.webUser} -g mailman -d $mailmanWebDir
553 if [ ! -e $mailmanWebCfg ]; then
554 hyperkittyApiKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)
555 secretKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)
557 install -m 0440 -o root -g mailman \
558 <(jq -n '.MAILMAN_ARCHIVER_KEY=$archiver_key | .SECRET_KEY=$secret_key' \
559 --arg archiver_key "$hyperkittyApiKey" \
560 --arg secret_key "$secretKey") \
564 hyperkittyApiKey="$(jq -r .MAILMAN_ARCHIVER_KEY "$mailmanWebCfg")"
565 mailmanCfgTmp=$(mktemp)
566 sed "s/@API_KEY@/$hyperkittyApiKey/g" ${mailmanHyperkittyCfg} >"$mailmanCfgTmp"
567 chown mailman:mailman "$mailmanCfgTmp"
568 mv "$mailmanCfgTmp" "$mailmanCfg"
572 mailman-web-setup = {
573 description = "Prepare mailman-web files and database";
574 before = [ "mailman-uwsgi.service" ];
575 requiredBy = [ "mailman-uwsgi.service" ];
576 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
578 [[ -e "${webSettings.STATIC_ROOT}" ]] && find "${webSettings.STATIC_ROOT}/" -mindepth 1 -delete
579 ${webEnv}/bin/mailman-web migrate
580 ${webEnv}/bin/mailman-web collectstatic
581 ${webEnv}/bin/mailman-web compress
587 WorkingDirectory = "/var/lib/mailman-web";
591 mailman-uwsgi = lib.mkIf cfg.serve.enable (let
592 uwsgiConfig = lib.recursiveUpdate {
595 plugins = ["python3"];
597 http = "127.0.0.1:18507";
599 // (if cfg.serve.virtualRoot == "/"
600 then { module = "mailman_web.wsgi:application"; }
602 mount = "${cfg.serve.virtualRoot}=mailman_web.wsgi:application";
603 manage-script-name = true;
605 } cfg.serve.uwsgiSettings;
606 uwsgiConfigFile = pkgs.writeText "uwsgi-mailman.json" (builtins.toJSON uwsgiConfig);
608 wantedBy = ["multi-user.target"];
609 after = lib.optional withPostgresql "postgresql.service";
610 requires = ["mailman-uwsgi.socket" "mailman-web-setup.service"]
611 ++ lib.optional withPostgresql "postgresql.service";
612 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
614 # Since the mailman-web settings.py obstinately creates a logs
615 # dir in the cwd, change to the (writable) runtime directory before
617 ExecStart = "${pkgs.coreutils}/bin/env -C $RUNTIME_DIRECTORY ${pkgs.uwsgi.override { plugins = ["python3"]; python3 = webEnv.python; }}/bin/uwsgi --json ${uwsgiConfigFile}";
620 RuntimeDirectory = "mailman-uwsgi";
621 Restart = "on-failure";
626 description = "Trigger daily Mailman events";
628 restartTriggers = [ mailmanCfgFile ];
630 ExecStart = "${mailmanEnv}/bin/mailman digests --send";
636 hyperkitty = lib.mkIf cfg.hyperkitty.enable {
637 description = "GNU Hyperkitty QCluster Process";
638 after = [ "network.target" ];
639 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
640 wantedBy = [ "mailman.service" "multi-user.target" ];
642 ExecStart = "${webEnv}/bin/mailman-web qcluster";
645 WorkingDirectory = "/var/lib/mailman-web";
646 Restart = "on-failure";
649 } // lib.flip lib.mapAttrs' {
650 "minutely" = "minutely";
651 "quarter_hourly" = "*:00/15";
657 lib.nameValuePair "hyperkitty-${name}" (lib.mkIf cfg.hyperkitty.enable {
658 description = "Trigger ${name} Hyperkitty events";
660 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
662 ExecStart = "${webEnv}/bin/mailman-web runjobs ${name}";
665 WorkingDirectory = "/var/lib/mailman-web";
671 maintainers = with lib.maintainers; [ qyliss ];