]> Git — Sourcephile - sourcephile-nix.git/blob - nixos/modules/services/mail/public-inbox.nix
public-inbox: update to 1.6.0
[sourcephile-nix.git] / nixos / modules / services / mail / public-inbox.nix
1 { lib, pkgs, config, ... }:
2
3 with lib;
4
5 let
6 cfg = config.services.public-inbox;
7 stateDir = "/var/lib/public-inbox";
8 inboxes = mapAttrs (name: inbox:
9 recursiveUpdate {
10 inherit (inbox) address url newsgroup watch;
11 mainrepo = "${stateDir}/inboxes/${name}";
12 watchheader = inbox.watchHeader;
13 } inbox.config)
14 cfg.inboxes;
15
16 configToList = attrs:
17 concatLists (mapAttrsToList (name': value':
18 if isAttrs value' then
19 map ({ name, value }: nameValuePair "${name'}.${name}" value)
20 (configToList value')
21 else if isList value' then map (nameValuePair name') value'
22 else if value' == null then []
23 else [ (nameValuePair name' value') ]) attrs);
24
25 configFull = recursiveUpdate {
26 publicinbox = inboxes // {
27 nntpserver = cfg.nntpServer;
28 wwwlisting = cfg.wwwListing;
29 };
30 publicinboxmda.spamcheck =
31 if (cfg.mda.spamCheck == null) then "none" else cfg.mda.spamCheck;
32 publicinboxwatch.spamcheck =
33 if (cfg.watch.spamCheck == null) then "none" else cfg.watch.spamCheck;
34 publicinboxwatch.watchspam = cfg.watch.watchSpam;
35 } cfg.config;
36
37 configList = configToList configFull;
38
39 gitConfig = key: val: ''
40 ${pkgs.git}/bin/git config --add --file $out ${escapeShellArgs [ key val ]}
41 '';
42
43 configFile = pkgs.runCommand "public-inbox-config" {}
44 (concatStrings (map ({ name, value }: gitConfig name value) configList));
45
46 environment = {
47 PI_EMERGENCY = "${stateDir}/emergency";
48 PI_CONFIG = configFile;
49 };
50
51 envList = mapAttrsToList (n: v: "${n}=${v}") environment;
52
53 # Can't use pkgs.linkFarm,
54 # because Postfix rejects .forward if it's a symlink.
55 home = pkgs.runCommand "public-inbox-home" {
56 forward = ''
57 |"env ${concatStringsSep " " envList} PATH=\"${makeBinPath cfg.path}:$PATH\" ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
58 '';
59 passAsFile = [ "forward" ];
60 } ''
61 mkdir $out
62 ln -s ${stateDir}/spamassassin $out/.spamassassin
63 cp $forwardPath $out/.forward
64 install -D -p ${configFile} $out/.public-inbox/config
65 '';
66
67 psgi = pkgs.writeText "public-inbox.psgi" ''
68 #!${cfg.package.fullperl} -w
69 # Copyright (C) 2014-2019 all contributors <meta@public-inbox.org>
70 # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
71 use strict;
72 use PublicInbox::WWW;
73 use Plack::Builder;
74
75 my $www = PublicInbox::WWW->new;
76 $www->preload;
77
78 builder {
79 enable 'Head';
80 enable 'ReverseProxy';
81 ${concatMapStrings (path: ''
82 mount q(${path}) => sub { $www->call(@_); };
83 '') cfg.http.mounts}
84 }
85 '';
86
87 descriptionFile = { description, ... }:
88 pkgs.writeText "description" description;
89
90 enableWatch = (any (i: i.watch != []) (attrValues cfg.inboxes))
91 || (cfg.watch.watchSpam != null);
92
93 useSpamAssassin = cfg.mda.spamCheck == "spamc" ||
94 cfg.watch.spamCheck == "spamc";
95
96 in
97
98 {
99 options.services.public-inbox = {
100 enable = mkEnableOption "the public-inbox mail archiver";
101
102 package = mkOption {
103 type = types.package;
104 default = pkgs.public-inbox;
105 description = ''
106 public-inbox package to use with the public-inbox module
107 '';
108 };
109
110 path = mkOption {
111 type = with types; listOf package;
112 default = [];
113 example = literalExample "with pkgs; [ spamassassin ]";
114 description = ''
115 Additional packages to place in the path of public-inbox-mda,
116 public-inbox-watch, etc.
117 '';
118 };
119
120 inboxes = mkOption {
121 description = ''
122 Inboxes to configure, where attribute names are inbox names
123 '';
124 type = types.attrsOf (types.submodule {
125 options = {
126 address = mkOption {
127 type = with types; listOf str;
128 example = "example-discuss@example.org";
129 };
130
131 url = mkOption {
132 type = with types; nullOr str;
133 default = null;
134 example = "https://example.org/lists/example-discuss";
135 description = ''
136 URL where this inbox can be accessed over HTTP
137 '';
138 };
139
140 description = mkOption {
141 type = types.str;
142 example = "user/dev discussion of public-inbox itself";
143 description = ''
144 User-visible description for the repository
145 '';
146 };
147
148 config = mkOption {
149 type = types.attrs;
150 default = {};
151 description = ''
152 Additional structured config for the inbox
153 '';
154 };
155
156 newsgroup = mkOption {
157 type = with types; nullOr str;
158 default = null;
159 description = ''
160 NNTP group name for the inbox
161 '';
162 };
163
164 watch = mkOption {
165 type = with types; listOf str;
166 default = [];
167 description = ''
168 Paths for public-inbox-watch(1) to monitor for new mail
169 '';
170 example = [ "maildir:/path/to/test.example.com.git" ];
171 };
172
173 watchHeader = mkOption {
174 type = with types; nullOr str;
175 default = null;
176 example = "List-Id:<test@example.com>";
177 description = ''
178 If specified, public-inbox-watch(1) will only process
179 mail containing a matching header.
180 '';
181 };
182 };
183 });
184 };
185
186 mda = {
187 args = mkOption {
188 type = with types; listOf str;
189 default = [];
190 description = ''
191 Command-line arguments to pass to public-inbox-mda(1).
192 '';
193 };
194
195 spamCheck = mkOption {
196 type = with types; nullOr (enum [ "spamc" ]);
197 default = "spamc";
198 description = ''
199 If set to spamc, public-inbox-mda(1) will filter spam
200 using SpamAssassin
201 '';
202 };
203 };
204
205 watch = {
206 spamCheck = mkOption {
207 type = with types; nullOr (enum [ "spamc" ]);
208 default = "spamc";
209 description = ''
210 If set to spamc, public-inbox-watch(1) will filter spam
211 using SpamAssassin
212 '';
213 };
214
215 watchSpam = mkOption {
216 type = with types; nullOr str;
217 default = null;
218 example = "maildir:/path/to/spam";
219 description = ''
220 If set, mail in this maildir will be trained as spam and
221 deleted from all watched inboxes
222 '';
223 };
224 };
225
226 http = {
227 mounts = mkOption {
228 type = with types; listOf str;
229 default = [ "/" ];
230 example = [ "/lists/archives" ];
231 description = ''
232 Root paths or URLs that public-inbox will be served on.
233 If domain parts are present, only requests to those
234 domains will be accepted.
235 '';
236 };
237
238 listenStreams = mkOption {
239 type = with types; listOf str;
240 default = [ "/run/public-inbox-httpd.sock" ];
241 description = ''
242 systemd.socket(5) ListenStream values for the
243 public-inbox-httpd service to listen on
244 '';
245 };
246 };
247
248 imap = {
249 listenStreams = mkOption {
250 type = with types; listOf str;
251 default = [ "0.0.0.0:993" ];
252 description = ''
253 systemd.socket(5) ListenStream values for the
254 public-inbox-imapd service to listen on
255 '';
256 };
257
258 cert = mkOption {
259 type = with types; nullOr str;
260 default = null;
261 example = "/path/to/fullchain.pem";
262 description = ''
263 Path to TLS certificate to use for public-inbox IMAP connections
264 '';
265 };
266
267 key = mkOption {
268 type = with types; nullOr str;
269 default = null;
270 example = "/path/to/key.pem";
271 description = ''
272 Path to TLS key to use for public-inbox IMAP connections
273 '';
274 };
275 };
276
277 nntp = {
278 listenStreams = mkOption {
279 type = with types; listOf str;
280 default = [ "0.0.0.0:119" "0.0.0.0:563" ];
281 description = ''
282 systemd.socket(5) ListenStream values for the
283 public-inbox-nntpd service to listen on
284 '';
285 };
286
287 cert = mkOption {
288 type = with types; nullOr str;
289 default = null;
290 example = "/path/to/fullchain.pem";
291 description = ''
292 Path to TLS certificate to use for public-inbox NNTP connections
293 '';
294 };
295
296 key = mkOption {
297 type = with types; nullOr str;
298 default = null;
299 example = "/path/to/key.pem";
300 description = ''
301 Path to TLS key to use for public-inbox NNTP connections
302 '';
303 };
304
305 extraGroups = mkOption {
306 type = with types; listOf str;
307 default = [];
308 example = [ "tls" ];
309 description = ''
310 Secondary groups to assign to the systemd DynamicUser
311 running public-inbox-nntpd, in addition to the
312 public-inbox group. This is useful for giving
313 public-inbox-nntpd access to a TLS certificate / key, for
314 example.
315 '';
316 };
317 };
318
319 nntpServer = mkOption {
320 type = with types; listOf str;
321 default = [];
322 example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ];
323 description = ''
324 NNTP URLs to this public-inbox instance
325 '';
326 };
327
328 wwwListing = mkOption {
329 type = with types; enum [ "all" "404" "match=domain" ];
330 default = "404";
331 description = ''
332 Controls which lists (if any) are listed for when the root
333 public-inbox URL is accessed over HTTP.
334 '';
335 };
336
337 spamAssassinRules = mkOption {
338 type = with types; nullOr path;
339 default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs";
340 description = ''
341 SpamAssassin configuration specific to public-inbox
342 '';
343 };
344
345 config = mkOption {
346 type = with types; attrsOf attrs;
347 default = {};
348 description = ''
349 Additional structured config for the public-inbox config file
350 '';
351 };
352 };
353
354 config = mkIf cfg.enable {
355
356 assertions = [
357 { assertion = config.services.spamassassin.enable || !useSpamAssassin;
358 message = ''
359 public-inbox is configured to use SpamAssassin, but
360 services.spamassassin.enable is false. If you don't need
361 spam checking, set services.public-inbox.mda.spamCheck and
362 services.public-inbox.watch.spamCheck to null.
363 '';
364 }
365 { assertion = cfg.path != [] || !useSpamAssassin;
366 message = ''
367 public-inbox is configured to use SpamAssassin, but there is
368 no spamc executable in services.public-inbox.path. If you
369 don't need spam checking, set
370 services.public-inbox.mda.spamCheck and
371 services.public-inbox.watch.spamCheck to null.
372 '';
373 }
374 ];
375
376 users = {
377 users.public-inbox = {
378 inherit home;
379 group = "public-inbox";
380 isSystemUser = true;
381 };
382 groups.public-inbox = {};
383 };
384
385 systemd.sockets = {
386 public-inbox-httpd = {
387 inherit (cfg.http) listenStreams;
388 wantedBy = [ "sockets.target" ];
389 };
390 public-inbox-imapd = {
391 inherit (cfg.imap) listenStreams;
392 wantedBy = [ "sockets.target" ];
393 };
394 public-inbox-nntpd = {
395 inherit (cfg.nntp) listenStreams;
396 wantedBy = [ "sockets.target" ];
397 };
398 };
399
400 systemd.services = {
401 public-inbox-httpd = {
402 inherit (environment);
403 /*
404 environment = environment // {
405 PATH = mkForce (lib.makeBinPath [
406 pkgs.coreutils pkgs.findutils pkgs.gnugrep pkgs.gnused pkgs.systemd
407 (pkgs.writeShellScriptBin "git" ''
408 set -x
409 ${pkgs.git}/bin/git "$@"
410 '')
411 ]);
412 };
413 */
414 after = [ "public-inbox-watch.service" ];
415 serviceConfig = {
416 ExecStart = "${cfg.package}/bin/public-inbox-httpd ${psgi}";
417 NonBlocking = true;
418 DynamicUser = true;
419 Group = "public-inbox";
420 };
421 };
422 public-inbox-imapd = {
423 inherit environment;
424 after = [ "public-inbox-watch.service" ];
425 #environment.PERL_INLINE_DIRECTORY = "/tmp/.pub-inline";
426 #environment.LimitNOFILE = 30000;
427 serviceConfig = {
428 ExecStart = escapeShellArgs (
429 [ "${cfg.package}/bin/public-inbox-imapd" "-W0" ] ++
430 optionals (cfg.imap.cert != null) [ "--cert" cfg.imap.cert ] ++
431 optionals (cfg.imap.key != null) [ "--key" cfg.imap.key ]
432 );
433 # NonBlocking is REQUIRED to avoid a race condition
434 # if running simultaneous services
435 NonBlocking = true;
436 DynamicUser = true;
437 Group = "public-inbox";
438 };
439 };
440 public-inbox-nntpd = {
441 inherit environment;
442 after = [ "public-inbox-watch.service" ];
443 serviceConfig.ExecStart = escapeShellArgs (
444 [ "${cfg.package}/bin/public-inbox-nntpd" ] ++
445 optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ] ++
446 optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ]
447 );
448 serviceConfig.NonBlocking = true;
449 serviceConfig.DynamicUser = true;
450 serviceConfig.Group = "public-inbox";
451 };
452 public-inbox-watch = {
453 inherit environment;
454 inherit (cfg) path;
455 after = optional (cfg.watch.spamCheck == "spamc") "spamassassin.service";
456 wantedBy = optional enableWatch "multi-user.target";
457 serviceConfig = {
458 ExecStart = "${cfg.package}/bin/public-inbox-watch";
459 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
460 User = "public-inbox";
461 Group = "public-inbox";
462 StateDirectory = [ "public-inbox/inboxes" "public-inbox/emergency" ];
463 StateDirectoryMode = "0750";
464 PrivateTmp = true;
465 };
466 preStart = ''
467 ${optionalString useSpamAssassin ''
468 install -m 0700 -o spamd -d ${stateDir}/spamassassin
469 ${optionalString (cfg.spamAssassinRules != null) ''
470 ln -sf ${cfg.spamAssassinRules} ${stateDir}/spamassassin/user_prefs
471 ''}
472 ''}
473
474 ${concatStrings (mapAttrsToList (name: { address, url, ... } @ inbox: ''
475 if [ ! -e ${stateDir}/inboxes/"${escapeShellArg name}" ]; then
476 # public-inbox-init creates an inbox and adds it to a config file.
477 # It tries to atomically write the config file by creating
478 # another file in the same directory, and renaming it.
479 # This has the sad consequence that we can't use
480 # /dev/null, or it would try to create a file in /dev.
481 conf_dir="$(mktemp -d)"
482
483 env PI_CONFIG=$conf_dir/conf \
484 ${cfg.package}/bin/public-inbox-init -V2 \
485 ${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" url ] ++ address)}
486
487 rm -rf $conf_dir
488 fi
489
490 ln -sf ${descriptionFile inbox} ${stateDir}/inboxes/"${escapeShellArg name}"/description
491
492 git=${stateDir}/inboxes/"${escapeShellArg name}"/all.git
493 if [ -d "$git" ]; then
494 # Config is inherited by each epoch repository,
495 # so just needs to be set for all.git.
496 ${pkgs.git}/bin/git --git-dir "$git" \
497 config core.sharedRepository 0640
498 fi
499 '') cfg.inboxes)}
500
501 for inbox in ${stateDir}/inboxes/*/; do
502 ls -1 "$inbox" | grep -q '^xap' && continue
503
504 # This should be idempotent, but only do it for new
505 # inboxes anyway because it's only needed once, and could
506 # be slow for large pre-existing inboxes.
507 env ${concatStringsSep " " envList} \
508 ${cfg.package}/bin/public-inbox-index "$inbox"
509 done
510 '';
511 };
512 };
513
514 environment.systemPackages = with pkgs; [ cfg.package ];
515 };
516 }