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