public-inbox: fix to support the current nixpkgs
[sourcephile-nix.git] / nixos / modules / services / mail / public-inbox.nix
index 437eaf5364d10ee5bb757234ba023689980992bd..94a4a987e88a84a61e0a5586532243bfbbb00a93 100644 (file)
-{ lib, pkgs, config, ... }:
+{
+  lib,
+  pkgs,
+  config,
+  ...
+}:
 
 with lib;
 
 let
   cfg = config.services.public-inbox;
+  stateDir = "/var/lib/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;
+  gitIni = pkgs.formats.gitIni { listsAsDuplicateKeys = true; };
+  iniAtom = elemAt gitIni.type/*attrsOf*/.functor.wrapped/*attrsOf*/.functor.wrapped/*either*/.functor.wrapped 0;
 
-  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);
+  useSpamAssassin =
+    cfg.settings.publicinboxmda.spamcheck == "spamc"
+    || cfg.settings.publicinboxwatch.spamcheck == "spamc";
 
-  configFull = recursiveUpdate {
-    publicinbox = inboxes // {
-      nntpserver = cfg.nntpServer;
-      wwwlisting = cfg.wwwListing;
+  publicInboxDaemonOptions = proto: defaultPort: {
+    args = mkOption {
+      type = with types; listOf str;
+      default = [ ];
+      description = "Command-line arguments to pass to {manpage}`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 {manpage}`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 {manpage}`public-inbox-${proto}d(1)`.";
     };
-    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
-  '';
-
-  psgi = pkgs.writeText "public-inbox.psgi" ''
-    #!${cfg.package.fullperl} -w
-    # Copyright (C) 2014-2019 all contributors <meta@public-inbox.org>
-    # License: GPL-3.0+ <https://www.gnu.org/licenses/gpl-3.0.txt>
-    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
-        '';
+  serviceConfig =
+    srv:
+    let
+      proto = removeSuffix "d" srv;
+      needNetwork = builtins.hasAttr proto cfg && cfg.${proto}.port == null;
+    in
+    {
+      serviceConfig = {
+        # 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"
+        ];
+        RuntimeDirectoryMode = "700";
+        # This is for BindPaths= and BindReadOnlyPaths=
+        # to allow traversal of directories they create inside RootDirectory=
+        UMask = "0066";
+        StateDirectory = [ "public-inbox" ];
+        StateDirectoryMode = "0750";
+        WorkingDirectory = stateDir;
+        BindReadOnlyPaths =
+          [
+            "/etc"
+            "/run/systemd"
+            "${config.i18n.glibcLocales}"
+          ]
+          ++ mapAttrsToList (name: inbox: inbox.description) cfg.inboxes
+          ++ filter (x: x != null) [
+            cfg.${proto}.cert or null
+            cfg.${proto}.key or null
+          ];
+        # 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;
+        PrivateNetwork = mkDefault (!needNetwork);
+        ProcSubset = "pid";
+        ProtectClock = true;
+        ProtectHome = "tmpfs";
+        ProtectHostname = true;
+        ProtectKernelLogs = 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";
       };
-
-      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.
-        '';
+      confinement = {
+        enable = true;
+        mode = "full-apivfs";
+        # Inline::C needs a /bin/sh, and dash is enough
+        binSh = "${pkgs.dash}/bin/dash";
+        packages = [
+          pkgs.iana-etc
+          (getLib pkgs.nss)
+          pkgs.tzdata
+        ];
       };
+    };
+in
 
-      inboxes = mkOption {
-        description = ''
-          Inboxes to configure, where attribute names are inbox names
-        '';
-        type = with types; loaOf (submodule {
-          options = {
-            address = mkOption {
-              type = listOf str;
+{
+  options.services.public-inbox = {
+    enable = mkEnableOption "the public-inbox mail archiver";
+    package = mkPackageOption pkgs "public-inbox" { };
+    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.";
             };
-
-            url = mkOption {
-              type = nullOr str;
-              default = null;
+            options.url = mkOption {
+              type = types.nonEmptyStr;
               example = "https://example.org/lists/example-discuss";
-              description = ''
-                URL where this inbox can be accessed over HTTP
-              '';
+              description = "URL where this inbox can be accessed over HTTP.";
             };
-
-            description = mkOption {
-              type = str;
+            options.description = mkOption {
+              type = types.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
-              '';
+              description = "User-visible description for the repository.";
+              apply = pkgs.writeText "public-inbox-description-${name}";
             };
-
-            newsgroup = mkOption {
-              type = nullOr str;
+            options.newsgroup = mkOption {
+              type = with types; nullOr str;
               default = null;
-              description = ''
-                NNTP group name for the inbox
-              '';
+              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
-              '';
+            options.watch = mkOption {
+              type = with types; listOf str;
+              default = [ ];
+              description = "Paths for {manpage}`public-inbox-watch(1)` to monitor for new mail.";
               example = [ "maildir:/path/to/test.example.com.git" ];
             };
-
-            watchHeader = mkOption {
-              type = nullOr str;
+            options.watchheader = mkOption {
+              type = with types; nullOr str;
               default = null;
               example = "List-Id:<test@example.com>";
               description = ''
-                If specified, public-inbox-watch(1) will only process
+                If specified, {manpage}`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.
+        '';
       };
-
-      mda = {
-        args = mkOption {
-          type = with types; listOf str;
-          default = [];
-          description = ''
-            Command-line arguments to pass to public-inbox-mda(1).
-          '';
+      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 {manpage}`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](https://public-inbox.org/public-inbox-config.html).
+      '';
+      default = { };
+      type = types.submodule {
+        freeformType = gitIni.type;
+        options.publicinbox = mkOption {
+          default = { };
+          description = "public inboxes";
+          type = types.submodule {
+            # Support both global options like `services.public-inbox.settings.publicinbox.imapserver`
+            # and inbox specific options like `services.public-inbox.settings.publicinbox.foo.address`.
+            freeformType =
+              with types;
+              attrsOf (oneOf [
+                iniAtom
+                (attrsOf 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.imapserver = mkOption {
+              type = with types; listOf str;
+              default = [ ];
+              example = [ "imap.public-inbox.org" ];
+              description = "IMAP URLs to this public-inbox instance";
+            };
+            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.pop3server = mkOption {
+              type = with types; listOf str;
+              default = [ ];
+              example = [ "pop.public-inbox.org" ];
+              description = "POP3 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.
+              '';
+            };
+          };
         };
-
-        spamCheck = mkOption {
-          type = with types; nullOr (enum [ "spamc" ]);
-          default = "spamc";
+        options.publicinboxmda.spamcheck = mkOption {
+          type =
+            with types;
+            enum [
+              "spamc"
+              "none"
+            ];
+          default = "none";
           description = ''
-            If set to spamc, public-inbox-mda(1) will filter spam
-            using SpamAssassin
+            If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam
+            using SpamAssassin.
           '';
         };
-      };
-
-      watch = {
-        spamCheck = mkOption {
-          type = with types; nullOr (enum [ "spamc" ]);
-          default = "spamc";
+        options.publicinboxwatch.spamcheck = mkOption {
+          type =
+            with types;
+            enum [
+              "spamc"
+              "none"
+            ];
+          default = "none";
           description = ''
-            If set to spamc, public-inbox-watch(1) will filter spam
-            using SpamAssassin
+            If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam
+            using SpamAssassin.
           '';
         };
-
-        watchSpam = mkOption {
+        options.publicinboxwatch.watchspam = mkOption {
           type = with types; nullOr str;
           default = null;
           example = "maildir:/path/to/spam";
@@ -227,231 +357,327 @@ in
             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.
-          '';
+        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";
+              };
+            }
+          );
         };
       };
-
-      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
-        '';
-      };
     };
+    openFirewall = mkEnableOption "opening the firewall when using a port option";
   };
-
   config = mkIf cfg.enable {
-
     assertions = [
-      { assertion = config.services.spamassassin.enable || !useSpamAssassin;
+      {
+        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.
+          spam checking, set `services.public-inbox.settings.publicinboxmda.spamcheck' and
+          `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
         '';
       }
-      { assertion = cfg.path != [] || !useSpamAssassin;
+      {
+        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.
+          `services.public-inbox.settings.publicinboxmda.spamcheck' and
+          `services.public-inbox.settings.publicinboxwatch.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" ];
+    services.public-inbox.settings = filterAttrsRecursive (n: v: v != null) {
+      publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes;
     };
-
-    systemd.sockets.public-inbox-nntpd = {
-      inherit (cfg.nntp) listenStreams;
-      wantedBy = [ "sockets.target" ];
+    users = {
+      users.public-inbox = {
+        home = stateDir;
+        group = "public-inbox";
+        isSystemUser = true;
+      };
+      groups.public-inbox = { };
     };
-
-    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" ];
+    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
+      );
 
-    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 ])
+      # Deliver the addresses with the public-inbox transport
+      transport = concatStringsSep "\n" (
+        mapAttrsToList (
+          _: inbox: concatMapStringsSep "\n" (address: "${address} public-inbox:${address}") inbox.address
+        ) cfg.inboxes
       );
-      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";
+      # 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}"
+        ];
+      };
     };
-
-    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
-
-    '';
-
+    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 = mkMerge [
+          (serviceConfig "imapd")
+          {
+            after = [
+              "public-inbox-init.service"
+              "public-inbox-watch.service"
+            ];
+            requires = [ "public-inbox-init.service" ];
+            serviceConfig = {
+              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 = mkMerge [
+          (serviceConfig "httpd")
+          {
+            after = [
+              "public-inbox-init.service"
+              "public-inbox-watch.service"
+            ];
+            requires = [ "public-inbox-init.service" ];
+            serviceConfig = {
+              BindReadOnlyPaths = map (c: c.dir) (lib.attrValues cfg.settings.coderepo);
+              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 = mkMerge [
+          (serviceConfig "nntpd")
+          {
+            after = [
+              "public-inbox-init.service"
+              "public-inbox-watch.service"
+            ];
+            requires = [ "public-inbox-init.service" ];
+            serviceConfig = {
+              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 = mkMerge [
+            (serviceConfig "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 = {
+                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
+          mkMerge [
+            (serviceConfig "init")
+            {
+              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 ${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
+                );
+              serviceConfig = {
+                Type = "oneshot";
+                RemainAfterExit = true;
+                StateDirectory = [
+                  "public-inbox/.public-inbox"
+                  "public-inbox/.public-inbox/emergency"
+                  "public-inbox/inboxes"
+                ];
+              };
+            }
+          ];
+      })
+    ];
     environment.systemPackages = with pkgs; [ cfg.package ];
-
   };
+  meta.maintainers = with lib.maintainers; [
+    julm
+    qyliss
+  ];
 }