1{ config, pkgs, lib, ... }: # mailman.nix
2
3with lib;
4
5let
6
7 cfg = config.services.mailman;
8
9 pythonEnv = pkgs.python3.withPackages (ps:
10 [ps.mailman ps.mailman-web]
11 ++ lib.optional cfg.hyperkitty.enable ps.mailman-hyperkitty
12 ++ cfg.extraPythonPackages);
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 {} cfg.settings;
48
49 mailmanHyperkittyCfg = pkgs.writeText "mailman-hyperkitty.cfg" ''
50 [general]
51 # This is your HyperKitty installation, preferably on the localhost. This
52 # address will be used by Mailman to forward incoming emails to HyperKitty
53 # for archiving. It does not need to be publicly available, in fact it's
54 # better if it is not.
55 base_url: ${cfg.hyperkitty.baseUrl}
56
57 # Shared API key, must be the identical to the value in HyperKitty's
58 # settings.
59 api_key: @API_KEY@
60 '';
61
62in {
63
64 ###### interface
65
66 imports = [
67 (mkRenamedOptionModule [ "services" "mailman" "hyperkittyBaseUrl" ]
68 [ "services" "mailman" "hyperkitty" "baseUrl" ])
69
70 (mkRemovedOptionModule [ "services" "mailman" "hyperkittyApiKey" ] ''
71 The Hyperkitty API key is now generated on first run, and not
72 stored in the world-readable Nix store. To continue using
73 Hyperkitty, you must set services.mailman.hyperkitty.enable = true.
74 '')
75 ];
76
77 options = {
78
79 services.mailman = {
80
81 enable = mkOption {
82 type = types.bool;
83 default = false;
84 description = "Enable Mailman on this host. Requires an active MTA on the host (e.g. Postfix).";
85 };
86
87 package = mkOption {
88 type = types.package;
89 default = pkgs.mailman;
90 defaultText = "pkgs.mailman";
91 example = literalExample "pkgs.mailman.override { archivers = []; }";
92 description = "Mailman package to use";
93 };
94
95 enablePostfix = mkOption {
96 type = types.bool;
97 default = true;
98 example = false;
99 description = ''
100 Enable Postfix integration. Requires an active Postfix installation.
101
102 If you want to use another MTA, set this option to false and configure
103 settings in services.mailman.settings.mta.
104
105 Refer to the Mailman manual for more info.
106 '';
107 };
108
109 siteOwner = mkOption {
110 type = types.str;
111 example = "postmaster@example.org";
112 description = ''
113 Certain messages that must be delivered to a human, but which can't
114 be delivered to a list owner (e.g. a bounce from a list owner), will
115 be sent to this address. It should point to a human.
116 '';
117 };
118
119 webHosts = mkOption {
120 type = types.listOf types.str;
121 default = [];
122 description = ''
123 The list of hostnames and/or IP addresses from which the Mailman Web
124 UI will accept requests. By default, "localhost" and "127.0.0.1" are
125 enabled. All additional names under which your web server accepts
126 requests for the UI must be listed here or incoming requests will be
127 rejected.
128 '';
129 };
130
131 webUser = mkOption {
132 type = types.str;
133 default = "mailman-web";
134 description = ''
135 User to run mailman-web as
136 '';
137 };
138
139 webSettings = mkOption {
140 type = types.attrs;
141 default = {};
142 description = ''
143 Overrides for the default mailman-web Django settings.
144 '';
145 };
146
147 serve = {
148 enable = mkEnableOption "Automatic nginx and uwsgi setup for mailman-web";
149 };
150
151 extraPythonPackages = mkOption {
152 description = "Packages to add to the python environment used by mailman and mailman-web";
153 type = types.listOf types.package;
154 default = [];
155 };
156
157 settings = mkOption {
158 description = "Settings for mailman.cfg";
159 type = types.attrsOf (types.attrsOf types.str);
160 default = {};
161 };
162
163 hyperkitty = {
164 enable = mkEnableOption "the Hyperkitty archiver for Mailman";
165
166 baseUrl = mkOption {
167 type = types.str;
168 default = "http://localhost:18507/archives/";
169 description = ''
170 Where can Mailman connect to Hyperkitty's internal API, preferably on
171 localhost?
172 '';
173 };
174 };
175
176 };
177 };
178
179 ###### implementation
180
181 config = mkIf cfg.enable {
182
183 services.mailman.settings = {
184 mailman.site_owner = lib.mkDefault cfg.siteOwner;
185 mailman.layout = "fhs";
186
187 "paths.fhs" = {
188 bin_dir = "${pkgs.python3Packages.mailman}/bin";
189 var_dir = "/var/lib/mailman";
190 queue_dir = "$var_dir/queue";
191 template_dir = "$var_dir/templates";
192 log_dir = "/var/log/mailman";
193 lock_dir = "$var_dir/lock";
194 etc_dir = "/etc";
195 ext_dir = "$etc_dir/mailman.d";
196 pid_file = "/run/mailman/master.pid";
197 };
198
199 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.");
200
201 "archiver.hyperkitty" = lib.mkIf cfg.hyperkitty.enable {
202 class = "mailman_hyperkitty.Archiver";
203 enable = "yes";
204 configuration = "/var/lib/mailman/mailman-hyperkitty.cfg";
205 };
206 } // (let
207 loggerNames = ["root" "archiver" "bounce" "config" "database" "debug" "error" "fromusenet" "http" "locks" "mischief" "plugins" "runner" "smtp"];
208 loggerSectionNames = map (n: "logging.${n}") loggerNames;
209 in lib.genAttrs loggerSectionNames(name: { handler = "stderr"; })
210 );
211
212 assertions = let
213 inherit (config.services) postfix;
214
215 requirePostfixHash = optionPath: dataFile:
216 with lib;
217 let
218 expected = "hash:/var/lib/mailman/data/${dataFile}";
219 value = attrByPath optionPath [] postfix;
220 in
221 { assertion = postfix.enable -> isList value && elem expected value;
222 message = ''
223 services.postfix.${concatStringsSep "." optionPath} must contain
224 "${expected}".
225 See <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>.
226 '';
227 };
228 in (lib.optionals cfg.enablePostfix [
229 { assertion = postfix.enable;
230 message = ''
231 Mailman's default NixOS configuration requires Postfix to be enabled.
232
233 If you want to use another MTA, set services.mailman.enablePostfix
234 to false and configure settings in services.mailman.settings.mta.
235
236 Refer to <https://mailman.readthedocs.io/en/latest/src/mailman/docs/mta.html>
237 for more info.
238 '';
239 }
240 (requirePostfixHash [ "relayDomains" ] "postfix_domains")
241 (requirePostfixHash [ "config" "transport_maps" ] "postfix_lmtp")
242 (requirePostfixHash [ "config" "local_recipient_maps" ] "postfix_lmtp")
243 ]);
244
245 users.users.mailman = {
246 description = "GNU Mailman";
247 isSystemUser = true;
248 group = "mailman";
249 };
250 users.users.mailman-web = lib.mkIf (cfg.webUser == "mailman-web") {
251 description = "GNU Mailman web interface";
252 isSystemUser = true;
253 group = "mailman";
254 };
255 users.groups.mailman = {};
256
257 environment.etc."mailman.cfg".text = mailmanCfg;
258
259 environment.etc."mailman3/settings.py".text = ''
260 import os
261
262 # Required by mailman_web.settings, but will be overridden when
263 # settings_local.json is loaded.
264 os.environ["SECRET_KEY"] = ""
265
266 from mailman_web.settings.base import *
267 from mailman_web.settings.mailman import *
268
269 import json
270
271 with open('${webSettingsJSON}') as f:
272 globals().update(json.load(f))
273
274 with open('/var/lib/mailman-web/settings_local.json') as f:
275 globals().update(json.load(f))
276 '';
277
278 services.nginx = mkIf cfg.serve.enable {
279 enable = mkDefault true;
280 virtualHosts."${lib.head cfg.webHosts}" = {
281 serverAliases = cfg.webHosts;
282 locations = {
283 "/".extraConfig = "uwsgi_pass unix:/run/mailman-web.socket;";
284 "/static/".alias = webSettings.STATIC_ROOT + "/";
285 };
286 };
287 };
288
289 environment.systemPackages = [ (pkgs.buildEnv {
290 name = "mailman-tools";
291 # We don't want to pollute the system PATH with a python
292 # interpreter etc. so let's pick only the stuff we actually
293 # want from pythonEnv
294 pathsToLink = ["/bin"];
295 paths = [pythonEnv];
296 postBuild = ''
297 find $out/bin/ -mindepth 1 -not -name "mailman*" -delete
298 '';
299 }) ];
300
301 services.postfix = lib.mkIf cfg.enablePostfix {
302 recipientDelimiter = "+"; # bake recipient addresses in mail envelopes via VERP
303 config = {
304 owner_request_special = "no"; # Mailman handles -owner addresses on its own
305 };
306 };
307
308 systemd.sockets.mailman-uwsgi = lib.mkIf cfg.serve.enable {
309 wantedBy = ["sockets.target"];
310 before = ["nginx.service"];
311 socketConfig.ListenStream = "/run/mailman-web.socket";
312 };
313 systemd.services = {
314 mailman = {
315 description = "GNU Mailman Master Process";
316 after = [ "network.target" ];
317 restartTriggers = [ config.environment.etc."mailman.cfg".source ];
318 wantedBy = [ "multi-user.target" ];
319 serviceConfig = {
320 ExecStart = "${pythonEnv}/bin/mailman start";
321 ExecStop = "${pythonEnv}/bin/mailman stop";
322 User = "mailman";
323 Group = "mailman";
324 Type = "forking";
325 RuntimeDirectory = "mailman";
326 LogsDirectory = "mailman";
327 PIDFile = "/run/mailman/master.pid";
328 };
329 };
330
331 mailman-settings = {
332 description = "Generate settings files (including secrets) for Mailman";
333 before = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ];
334 requiredBy = [ "mailman.service" "mailman-web-setup.service" "mailman-uwsgi.service" "hyperkitty.service" ];
335 path = with pkgs; [ jq ];
336 script = ''
337 mailmanDir=/var/lib/mailman
338 mailmanWebDir=/var/lib/mailman-web
339
340 mailmanCfg=$mailmanDir/mailman-hyperkitty.cfg
341 mailmanWebCfg=$mailmanWebDir/settings_local.json
342
343 install -m 0775 -o mailman -g mailman -d /var/lib/mailman-web-static
344 install -m 0770 -o mailman -g mailman -d $mailmanDir
345 install -m 0770 -o ${cfg.webUser} -g mailman -d $mailmanWebDir
346
347 if [ ! -e $mailmanWebCfg ]; then
348 hyperkittyApiKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)
349 secretKey=$(tr -dc A-Za-z0-9 < /dev/urandom | head -c 64)
350
351 mailmanWebCfgTmp=$(mktemp)
352 jq -n '.MAILMAN_ARCHIVER_KEY=$archiver_key | .SECRET_KEY=$secret_key' \
353 --arg archiver_key "$hyperkittyApiKey" \
354 --arg secret_key "$secretKey" \
355 >"$mailmanWebCfgTmp"
356 chown root:mailman "$mailmanWebCfgTmp"
357 chmod 440 "$mailmanWebCfgTmp"
358 mv -n "$mailmanWebCfgTmp" "$mailmanWebCfg"
359 fi
360
361 hyperkittyApiKey="$(jq -r .MAILMAN_ARCHIVER_KEY "$mailmanWebCfg")"
362 mailmanCfgTmp=$(mktemp)
363 sed "s/@API_KEY@/$hyperkittyApiKey/g" ${mailmanHyperkittyCfg} >"$mailmanCfgTmp"
364 chown mailman:mailman "$mailmanCfgTmp"
365 mv "$mailmanCfgTmp" "$mailmanCfg"
366 '';
367 };
368
369 mailman-web-setup = {
370 description = "Prepare mailman-web files and database";
371 before = [ "mailman-uwsgi.service" ];
372 requiredBy = [ "mailman-uwsgi.service" ];
373 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
374 script = ''
375 [[ -e "${webSettings.STATIC_ROOT}" ]] && find "${webSettings.STATIC_ROOT}/" -mindepth 1 -delete
376 ${pythonEnv}/bin/mailman-web migrate
377 ${pythonEnv}/bin/mailman-web collectstatic
378 ${pythonEnv}/bin/mailman-web compress
379 '';
380 serviceConfig = {
381 User = cfg.webUser;
382 Group = "mailman";
383 Type = "oneshot";
384 WorkingDirectory = "/var/lib/mailman-web";
385 };
386 };
387
388 mailman-uwsgi = mkIf cfg.serve.enable (let
389 uwsgiConfig.uwsgi = {
390 type = "normal";
391 plugins = ["python3"];
392 home = pythonEnv;
393 module = "mailman_web.wsgi";
394 http = "127.0.0.1:18507";
395 };
396 uwsgiConfigFile = pkgs.writeText "uwsgi-mailman.json" (builtins.toJSON uwsgiConfig);
397 in {
398 wantedBy = ["multi-user.target"];
399 requires = ["mailman-uwsgi.socket" "mailman-web-setup.service"];
400 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
401 serviceConfig = {
402 # Since the mailman-web settings.py obstinately creates a logs
403 # dir in the cwd, change to the (writable) runtime directory before
404 # starting uwsgi.
405 ExecStart = "${pkgs.coreutils}/bin/env -C $RUNTIME_DIRECTORY ${pkgs.uwsgi.override { plugins = ["python3"]; }}/bin/uwsgi --json ${uwsgiConfigFile}";
406 User = cfg.webUser;
407 Group = "mailman";
408 RuntimeDirectory = "mailman-uwsgi";
409 };
410 });
411
412 mailman-daily = {
413 description = "Trigger daily Mailman events";
414 startAt = "daily";
415 restartTriggers = [ config.environment.etc."mailman.cfg".source ];
416 serviceConfig = {
417 ExecStart = "${pythonEnv}/bin/mailman digests --send";
418 User = "mailman";
419 Group = "mailman";
420 };
421 };
422
423 hyperkitty = lib.mkIf cfg.hyperkitty.enable {
424 description = "GNU Hyperkitty QCluster Process";
425 after = [ "network.target" ];
426 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
427 wantedBy = [ "mailman.service" "multi-user.target" ];
428 serviceConfig = {
429 ExecStart = "${pythonEnv}/bin/mailman-web qcluster";
430 User = cfg.webUser;
431 Group = "mailman";
432 WorkingDirectory = "/var/lib/mailman-web";
433 };
434 };
435 } // flip lib.mapAttrs' {
436 "minutely" = "minutely";
437 "quarter_hourly" = "*:00/15";
438 "hourly" = "hourly";
439 "daily" = "daily";
440 "weekly" = "weekly";
441 "yearly" = "yearly";
442 } (name: startAt:
443 lib.nameValuePair "hyperkitty-${name}" (lib.mkIf cfg.hyperkitty.enable {
444 description = "Trigger ${name} Hyperkitty events";
445 inherit startAt;
446 restartTriggers = [ config.environment.etc."mailman3/settings.py".source ];
447 serviceConfig = {
448 ExecStart = "${pythonEnv}/bin/mailman-web runjobs ${name}";
449 User = cfg.webUser;
450 Group = "mailman";
451 WorkingDirectory = "/var/lib/mailman-web";
452 };
453 }));
454 };
455
456 meta = {
457 maintainers = with lib.maintainers; [ lheckemann qyliss ];
458 doc = ./mailman.xml;
459 };
460
461}