diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 4b2cb803e20..6e6cc9879ac 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -490,6 +490,7 @@
./services/mail/postfixadmin.nix
./services/mail/postsrsd.nix
./services/mail/postgrey.nix
+ ./services/mail/public-inbox.nix
./services/mail/spamassassin.nix
./services/mail/rspamd.nix
./services/mail/rss2email.nix
diff --git a/nixos/modules/services/mail/public-inbox.nix b/nixos/modules/services/mail/public-inbox.nix
new file mode 100644
index 00000000000..039eae5670a
--- /dev/null
+++ b/nixos/modules/services/mail/public-inbox.nix
@@ -0,0 +1,560 @@
+{ lib, pkgs, config, ... }:
+
+with lib;
+
+let
+ cfg = config.services.public-inbox;
+ stateDir = "/var/lib/public-inbox";
+
+ manref = name: vol: "${name}${toString vol}";
+
+ gitIni = pkgs.formats.gitIni { listsAsDuplicateKeys = true; };
+ iniAtom = elemAt gitIni.type/*attrsOf*/.functor.wrapped/*attrsOf*/.functor.wrapped/*either*/.functor.wrapped 0;
+
+ useSpamAssassin = cfg.settings.publicinboxmda.spamcheck == "spamc" ||
+ cfg.settings.publicinboxwatch.spamcheck == "spamc";
+
+ publicInboxDaemonOptions = proto: defaultPort: {
+ args = mkOption {
+ type = with types; listOf str;
+ default = [];
+ description = "Command-line arguments to pass to ${manref "public-inbox-${proto}d" 1}.";
+ };
+ port = mkOption {
+ type = with types; nullOr (either str port);
+ default = defaultPort;
+ description = ''
+ Listening port.
+ Beware that public-inbox uses well-known ports number to decide whether to enable TLS or not.
+ Set to null and use systemd.sockets.public-inbox-${proto}d.listenStreams
+ if you need a more advanced listening.
+ '';
+ };
+ cert = mkOption {
+ type = with types; nullOr str;
+ default = null;
+ example = "/path/to/fullchain.pem";
+ description = "Path to TLS certificate to use for connections to ${manref "public-inbox-${proto}d" 1}.";
+ };
+ key = mkOption {
+ type = with types; nullOr str;
+ default = null;
+ example = "/path/to/key.pem";
+ description = "Path to TLS key to use for connections to ${manref "public-inbox-${proto}d" 1}.";
+ };
+ };
+
+ serviceConfig = srv:
+ let proto = removeSuffix "d" srv;
+ needNetwork = builtins.hasAttr proto cfg && cfg.${proto}.port == null;
+ in {
+ # Enable JIT-compiled C (via Inline::C)
+ Environment = [ "PERL_INLINE_DIRECTORY=/run/public-inbox-${srv}/perl-inline" ];
+ # NonBlocking is REQUIRED to avoid a race condition
+ # if running simultaneous services.
+ NonBlocking = true;
+ #LimitNOFILE = 30000;
+ User = config.users.users."public-inbox".name;
+ Group = config.users.groups."public-inbox".name;
+ RuntimeDirectory = [
+ "public-inbox-${srv}/perl-inline"
+ # Create RootDirectory= in the host's mount namespace.
+ "public-inbox-${srv}/root"
+ ];
+ RuntimeDirectoryMode = "700";
+ # Avoid mounting RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace.
+ InaccessiblePaths = ["-+/run/public-inbox-${srv}/root"];
+ # This is for BindPaths= and BindReadOnlyPaths=
+ # to allow traversal of directories they create in RootDirectory=.
+ UMask = "0066";
+ RootDirectory = "/run/public-inbox-${srv}/root";
+ RootDirectoryStartOnly = true;
+ WorkingDirectory = stateDir;
+ MountAPIVFS = true;
+ BindReadOnlyPaths = [
+ builtins.storeDir
+ "/etc"
+ "/run"
+ # For Inline::C
+ "/bin/sh"
+ ];
+ BindPaths = [
+ stateDir
+ ];
+ # The following options are only for optimizing:
+ # systemd-analyze security public-inbox-'*'
+ AmbientCapabilities = "";
+ CapabilityBoundingSet = "";
+ # ProtectClock= adds DeviceAllow=char-rtc r
+ DeviceAllow = "";
+ LockPersonality = true;
+ MemoryDenyWriteExecute = true;
+ NoNewPrivileges = true;
+ PrivateDevices = true;
+ PrivateMounts = true;
+ PrivateNetwork = mkDefault (!needNetwork);
+ PrivateTmp = true;
+ PrivateUsers = true;
+ ProcSubset = "pid";
+ ProtectClock = true;
+ ProtectControlGroups = true;
+ ProtectHome = mkDefault true;
+ ProtectHostname = true;
+ ProtectKernelLogs = true;
+ ProtectKernelModules = true;
+ ProtectKernelTunables = true;
+ ProtectProc = "invisible";
+ ProtectSystem = "strict";
+ RemoveIPC = true;
+ RestrictAddressFamilies = [ "AF_UNIX" ]
+ ++ optionals needNetwork [ "AF_INET" "AF_INET6" ];
+ RestrictNamespaces = true;
+ RestrictRealtime = true;
+ RestrictSUIDSGID = true;
+ SystemCallFilter = [
+ "@system-service"
+ "~@aio" "~@chown" "~@keyring" "~@memlock" "~@resources"
+ # Not removing @setuid and @privileged
+ # because Inline::C needs them.
+ # Not removing @timer
+ # because git upload-pack needs it.
+ ];
+ SystemCallArchitectures = "native";
+ };
+in
+
+{
+ options.services.public-inbox = {
+ enable = mkEnableOption "the public-inbox mail archiver";
+ package = mkOption {
+ type = types.package;
+ default = pkgs.public-inbox;
+ defaultText = literalExpression "pkgs.public-inbox";
+ description = "public-inbox package to use.";
+ };
+ path = mkOption {
+ type = with types; listOf package;
+ default = [];
+ example = literalExpression "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.
+ '';
+ default = {};
+ type = types.attrsOf (types.submodule ({name, ...}: {
+ freeformType = types.attrsOf iniAtom;
+ options.inboxdir = mkOption {
+ type = types.str;
+ default = "${stateDir}/inboxes/${name}";
+ description = "The absolute path to the directory which hosts the public-inbox.";
+ };
+ options.address = mkOption {
+ type = with types; listOf str;
+ example = "example-discuss@example.org";
+ description = "The email addresses of the public-inbox.";
+ };
+ options.url = mkOption {
+ type = with types; nullOr str;
+ default = null;
+ example = "https://example.org/lists/example-discuss";
+ description = "URL where this inbox can be accessed over HTTP.";
+ };
+ options.description = mkOption {
+ type = types.str;
+ example = "user/dev discussion of public-inbox itself";
+ description = "User-visible description for the repository.";
+ };
+ options.newsgroup = mkOption {
+ type = with types; nullOr str;
+ default = null;
+ description = "NNTP group name for the inbox.";
+ };
+ options.watch = mkOption {
+ type = with types; listOf str;
+ default = [];
+ description = "Paths for ${manref "public-inbox-watch" 1} to monitor for new mail.";
+ example = [ "maildir:/path/to/test.example.com.git" ];
+ };
+ options.watchheader = mkOption {
+ type = with types; nullOr str;
+ default = null;
+ example = "List-Id:";
+ description = ''
+ If specified, ${manref "public-inbox-watch" 1} will only process
+ mail containing a matching header.
+ '';
+ };
+ options.coderepo = mkOption {
+ type = (types.listOf (types.enum (attrNames cfg.settings.coderepo))) // {
+ description = "list of coderepo names";
+ };
+ default = [];
+ description = "Nicknames of a 'coderepo' section associated with the inbox.";
+ };
+ }));
+ };
+ imap = {
+ enable = mkEnableOption "the public-inbox IMAP server";
+ } // publicInboxDaemonOptions "imap" 993;
+ http = {
+ enable = mkEnableOption "the public-inbox HTTP server";
+ 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.
+ '';
+ };
+ args = (publicInboxDaemonOptions "http" 80).args;
+ port = mkOption {
+ type = with types; nullOr (either str port);
+ default = 80;
+ example = "/run/public-inbox-httpd.sock";
+ description = ''
+ Listening port or systemd's ListenStream= entry
+ to be used as a reverse proxy, eg. in nginx:
+ locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox";
+ Set to null and use systemd.sockets.public-inbox-httpd.listenStreams
+ if you need a more advanced listening.
+ '';
+ };
+ };
+ mda = {
+ enable = mkEnableOption "the public-inbox Mail Delivery Agent";
+ args = mkOption {
+ type = with types; listOf str;
+ default = [];
+ description = "Command-line arguments to pass to ${manref "public-inbox-mda" 1}.";
+ };
+ };
+ postfix.enable = mkEnableOption "the integration into Postfix";
+ nntp = {
+ enable = mkEnableOption "the public-inbox NNTP server";
+ } // publicInboxDaemonOptions "nntp" 563;
+ spamAssassinRules = mkOption {
+ type = with types; nullOr path;
+ default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs";
+ defaultText = literalExpression "\${cfg.package.sa_config}/user/.spamassassin/user_prefs";
+ description = "SpamAssassin configuration specific to public-inbox.";
+ };
+ settings = mkOption {
+ description = ''
+ Settings for the public-inbox config file.
+ '';
+ default = {};
+ type = types.submodule {
+ freeformType = gitIni.type;
+ options.publicinbox = mkOption {
+ default = {};
+ description = "public inboxes";
+ type = types.submodule {
+ freeformType = with types; /*inbox name*/attrsOf (/*inbox option name*/attrsOf /*inbox option value*/iniAtom);
+ options.css = mkOption {
+ type = with types; listOf str;
+ default = [];
+ description = "The local path name of a CSS file for the PSGI web interface.";
+ };
+ options.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";
+ };
+ options.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.
+ '';
+ };
+ };
+ };
+ options.publicinboxmda.spamcheck = mkOption {
+ type = with types; enum [ "spamc" "none" ];
+ default = "none";
+ description = ''
+ If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
+ using SpamAssassin.
+ '';
+ };
+ options.publicinboxwatch.spamcheck = mkOption {
+ type = with types; enum [ "spamc" "none" ];
+ default = "none";
+ description = ''
+ If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
+ using SpamAssassin.
+ '';
+ };
+ options.publicinboxwatch.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
+ '';
+ };
+ options.coderepo = mkOption {
+ default = {};
+ description = "code repositories";
+ type = types.attrsOf (types.submodule {
+ freeformType = types.attrsOf iniAtom;
+ options.cgitUrl = mkOption {
+ type = types.str;
+ description = "URL of a cgit instance";
+ };
+ options.dir = mkOption {
+ type = types.str;
+ description = "Path to a git repository";
+ };
+ });
+ };
+ };
+ };
+ openFirewall = mkEnableOption "opening the firewall when using a port option";
+ };
+ 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.settings.publicinboxmda.spamcheck' and
+ `services.public-inbox.settings.publicinboxwatch.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.settings.publicinboxmda.spamcheck' and
+ `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
+ '';
+ }
+ ];
+ services.public-inbox.settings =
+ filterAttrsRecursive (n: v: v != null) {
+ publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes;
+ };
+ users = {
+ users.public-inbox = {
+ home = stateDir;
+ group = "public-inbox";
+ isSystemUser = true;
+ };
+ groups.public-inbox = {};
+ };
+ networking.firewall = mkIf cfg.openFirewall
+ { allowedTCPPorts = mkMerge (map (proto:
+ (mkIf (cfg.${proto}.enable && types.port.check cfg.proto.port) [ cfg.proto.port ])
+ ["imap" "http" "nntp"]));
+ };
+ services.postfix = mkIf (cfg.postfix.enable && cfg.mda.enable) {
+ # Not sure limiting to 1 is necessary, but better safe than sorry.
+ config.public-inbox_destination_recipient_limit = "1";
+
+ # Register the addresses as existing
+ virtual =
+ concatStringsSep "\n" (mapAttrsToList (_: inbox:
+ concatMapStringsSep "\n" (address:
+ "${address} ${address}"
+ ) inbox.address
+ ) cfg.inboxes);
+
+ # Deliver the addresses with the public-inbox transport
+ transport =
+ concatStringsSep "\n" (mapAttrsToList (_: inbox:
+ concatMapStringsSep "\n" (address:
+ "${address} public-inbox:${address}"
+ ) inbox.address
+ ) cfg.inboxes);
+
+ # The public-inbox transport
+ masterConfig.public-inbox = {
+ type = "unix";
+ privileged = true; # Required for user=
+ command = "pipe";
+ args = [
+ "flags=X" # Report as a final delivery
+ "user=${with config.users; users."public-inbox".name + ":" + groups."public-inbox".name}"
+ # Specifying a nexthop when using the transport
+ # (eg. test public-inbox:test) allows to
+ # receive mails with an extension (eg. test+foo).
+ "argv=${pkgs.writeShellScript "public-inbox-transport" ''
+ export HOME="${stateDir}"
+ export ORIGINAL_RECIPIENT="''${2:-1}"
+ export PATH="${makeBinPath cfg.path}:$PATH"
+ exec ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
+ ''} \${original_recipient} \${nexthop}"
+ ];
+ };
+ };
+ systemd.sockets = mkMerge (map (proto:
+ mkIf (cfg.${proto}.enable && cfg.${proto}.port != null)
+ { "public-inbox-${proto}d" = {
+ listenStreams = [ (toString cfg.${proto}.port) ];
+ wantedBy = [ "sockets.target" ];
+ };
+ }
+ ) [ "imap" "http" "nntp" ]);
+ systemd.services = mkMerge [
+ (mkIf cfg.imap.enable
+ { public-inbox-imapd = {
+ after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
+ requires = [ "public-inbox-init.service" ];
+ serviceConfig = mkMerge [(serviceConfig "imapd") {
+ ExecStart = escapeShellArgs (
+ [ "${cfg.package}/bin/public-inbox-imapd" ] ++
+ cfg.imap.args ++
+ optionals (cfg.imap.cert != null) [ "--cert" cfg.imap.cert ] ++
+ optionals (cfg.imap.key != null) [ "--key" cfg.imap.key ]
+ );
+ }];
+ };
+ })
+ (mkIf cfg.http.enable
+ { public-inbox-httpd = {
+ after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
+ requires = [ "public-inbox-init.service" ];
+ serviceConfig = mkMerge [(serviceConfig "httpd") {
+ ExecStart = escapeShellArgs (
+ [ "${cfg.package}/bin/public-inbox-httpd" ] ++
+ cfg.http.args ++
+ # See https://public-inbox.org/public-inbox.git/tree/examples/public-inbox.psgi
+ # for upstream's example.
+ [ (pkgs.writeText "public-inbox.psgi" ''
+ #!${cfg.package.fullperl} -w
+ use strict;
+ use warnings;
+ use Plack::Builder;
+ use PublicInbox::WWW;
+
+ my $www = PublicInbox::WWW->new;
+ $www->preload;
+
+ builder {
+ # If reached through a reverse proxy,
+ # make it transparent by resetting some HTTP headers
+ # used by public-inbox to generate URIs.
+ enable 'ReverseProxy';
+
+ # No need to send a response body if it's an HTTP HEAD requests.
+ enable 'Head';
+
+ # Route according to configured domains and root paths.
+ ${concatMapStrings (path: ''
+ mount q(${path}) => sub { $www->call(@_); };
+ '') cfg.http.mounts}
+ }
+ '') ]
+ );
+ }];
+ };
+ })
+ (mkIf cfg.nntp.enable
+ { public-inbox-nntpd = {
+ after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
+ requires = [ "public-inbox-init.service" ];
+ serviceConfig = mkMerge [(serviceConfig "nntpd") {
+ ExecStart = escapeShellArgs (
+ [ "${cfg.package}/bin/public-inbox-nntpd" ] ++
+ cfg.nntp.args ++
+ optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ] ++
+ optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ]
+ );
+ }];
+ };
+ })
+ (mkIf (any (inbox: inbox.watch != []) (attrValues cfg.inboxes)
+ || cfg.settings.publicinboxwatch.watchspam != null)
+ { public-inbox-watch = {
+ inherit (cfg) path;
+ wants = [ "public-inbox-init.service" ];
+ requires = [ "public-inbox-init.service" ] ++
+ optional (cfg.settings.publicinboxwatch.spamcheck == "spamc") "spamassassin.service";
+ wantedBy = [ "multi-user.target" ];
+ serviceConfig = mkMerge [(serviceConfig "watch") {
+ ExecStart = "${cfg.package}/bin/public-inbox-watch";
+ ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
+ }];
+ };
+ })
+ ({ public-inbox-init = let
+ PI_CONFIG = gitIni.generate "public-inbox.ini"
+ (filterAttrsRecursive (n: v: v != null) cfg.settings);
+ in {
+ wantedBy = [ "multi-user.target" ];
+ restartIfChanged = true;
+ restartTriggers = [ PI_CONFIG ];
+ script = ''
+ set -ux
+ install -D -p ${PI_CONFIG} ${stateDir}/.public-inbox/config
+ '' + optionalString useSpamAssassin ''
+ install -m 0700 -o spamd -d ${stateDir}/.spamassassin
+ ${optionalString (cfg.spamAssassinRules != null) ''
+ ln -sf ${cfg.spamAssassinRules} ${stateDir}/.spamassassin/user_prefs
+ ''}
+ '' + concatStrings (mapAttrsToList (name: inbox: ''
+ if [ ! -e ${stateDir}/inboxes/${escapeShellArg 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="$(mktemp -d)"
+
+ PI_CONFIG=$conf_dir/conf \
+ ${cfg.package}/bin/public-inbox-init -V2 \
+ ${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)}
+
+ rm -rf $conf_dir
+ fi
+
+ ln -sf ${pkgs.writeText "description" inbox.description} \
+ ${stateDir}/inboxes/${escapeShellArg name}/description
+
+ export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git
+ if test -d "$GIT_DIR"; then
+ # Config is inherited by each epoch repository,
+ # so just needs to be set for all.git.
+ ${pkgs.git}/bin/git config core.sharedRepository 0640
+ fi
+ '') cfg.inboxes
+ ) + ''
+ shopt -s nullglob
+ for inbox in ${stateDir}/inboxes/*/; do
+ # 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.
+ ls -1 "$inbox" | grep -q '^xap' ||
+ ${cfg.package}/bin/public-inbox-index "$inbox"
+ done
+ '';
+ serviceConfig = mkMerge [(serviceConfig "init") {
+ Type = "oneshot";
+ RemainAfterExit = true;
+ StateDirectory = [
+ "public-inbox"
+ "public-inbox/.public-inbox"
+ "public-inbox/.public-inbox/emergency"
+ "public-inbox/inboxes"
+ ];
+ StateDirectoryMode = "0750";
+ }];
+ };
+ })
+ ];
+ environment.systemPackages = with pkgs; [ cfg.package ];
+ };
+ meta.maintainers = with lib.maintainers; [ julm qyliss ];
+}
diff --git a/pkgs/pkgs-lib/formats.nix b/pkgs/pkgs-lib/formats.nix
index 5e17519d4ce..e6e6a95c1f4 100644
--- a/pkgs/pkgs-lib/formats.nix
+++ b/pkgs/pkgs-lib/formats.nix
@@ -123,6 +123,17 @@ rec {
};
+ gitIni = { listsAsDuplicateKeys ? false, ... }@args: {
+
+ type = with lib.types; let
+
+ iniAtom = (ini args).type/*attrsOf*/.functor.wrapped/*attrsOf*/.functor.wrapped;
+
+ in attrsOf (attrsOf (either iniAtom (attrsOf iniAtom)));
+
+ generate = name: value: pkgs.writeText name (lib.generators.toGitINI value);
+ };
+
toml = {}: json {} // {
type = with lib.types; let
valueType = oneOf [