1 { lib, pkgs, config, ... }:
6 cfg = config.services.public-inbox;
8 inboxesDir = "/var/lib/public-inbox/inboxes";
9 inboxPath = name: "${inboxesDir}/${name}";
10 gitPath = name: "${inboxPath name}/all.git";
12 inboxes = mapAttrs (name: inbox:
14 inherit (inbox) address url newsgroup watch;
15 mainrepo = inboxPath name;
16 watchheader = inbox.watchHeader;
20 concat = concatMap id;
23 concat (mapAttrsToList (name': value':
24 if isAttrs value' then
25 map ({ name, value }: nameValuePair "${name'}.${name}" value)
27 else if isList value' then map (nameValuePair name') value'
28 else if value' == null then []
29 else [ (nameValuePair name' value') ]) attrs);
31 configFull = recursiveUpdate {
32 publicinbox = inboxes // {
33 nntpserver = cfg.nntpServer;
34 wwwlisting = cfg.wwwListing;
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;
43 configList = configToList configFull;
45 gitConfig = key: val: ''
46 ${pkgs.git}/bin/git config --add --file $out ${escapeShellArgs [ key val ]}
49 configFile = pkgs.runCommand "public-inbox-config" {}
50 (concatStrings (map ({ name, value }: gitConfig name value) configList));
53 PI_EMERGENCY = "/var/lib/public-inbox/emergency";
54 PI_CONFIG = configFile;
57 envList = mapAttrsToList (n: v: "${n}=${v}") environment;
59 # Can't use pkgs.linkFarm,
60 # because Postfix rejects .forward if it's a symlink.
61 home = pkgs.runCommand "public-inbox-home" {
63 |"env ${concatStringsSep " " envList} PATH=\"${makeBinPath cfg.path}:$PATH\" ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
65 passAsFile = [ "forward" ];
68 ln -s /var/lib/public-inbox/spamassassin $out/.spamassassin
69 cp $forwardPath $out/.forward
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>
80 my $www = PublicInbox::WWW->new;
85 enable 'ReverseProxy';
86 ${concatMapStrings (path: ''
87 mount q(${path}) => sub { $www->call(@_); };
92 descriptionFile = { description, ... }:
93 pkgs.writeText "description" description;
95 enableWatch = (any (i: i.watch != []) (attrValues cfg.inboxes))
96 || (cfg.watch.watchSpam != null);
98 useSpamAssassin = cfg.mda.spamCheck == "spamc" ||
99 cfg.watch.spamCheck == "spamc";
105 services.public-inbox = {
106 enable = mkEnableOption "the public-inbox mail archiver";
109 type = types.package;
110 default = pkgs.public-inbox;
112 public-inbox package to use with the public-inbox module
117 type = with types; listOf package;
119 example = literalExample "with pkgs; [ spamassassin ]";
121 Additional packages to place in the path of public-inbox-mda,
122 public-inbox-watch, etc.
128 Inboxes to configure, where attribute names are inbox names
130 type = with types; loaOf (submodule {
134 example = "example-discuss@example.org";
140 example = "https://example.org/lists/example-discuss";
142 URL where this inbox can be accessed over HTTP
146 description = mkOption {
148 example = "user/dev discussion of public-inbox itself";
150 User-visible description for the repository
158 Additional structured config for the inbox
162 newsgroup = mkOption {
166 NNTP group name for the inbox
174 Paths for public-inbox-watch(1) to monitor for new mail
176 example = [ "maildir:/path/to/test.example.com.git" ];
179 watchHeader = mkOption {
182 example = "List-Id:<test@example.com>";
184 If specified, public-inbox-watch(1) will only process
185 mail containing a matching header.
194 type = with types; listOf str;
197 Command-line arguments to pass to public-inbox-mda(1).
201 spamCheck = mkOption {
202 type = with types; nullOr (enum [ "spamc" ]);
205 If set to spamc, public-inbox-mda(1) will filter spam
212 spamCheck = mkOption {
213 type = with types; nullOr (enum [ "spamc" ]);
216 If set to spamc, public-inbox-watch(1) will filter spam
221 watchSpam = mkOption {
222 type = with types; nullOr str;
224 example = "maildir:/path/to/spam";
226 If set, mail in this maildir will be trained as spam and
227 deleted from all watched inboxes
234 type = with types; listOf str;
236 example = [ "/lists/archives" ];
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.
244 listenStreams = mkOption {
245 type = with types; listOf str;
246 default = [ "/run/public-inbox-httpd.sock" ];
248 systemd.socket(5) ListenStream values for the
249 public-inbox-httpd service to listen on
255 listenStreams = mkOption {
256 type = with types; listOf str;
257 default = [ "0.0.0.0:119" "0.0.0.0:563" ];
259 systemd.socket(5) ListenStream values for the
260 public-inbox-nntpd service to listen on
265 type = with types; nullOr str;
267 example = "/path/to/fullchain.pem";
269 Path to TLS certificate to use for public-inbox NNTP connections
274 type = with types; nullOr str;
276 example = "/path/to/key.pem";
278 Path to TLS key to use for public-inbox NNTP connections
282 extraGroups = mkOption {
283 type = with types; listOf str;
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
296 nntpServer = mkOption {
297 type = with types; listOf str;
299 example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ];
301 NNTP URLs to this public-inbox instance
305 wwwListing = mkOption {
306 type = with types; enum [ "all" "404" "match=domain" ];
309 Controls which lists (if any) are listed for when the root
310 public-inbox URL is accessed over HTTP.
314 spamAssassinRules = mkOption {
315 type = with types; nullOr path;
316 default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs";
318 SpamAssassin configuration specific to public-inbox
323 type = with types; attrsOf attrs;
326 Additional structured config for the public-inbox config file
332 config = mkIf cfg.enable {
335 { assertion = config.services.spamassassin.enable || !useSpamAssassin;
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.
343 { assertion = cfg.path != [] || !useSpamAssassin;
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.
354 users.users.public-inbox = {
356 group = "public-inbox";
360 users.groups.public-inbox = {};
362 systemd.sockets.public-inbox-httpd = {
363 inherit (cfg.http) listenStreams;
364 wantedBy = [ "sockets.target" ];
367 systemd.sockets.public-inbox-nntpd = {
368 inherit (cfg.nntp) listenStreams;
369 wantedBy = [ "sockets.target" ];
372 systemd.services.public-inbox-httpd = {
374 serviceConfig.ExecStart = "${cfg.package}/bin/public-inbox-httpd ${psgi}";
375 serviceConfig.NonBlocking = true;
376 serviceConfig.DynamicUser = true;
377 serviceConfig.SupplementaryGroups = [ "public-inbox" ];
380 systemd.services.public-inbox-nntpd = {
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 ])
387 serviceConfig.NonBlocking = true;
388 serviceConfig.DynamicUser = true;
389 serviceConfig.SupplementaryGroups = [ "public-inbox" ] ++ cfg.nntp.extraGroups;
392 systemd.services.public-inbox-watch = {
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";
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
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
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)"
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)}
431 ln -sf ${descriptionFile inbox} ${inboxPath name}/description
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
441 for inbox in /var/lib/public-inbox/inboxes/*/; do
442 ls -1 "$inbox" | grep -q '^xap' && continue
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"
454 environment.systemPackages = with pkgs; [ cfg.package ];