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