nix: update to latest nixpkgs
[sourcephile-nix.git] / nixos / modules / services / mail / public-inbox.nix
index 25e83921fa3b7ac85d597b375e643ed21377930b..40c2aab5e69e576678d7982b05d3e52e34bd476c 100644 (file)
@@ -4,341 +4,376 @@ 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";
+  manref = name: vol: "<citerefentry><refentrytitle>${name}</refentrytitle><manvolnum>${toString vol}</manvolnum></citerefentry>";
 
-  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));
+  singleIniAtom = with types; nullOr (oneOf [ bool int float str ]) // {
+    description = "INI atom (null, bool, int, float or string)";
+  };
+  iniAtom = with types; coercedTo singleIniAtom singleton (listOf singleIniAtom) // {
+    description = singleIniAtom.description + " or a list of them for duplicate keys";
+  };
+  iniAttrs = with types; attrsOf (either (attrsOf iniAtom) iniAtom);
+  gitIni = {
+    type = with types; attrsOf iniAttrs;
+    generate = name: value: pkgs.writeText name (generators.toGitINI value);
+  };
 
   environment = {
-    PI_EMERGENCY = "/var/lib/public-inbox/emergency";
-    PI_CONFIG = configFile;
+    PI_EMERGENCY = "${stateDir}/emergency";
+    PI_CONFIG = gitIni.generate "public-inbox.ini"
+      (filterAttrsRecursive (n: v: v != null) cfg.settings);
   };
 
