{
  lib,
  pkgs,
  config,
  ...
}:

with lib;

let
  cfg = config.services.public-inbox;
  stateDir = "/var/lib/public-inbox";

  gitIni = pkgs.formats.gitIni { listsAsDuplicateKeys = true; };
  iniAtom = gitIni.lib.types.atom;

  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 {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)`.";
    };
  };

  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";
      };
      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

{
  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.";
            };
            options.url = mkOption {
              type = types.nonEmptyStr;
              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.";
              apply = pkgs.writeText "public-inbox-description-${name}";
            };
            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 {manpage}`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, {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.
        '';
      };
      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.
              '';
            };
          };
        };
        options.publicinboxmda.spamcheck = mkOption {
          type =
            with types;
            enum [
              "spamc"
              "none"
            ];
          default = "none";
          description = ''
            If set to spamc, {manpage}`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, {manpage}`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: v: removeAttrs v [ "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.
      settings.main.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
      settings.master.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 = 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 ${escapeShellArg inbox.inboxdir} ]; 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
                          inbox.inboxdir
                          inbox.url
                        ]
                        ++ inbox.address
                      )}

                    rm -rf $conf_dir
                  fi

                  ln -sf ${inbox.description} \
                    ${escapeShellArg inbox.inboxdir}/description

                  export GIT_DIR=${escapeShellArg inbox.inboxdir}/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 = [ cfg.package ];
  };
  meta.maintainers = with lib.maintainers; [
    julm
    qyliss
  ];
}
