{ lib, pkgs, config, ... }:
with lib;
let
cfg = config.services.public-inbox;
inboxesDir = "/var/lib/public-inbox/inboxes";
inboxPath = name: "${inboxesDir}/${name}";
gitPath = name: "${inboxPath name}/all.git";
inboxes = mapAttrs (name: inbox:
(recursiveUpdate {
inherit (inbox) address url newsgroup watch;
mainrepo = inboxPath name;
watchheader = inbox.watchHeader;
} inbox.config))
cfg.inboxes;
concat = concatMap id;
configToList = attrs:
concat (mapAttrsToList (name': value':
if isAttrs value' then
map ({ name, value }: nameValuePair "${name'}.${name}" value)
(configToList value')
else if isList value' then map (nameValuePair name') value'
else if value' == null then []
else [ (nameValuePair name' value') ]) attrs);
configFull = recursiveUpdate {
publicinbox = inboxes // {
nntpserver = cfg.nntpServer;
wwwlisting = cfg.wwwListing;
};
publicinboxmda.spamcheck =
if (cfg.mda.spamCheck == null) then "none" else cfg.mda.spamCheck;
publicinboxwatch.spamcheck =
if (cfg.watch.spamCheck == null) then "none" else cfg.watch.spamCheck;
publicinboxwatch.watchspam = cfg.watch.watchSpam;
} cfg.config;
configList = configToList configFull;
gitConfig = key: val: ''
${pkgs.git}/bin/git config --add --file $out ${escapeShellArgs [ key val ]}
'';
configFile = pkgs.runCommand "public-inbox-config" {}
(concatStrings (map ({ name, value }: gitConfig name value) configList));
environment = {
PI_EMERGENCY = "/var/lib/public-inbox/emergency";
PI_CONFIG = configFile;
};
envList = mapAttrsToList (n: v: "${n}=${v}") environment;
# Can't use pkgs.linkFarm,
# because Postfix rejects .forward if it's a symlink.
home = pkgs.runCommand "public-inbox-home" {
forward = ''
|"env ${concatStringsSep " " envList} PATH=\"${makeBinPath cfg.path}:$PATH\" ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
'';
passAsFile = [ "forward" ];
} ''
mkdir $out
ln -s /var/lib/public-inbox/spamassassin $out/.spamassassin
cp $forwardPath $out/.forward
install -D -p ${configFile} $out/.public-inbox/config
'';
psgi = pkgs.writeText "public-inbox.psgi" ''
#!${cfg.package.fullperl} -w
# Copyright (C) 2014-2019 all contributors
# License: GPL-3.0+
use strict;
use PublicInbox::WWW;
use Plack::Builder;
my $www = PublicInbox::WWW->new;
$www->preload;
builder {
enable 'Head';
enable 'ReverseProxy';
${concatMapStrings (path: ''
mount q(${path}) => sub { $www->call(@_); };
'') cfg.http.mounts}
}
'';
descriptionFile = { description, ... }:
pkgs.writeText "description" description;
enableWatch = (any (i: i.watch != []) (attrValues cfg.inboxes))
|| (cfg.watch.watchSpam != null);
useSpamAssassin = cfg.mda.spamCheck == "spamc" ||
cfg.watch.spamCheck == "spamc";
in
{
options = {
services.public-inbox = {
enable = mkEnableOption "the public-inbox mail archiver";
package = mkOption {
type = types.package;
default = pkgs.public-inbox;
description = ''
public-inbox package to use with the public-inbox module
'';
};
path = mkOption {
type = with types; listOf package;
default = [];
example = literalExample "with pkgs; [ spamassassin ]";
description = ''
Additional packages to place in the path of public-inbox-mda,
public-inbox-watch, etc.
'';
};
inboxes = mkOption {
description = ''
Inboxes to configure, where attribute names are inbox names
'';
type = with types; loaOf (submodule {
options = {
address = mkOption {
type = listOf str;
example = "example-discuss@example.org";
};
url = mkOption {
type = nullOr str;
default = null;
example = "https://example.org/lists/example-discuss";
description = ''
URL where this inbox can be accessed over HTTP
'';
};
description = mkOption {
type = str;
example = "user/dev discussion of public-inbox itself";
description = ''
User-visible description for the repository
'';
};
config = mkOption {
type = attrs;
default = {};
description = ''
Additional structured config for the inbox
'';
};
newsgroup = mkOption {
type = nullOr str;
default = null;
description = ''
NNTP group name for the inbox
'';
};
watch = mkOption {
type = listOf str;
default = [];
description = ''
Paths for public-inbox-watch(1) to monitor for new mail
'';
example = [ "maildir:/path/to/test.example.com.git" ];
};
watchHeader = mkOption {
type = nullOr str;
default = null;
example = "List-Id:";
description = ''
If specified, public-inbox-watch(1) will only process
mail containing a matching header.
'';
};
};
});
};
mda = {
args = mkOption {
type = with types; listOf str;
default = [];
description = ''
Command-line arguments to pass to public-inbox-mda(1).
'';
};
spamCheck = mkOption {
type = with types; nullOr (enum [ "spamc" ]);
default = "spamc";
description = ''
If set to spamc, public-inbox-mda(1) will filter spam
using SpamAssassin
'';
};
};
watch = {
spamCheck = mkOption {
type = with types; nullOr (enum [ "spamc" ]);
default = "spamc";
description = ''
If set to spamc, public-inbox-watch(1) will filter spam
using SpamAssassin
'';
};
watchSpam = mkOption {
type = with types; nullOr str;
default = null;
example = "maildir:/path/to/spam";
description = ''
If set, mail in this maildir will be trained as spam and
deleted from all watched inboxes
'';
};
};
http = {
mounts = mkOption {
type = with types; listOf str;
default = [ "/" ];
example = [ "/lists/archives" ];
description = ''
Root paths or URLs that public-inbox will be served on.
If domain parts are present, only requests to those
domains will be accepted.
'';
};
listenStreams = mkOption {
type = with types; listOf str;
default = [ "/run/public-inbox-httpd.sock" ];
description = ''
systemd.socket(5) ListenStream values for the
public-inbox-httpd service to listen on
'';
};
};
nntp = {
listenStreams = mkOption {
type = with types; listOf str;
default = [ "0.0.0.0:119" "0.0.0.0:563" ];
description = ''
systemd.socket(5) ListenStream values for the
public-inbox-nntpd service to listen on
'';
};
cert = mkOption {
type = with types; nullOr str;
default = null;
example = "/path/to/fullchain.pem";
description = ''
Path to TLS certificate to use for public-inbox NNTP connections
'';
};
key = mkOption {
type = with types; nullOr str;
default = null;
example = "/path/to/key.pem";
description = ''
Path to TLS key to use for public-inbox NNTP connections
'';
};
extraGroups = mkOption {
type = with types; listOf str;
default = [];
example = [ "tls" ];
description = ''
Secondary groups to assign to the systemd DynamicUser
running public-inbox-nntpd, in addition to the
public-inbox group. This is useful for giving
public-inbox-nntpd access to a TLS certificate / key, for
example.
'';
};
};
nntpServer = mkOption {
type = with types; listOf str;
default = [];
example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ];
description = ''
NNTP URLs to this public-inbox instance
'';
};
wwwListing = mkOption {
type = with types; enum [ "all" "404" "match=domain" ];
default = "404";
description = ''
Controls which lists (if any) are listed for when the root
public-inbox URL is accessed over HTTP.
'';
};
spamAssassinRules = mkOption {
type = with types; nullOr path;
default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs";
description = ''
SpamAssassin configuration specific to public-inbox
'';
};
config = mkOption {
type = with types; attrsOf attrs;
default = {};
description = ''
Additional structured config for the public-inbox config file
'';
};
};
};
config = mkIf cfg.enable {
assertions = [
{ assertion = config.services.spamassassin.enable || !useSpamAssassin;
message = ''
public-inbox is configured to use SpamAssassin, but
services.spamassassin.enable is false. If you don't need
spam checking, set services.public-inbox.mda.spamCheck and
services.public-inbox.watch.spamCheck to null.
'';
}
{ assertion = cfg.path != [] || !useSpamAssassin;
message = ''
public-inbox is configured to use SpamAssassin, but there is
no spamc executable in services.public-inbox.path. If you
don't need spam checking, set
services.public-inbox.mda.spamCheck and
services.public-inbox.watch.spamCheck to null.
'';
}
];
users.users.public-inbox = {
inherit home;
group = "public-inbox";
isSystemUser = true;
};
users.groups.public-inbox = {};
systemd.sockets.public-inbox-httpd = {
inherit (cfg.http) listenStreams;
wantedBy = [ "sockets.target" ];
};
systemd.sockets.public-inbox-nntpd = {
inherit (cfg.nntp) listenStreams;
wantedBy = [ "sockets.target" ];
};
systemd.services.public-inbox-httpd = {
inherit environment;
serviceConfig.ExecStart = "${cfg.package}/bin/public-inbox-httpd ${psgi}";
serviceConfig.NonBlocking = true;
serviceConfig.DynamicUser = true;
serviceConfig.SupplementaryGroups = [ "public-inbox" ];
};
systemd.services.public-inbox-nntpd = {
inherit environment;
serviceConfig.ExecStart = escapeShellArgs (
[ "${cfg.package}/bin/public-inbox-nntpd" ] ++
(optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ]) ++
(optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ])
);
serviceConfig.NonBlocking = true;
serviceConfig.DynamicUser = true;
serviceConfig.SupplementaryGroups = [ "public-inbox" ] ++ cfg.nntp.extraGroups;
};
systemd.services.public-inbox-watch = {
inherit environment;
inherit (cfg) path;
after = optional (cfg.watch.spamCheck == "spamc") "spamassassin.service";
wantedBy = optional enableWatch "multi-user.target";
serviceConfig.ExecStart = "${cfg.package}/bin/public-inbox-watch";
serviceConfig.ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
serviceConfig.User = "public-inbox";
};
system.activationScripts.public-inbox = stringAfter [ "users" ] ''
install -m 0755 -o public-inbox -g public-inbox -d /var/lib/public-inbox
install -m 0750 -o public-inbox -g public-inbox -d ${inboxesDir}
install -m 0700 -o public-inbox -g public-inbox -d /var/lib/public-inbox/emergency
${optionalString useSpamAssassin ''
install -m 0700 -o spamd -d /var/lib/public-inbox/spamassassin
${optionalString (cfg.spamAssassinRules != null) ''
ln -sf ${cfg.spamAssassinRules} /var/lib/public-inbox/spamassassin/user_prefs
''}
''}
${concatStrings (mapAttrsToList (name: { address, url, ... } @ inbox: ''
if [ ! -e ${escapeShellArg (inboxPath name)} ]; then
# public-inbox-init creates an inbox and adds it to a config file.
# It tries to atomically write the config file by creating
# another file in the same directory, and renaming it.
# This has the sad consequence that we can't use
# /dev/null, or it would try to create a file in /dev.
conf_dir="$(${pkgs.sudo}/bin/sudo -u public-inbox mktemp -d)"
${pkgs.sudo}/bin/sudo -u public-inbox \
env PI_CONFIG=$conf_dir/conf \
${cfg.package}/bin/public-inbox-init -V2 \
${escapeShellArgs ([ name (inboxPath name) url ] ++ address)}
rm -rf $conf_dir
fi
ln -sf ${descriptionFile inbox} ${inboxPath name}/description
if [ -d ${escapeShellArg (gitPath name)} ]; then
# Config is inherited by each epoch repository,
# so just needs to be set for all.git.
${pkgs.git}/bin/git --git-dir ${gitPath name} \
config core.sharedRepository 0640
fi
'') cfg.inboxes)}
for inbox in /var/lib/public-inbox/inboxes/*/; do
ls -1 "$inbox" | grep -q '^xap' && continue
# This should be idempotent, but only do it for new
# inboxes anyway because it's only needed once, and could
# be slow for large pre-existing inboxes.
${pkgs.sudo}/bin/sudo -u public-inbox \
env ${concatStringsSep " " envList} \
${cfg.package}/bin/public-inbox-index "$inbox"
done
'';
environment.systemPackages = with pkgs; [ cfg.package ];
};
}