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