-  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 <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";
-
+  useSpamAssassin = cfg.settings.publicinboxmda.spamcheck == "spamc" ||
+                    cfg.settings.publicinboxwatch.spamcheck == "spamc";
+
+  serviceConfig = srv: {
+    # 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"
+    ];
+    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 false;
+    PrivateTmp = true;
+    PrivateUsers = true;
+    ProtectClock = true;
+    ProtectControlGroups = true;
+    ProtectHome = true;
+    ProtectHostname = true;
+    ProtectKernelLogs = true;
+    ProtectKernelModules = true;
+    ProtectKernelTunables = true;
+    ProtectSystem = "strict";
+    RemoveIPC = true;
+    RestrictAddressFamilies = [ "AF_UNIX" ];
+    RestrictNamespaces = true;
+    RestrictRealtime = true;
+    RestrictSUIDSGID = true;
+    SystemCallFilter = optionals (srv != "init") [
+      "@system-service"
+      "~@aio" "~@chown" "~@keyring" "~@memlock"
+      "~@resources" "~@setuid" "~@timer" "~@privileged"
+    ];
+    SystemCallArchitectures = "native";
+    SystemCallErrorNumber = "EPERM";
+  };
 in
 
 {
-  options = {
-    services.public-inbox = {
-      enable = mkEnableOption "the public-inbox mail archiver";
-
-      package = mkOption {
-        type = types.package;
-        default = pkgs.public-inbox;
+  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.";
+    };
+    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.
+      '';
+      default = {};
+      type = types.submodule {
+        freeformType = 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:<test@example.com>";
+            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.";
+          };
+        }));
+      };
+    };
+    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}.";
+      };
+    };
+    http = {
+      enable = mkEnableOption "the public-inbox HTTP server";
+      mounts = mkOption {
+        type = with types; listOf str;
+        default = [ "/" ];
+        example = [ "/lists/archives" ];
         description = ''
-          public-inbox package to use with the public-inbox module
+          Root paths or URLs that public-inbox will be served on.
+          If domain parts are present, only requests to those
+          domains will be accepted.
         '';
       };
-
-      path = mkOption {
-        type = with types; listOf package;
-        default = [];
-        example = literalExample "with pkgs; [ spamassassin ]";
+      args = mkOption {
+        type = with types; listOf str;
+        default = ["-W0"];
+        description = "Command-line arguments to pass to ${manref "public-inbox-httpd" 1}.";
+      };
+      port = mkOption {
+        type = with types; nullOr (either str port);
+        default = 80;
+        example = "/run/public-inbox-httpd.sock";
         description = ''
-          Additional packages to place in the path of public-inbox-mda,
-          public-inbox-watch, etc.
+          Listening port or systemd's ListenStream= entry
+          to be used as a reverse proxy, eg. in nginx:
+          <code>locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox";</code>
+          Set to null and use <code>systemd.sockets.public-inbox-httpd.listenStreams</code>
+          if you need a more advanced listening.
         '';
       };
-
-      inboxes = mkOption {
+    };
+    imap = {
+      enable = mkEnableOption "the public-inbox IMAP server";
+      args = mkOption {
+        type = with types; listOf str;
+        default = ["-W0"];
+        description = "Command-line arguments to pass to ${manref "public-inbox-imapd" 1}.";
+      };
+      port = mkOption {
+        type = with types; nullOr port;
+        default = 993;
         description = ''
-          Inboxes to configure, where attribute names are inbox names
+          Listening port.
+          Set to null and use <code>systemd.sockets.public-inbox-imapd.listenStreams</code>
+          if you need a more advanced listening.
         '';
-        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
-              '';
+      };
+      cert = mkOption {
+        type = with types; nullOr str;
+        default = null;
+        example = "/path/to/fullchain.pem";
+        description = "Path to TLS certificate to use for public-inbox IMAP 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 IMAP connections.";
+      };
+    };
+    nntp = {
+      enable = mkEnableOption "the public-inbox NNTP server";
+      port = mkOption {
+        type = with types; nullOr port;
+        default = 563;
+        description = ''
+          Listening port.
+          Set to null and use <code>systemd.sockets.public-inbox-nntpd.listenStreams</code>
+          if you need a more advanced listening.
+        '';
+      };
+      args = mkOption {
+        type = with types; listOf str;
+        default = ["-W0"];
+        description = "Command-line arguments to pass to ${manref "public-inbox-nntpd" 1}.";
+      };
+      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.";
+      };
+    };
+    spamAssassinRules = mkOption {
+      type = with types; nullOr path;
+      default = "${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-inbox configuration.";
+          type = types.submodule {
+            freeformType = iniAttrs;
+            options.css = mkOption {
+              type = with types; listOf str;
+              default = [];
+              description = "The local path name of a CSS file for the PSGI web interface.";
             };
-
-            description = mkOption {
-              type = str;
-              example = "user/dev discussion of public-inbox itself";
-              description = ''
-                User-visible description for the repository
-              '';
+            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";
             };
-
-            config = mkOption {
-              type = attrs;
-              default = {};
+            options.wwwlisting = mkOption {
+              type = with types; enum [ "all" "404" "match=domain" ];
+              default = "404";
               description = ''
-                Additional structured config for the inbox
+                Controls which lists (if any) are listed for when the root
+                public-inbox URL is accessed over HTTP.
               '';
             };
-
-            newsgroup = mkOption {
-              type = nullOr str;
-              default = null;
+          };
+        };
+        options.publicinboxmda = mkOption {
+          default = {};
+          description = "mailbox delivery agent";
+          type = types.submodule {
+            freeformType = iniAttrs;
+            options.spamcheck = mkOption {
+              type = with types; enum [ "spamc" "none" ];
+              default = "none";
               description = ''
-                NNTP group name for the inbox
+                If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
+                using SpamAssassin.
               '';
             };
-
-            watch = mkOption {
-              type = listOf str;
-              default = [];
+          };
+        };
+        options.publicinboxwatch = mkOption {
+          default = {};
+          description = "mailbox watcher";
+          type = types.submodule {
+            freeformType = iniAttrs;
+            options.spamcheck = mkOption {
+              type = with types; enum [ "spamc" "none" ];
+              default = "none";
               description = ''
-                Paths for public-inbox-watch(1) to monitor for new mail
+                If set to spamc, ${manref "public-inbox-watch" 1} will filter spam
+                using SpamAssassin.
               '';
-              example = [ "maildir:/path/to/test.example.com.git" ];
             };
-
-            watchHeader = mkOption {
-              type = nullOr str;
+            options.watchspam = mkOption {
+              type = with types; nullOr str;
               default = null;
-              example = "List-Id:<test@example.com>";
+              example = "maildir:/path/to/spam";
               description = ''
-                If specified, public-inbox-watch(1) will only process
-                mail containing a matching header.
+                If set, mail in this maildir will be trained as spam and
+                deleted from all watched inboxes
               '';
             };
           };
-        });
-      };
-
-      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.
-          '';
+        options.coderepo = mkOption {
+          default = {};
+          description = "code repositories";
+          type = types.submodule {
+            freeformType = types.attrsOf (types.submodule {
+              freeformType = types.either (types.attrsOf iniAtom) 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;
         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;
@@ -346,113 +381,190 @@ in
           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;
+    services.public-inbox.settings =
+      filterAttrsRecursive (n: v: v != null) {
+      publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes;
     };
-
-    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";
+    users = {
+      users.public-inbox = {
+        # Use runCommand instead of linkFarm,
+        # because Postfix rejects .forward if it's a symlink.
+        home = pkgs.runCommand "public-inbox-home" {} (''
+          install -D -p ${environment.PI_CONFIG} $out/.public-inbox/config
+          ln -s ${stateDir}/emergency $out/.public-inbox/emergency
+          ln -s ${stateDir}/spamassassin $out/.spamassassin
+        '' + optionalString cfg.mda.enable ''
+          cp ${let env = concatStringsSep " " (mapAttrsToList (n: v: "${n}=${escapeShellArg v}") environment); in
+            pkgs.writeText "forward" ''
+              |"env ${env} PATH=\"${makeBinPath cfg.path}:$PATH\" ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
+          ''} $out/.forward
+        '');
+        group = "public-inbox";
+        isSystemUser = true;
+      };
+      groups.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 \
+    networking.firewall = mkIf cfg.openFirewall
+      { allowedTCPPorts = mkMerge [
+          (mkIf (cfg.http.enable && types.port.check cfg.http.port) [ cfg.http.port ])
+          (mkIf (cfg.imap.enable && types.port.check cfg.imap.port) [ cfg.imap.port ])
+          (mkIf (cfg.nntp.enable && types.port.check cfg.nntp.port) [ cfg.nntp.port ])
+        ];
+      };
+    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" ];
+          };
+        }
+      ) [ "http" "imap" "nntp" ]);
+    systemd.services = mkMerge [
+      (mkIf cfg.http.enable
+        { public-inbox-httpd = {
+          inherit environment;
+          after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
+          requires = [ "public-inbox-init.service" ];
+          serviceConfig = serviceConfig "httpd" // {
+            ExecStart = escapeShellArgs (
+              [ "${cfg.package}/bin/public-inbox-httpd" ] ++
+              cfg.http.args ++
+              [ (pkgs.writeText "public-inbox.psgi" ''
+                #!${cfg.package.fullperl} -w
+                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}
+                }
+              '') ]
+            );
+          };
+        };
+      })
+      (mkIf cfg.imap.enable
+        { public-inbox-imapd = {
+          inherit environment;
+          after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
+          requires = [ "public-inbox-init.service" ];
+          serviceConfig = 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.nntp.enable
+        { public-inbox-nntpd = {
+          inherit environment;
+          after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
+          requires = [ "public-inbox-init.service" ];
+          serviceConfig = 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 environment;
+          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 = serviceConfig "watch" // {
+            ExecStart = "${cfg.package}/bin/public-inbox-watch";
+            ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
+          };
+        };
+      })
+      ({ public-inbox-init = {
+          inherit environment;
+          wantedBy = [ "multi-user.target" ];
+          restartIfChanged = true;
+          restartTriggers = [ environment.PI_CONFIG ];
+          script = ''
+            set -ux
+            ${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 (inboxPath name) url ] ++ address)}
+                  ${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)}
 
-            rm -rf $conf_dir
-        fi
+                rm -rf $conf_dir
+              fi
 
-        ln -sf ${descriptionFile inbox} ${inboxPath name}/description
+              ln -sf ${pkgs.writeText "description" inbox.description} \
+                ${stateDir}/inboxes/${escapeShellArg 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)}
+              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)}
 
-      for inbox in /var/lib/public-inbox/inboxes/*/; do
-          ls -1 "$inbox" | grep -q '^xap' && continue
+            shopt -s nullglob
+            for inbox in ${stateDir}/inboxes/*/; do
+              ls -1 "$inbox" | ${pkgs.gnugrep}/bin/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} \
+              # 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.
               ${cfg.package}/bin/public-inbox-index "$inbox"
-      done
-
-    '';
-
+            done
+          '';
+          serviceConfig = serviceConfig "init" // {
+            Type = "oneshot";
+            RemainAfterExit = true;
+            StateDirectory = [
+              "public-inbox"
+              "public-inbox/emergency"
+              "public-inbox/inboxes"
+            ];
+            StateDirectoryMode = "0750";
+          };
+        };
+      })
+    ];
     environment.systemPackages = with pkgs; [ cfg.package ];
-
   };
+  meta.maintainers = with lib.maintainers; [ julm ];
 }