11 cfg = config.services.public-inbox;
12 stateDir = "/var/lib/public-inbox";
14 gitIni = pkgs.formats.gitIni { listsAsDuplicateKeys = true; };
15 iniAtom = gitIni.lib.types.atom;
18 cfg.settings.publicinboxmda.spamcheck == "spamc"
19 || cfg.settings.publicinboxwatch.spamcheck == "spamc";
21 publicInboxDaemonOptions = proto: defaultPort: {
23 type = with types; listOf str;
25 description = "Command-line arguments to pass to {manpage}`public-inbox-${proto}d(1)`.";
28 type = with types; nullOr (either str port);
29 default = defaultPort;
32 Beware that public-inbox uses well-known ports number to decide whether to enable TLS or not.
33 Set to null and use `systemd.sockets.public-inbox-${proto}d.listenStreams`
34 if you need a more advanced listening.
38 type = with types; nullOr str;
40 example = "/path/to/fullchain.pem";
41 description = "Path to TLS certificate to use for connections to {manpage}`public-inbox-${proto}d(1)`.";
44 type = with types; nullOr str;
46 example = "/path/to/key.pem";
47 description = "Path to TLS key to use for connections to {manpage}`public-inbox-${proto}d(1)`.";
54 proto = removeSuffix "d" srv;
55 needNetwork = builtins.hasAttr proto cfg && cfg.${proto}.port == null;
59 # Enable JIT-compiled C (via Inline::C)
60 Environment = [ "PERL_INLINE_DIRECTORY=/run/public-inbox-${srv}/perl-inline" ];
61 # NonBlocking is REQUIRED to avoid a race condition
62 # if running simultaneous services.
65 User = config.users.users."public-inbox".name;
66 Group = config.users.groups."public-inbox".name;
68 "public-inbox-${srv}/perl-inline"
70 RuntimeDirectoryMode = "700";
71 # This is for BindPaths= and BindReadOnlyPaths=
72 # to allow traversal of directories they create inside RootDirectory=
74 StateDirectory = [ "public-inbox" ];
75 StateDirectoryMode = "0750";
76 WorkingDirectory = stateDir;
81 "${config.i18n.glibcLocales}"
83 ++ mapAttrsToList (name: inbox: inbox.description) cfg.inboxes
84 ++ filter (x: x != null) [
85 cfg.${proto}.cert or null
86 cfg.${proto}.key or null
88 # The following options are only for optimizing:
89 # systemd-analyze security public-inbox-'*'
90 AmbientCapabilities = "";
91 CapabilityBoundingSet = "";
92 # ProtectClock= adds DeviceAllow=char-rtc r
94 LockPersonality = true;
95 MemoryDenyWriteExecute = true;
96 NoNewPrivileges = true;
97 PrivateNetwork = mkDefault (!needNetwork);
100 ProtectHome = "tmpfs";
101 ProtectHostname = true;
102 ProtectKernelLogs = true;
103 ProtectProc = "invisible";
104 ProtectSystem = "strict";
106 RestrictAddressFamilies =
108 ++ optionals needNetwork [
112 RestrictNamespaces = true;
113 RestrictRealtime = true;
114 RestrictSUIDSGID = true;
122 # Not removing @setuid and @privileged because Inline::C needs them.
123 # Not removing @timer because git upload-pack needs it.
125 SystemCallArchitectures = "native";
129 mode = "full-apivfs";
130 # Inline::C needs a /bin/sh, and dash is enough
131 binSh = "${pkgs.dash}/bin/dash";
142 options.services.public-inbox = {
143 enable = mkEnableOption "the public-inbox mail archiver";
144 package = mkPackageOption pkgs "public-inbox" { };
146 type = with types; listOf package;
148 example = literalExpression "with pkgs; [ spamassassin ]";
150 Additional packages to place in the path of public-inbox-mda,
151 public-inbox-watch, etc.
156 Inboxes to configure, where attribute names are inbox names.
159 type = types.attrsOf (
163 freeformType = types.attrsOf iniAtom;
164 options.inboxdir = mkOption {
166 default = "${stateDir}/inboxes/${name}";
167 description = "The absolute path to the directory which hosts the public-inbox.";
169 options.address = mkOption {
170 type = with types; listOf str;
171 example = "example-discuss@example.org";
172 description = "The email addresses of the public-inbox.";
174 options.url = mkOption {
175 type = types.nonEmptyStr;
176 example = "https://example.org/lists/example-discuss";
177 description = "URL where this inbox can be accessed over HTTP.";
179 options.description = mkOption {
181 example = "user/dev discussion of public-inbox itself";
182 description = "User-visible description for the repository.";
183 apply = pkgs.writeText "public-inbox-description-${name}";
185 options.newsgroup = mkOption {
186 type = with types; nullOr str;
188 description = "NNTP group name for the inbox.";
190 options.watch = mkOption {
191 type = with types; listOf str;
193 description = "Paths for {manpage}`public-inbox-watch(1)` to monitor for new mail.";
194 example = [ "maildir:/path/to/test.example.com.git" ];
196 options.watchheader = mkOption {
197 type = with types; nullOr str;
199 example = "List-Id:<test@example.com>";
201 If specified, {manpage}`public-inbox-watch(1)` will only process
202 mail containing a matching header.
205 options.coderepo = mkOption {
206 type = (types.listOf (types.enum (attrNames cfg.settings.coderepo))) // {
207 description = "list of coderepo names";
210 description = "Nicknames of a 'coderepo' section associated with the inbox.";
217 enable = mkEnableOption "the public-inbox IMAP server";
218 } // publicInboxDaemonOptions "imap" 993;
220 enable = mkEnableOption "the public-inbox HTTP server";
222 type = with types; listOf str;
224 example = [ "/lists/archives" ];
226 Root paths or URLs that public-inbox will be served on.
227 If domain parts are present, only requests to those
228 domains will be accepted.
231 args = (publicInboxDaemonOptions "http" 80).args;
233 type = with types; nullOr (either str port);
235 example = "/run/public-inbox-httpd.sock";
237 Listening port or systemd's ListenStream= entry
238 to be used as a reverse proxy, eg. in nginx:
239 `locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox";`
240 Set to null and use `systemd.sockets.public-inbox-httpd.listenStreams`
241 if you need a more advanced listening.
246 enable = mkEnableOption "the public-inbox Mail Delivery Agent";
248 type = with types; listOf str;
250 description = "Command-line arguments to pass to {manpage}`public-inbox-mda(1)`.";
253 postfix.enable = mkEnableOption "the integration into Postfix";
255 enable = mkEnableOption "the public-inbox NNTP server";
256 } // publicInboxDaemonOptions "nntp" 563;
257 spamAssassinRules = mkOption {
258 type = with types; nullOr path;
259 default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs";
260 defaultText = literalExpression "\${cfg.package.sa_config}/user/.spamassassin/user_prefs";
261 description = "SpamAssassin configuration specific to public-inbox.";
263 settings = mkOption {
265 Settings for the [public-inbox config file](https://public-inbox.org/public-inbox-config.html).
268 type = types.submodule {
269 freeformType = gitIni.type;
270 options.publicinbox = mkOption {
272 description = "public inboxes";
273 type = types.submodule {
274 # Support both global options like `services.public-inbox.settings.publicinbox.imapserver`
275 # and inbox specific options like `services.public-inbox.settings.publicinbox.foo.address`.
283 options.css = mkOption {
284 type = with types; listOf str;
286 description = "The local path name of a CSS file for the PSGI web interface.";
288 options.imapserver = mkOption {
289 type = with types; listOf str;
291 example = [ "imap.public-inbox.org" ];
292 description = "IMAP URLs to this public-inbox instance";
294 options.nntpserver = mkOption {
295 type = with types; listOf str;
298 "nntp://news.public-inbox.org"
299 "nntps://news.public-inbox.org"
301 description = "NNTP URLs to this public-inbox instance";
303 options.pop3server = mkOption {
304 type = with types; listOf str;
306 example = [ "pop.public-inbox.org" ];
307 description = "POP3 URLs to this public-inbox instance";
309 options.wwwlisting = mkOption {
319 Controls which lists (if any) are listed for when the root
320 public-inbox URL is accessed over HTTP.
325 options.publicinboxmda.spamcheck = mkOption {
334 If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam
338 options.publicinboxwatch.spamcheck = mkOption {
347 If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam
351 options.publicinboxwatch.watchspam = mkOption {
352 type = with types; nullOr str;
354 example = "maildir:/path/to/spam";
356 If set, mail in this maildir will be trained as spam and
357 deleted from all watched inboxes
360 options.coderepo = mkOption {
362 description = "code repositories";
363 type = types.attrsOf (
365 freeformType = types.attrsOf iniAtom;
366 options.cgitUrl = mkOption {
368 description = "URL of a cgit instance";
370 options.dir = mkOption {
372 description = "Path to a git repository";
379 openFirewall = mkEnableOption "opening the firewall when using a port option";
381 config = mkIf cfg.enable {
384 assertion = config.services.spamassassin.enable || !useSpamAssassin;
386 public-inbox is configured to use SpamAssassin, but
387 services.spamassassin.enable is false. If you don't need
388 spam checking, set `services.public-inbox.settings.publicinboxmda.spamcheck' and
389 `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
393 assertion = cfg.path != [ ] || !useSpamAssassin;
395 public-inbox is configured to use SpamAssassin, but there is
396 no spamc executable in services.public-inbox.path. If you
397 don't need spam checking, set
398 `services.public-inbox.settings.publicinboxmda.spamcheck' and
399 `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
403 services.public-inbox.settings = filterAttrsRecursive (n: v: v != null) {
404 publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes;
407 users.public-inbox = {
409 group = "public-inbox";
412 groups.public-inbox = { };
414 networking.firewall = mkIf cfg.openFirewall {
415 allowedTCPPorts = mkMerge (
417 (proto: (mkIf (cfg.${proto}.enable && types.port.check cfg.${proto}.port) [ cfg.${proto}.port ]))
425 services.postfix = mkIf (cfg.postfix.enable && cfg.mda.enable) {
426 # Not sure limiting to 1 is necessary, but better safe than sorry.
427 config.public-inbox_destination_recipient_limit = "1";
429 # Register the addresses as existing
430 virtual = concatStringsSep "\n" (
432 _: inbox: concatMapStringsSep "\n" (address: "${address} ${address}") inbox.address
436 # Deliver the addresses with the public-inbox transport
437 transport = concatStringsSep "\n" (
439 _: inbox: concatMapStringsSep "\n" (address: "${address} public-inbox:${address}") inbox.address
443 # The public-inbox transport
444 masterConfig.public-inbox = {
446 privileged = true; # Required for user=
449 "flags=X" # Report as a final delivery
450 "user=${with config.users; users."public-inbox".name + ":" + groups."public-inbox".name}"
451 # Specifying a nexthop when using the transport
452 # (eg. test public-inbox:test) allows to
453 # receive mails with an extension (eg. test+foo).
454 "argv=${pkgs.writeShellScript "public-inbox-transport" ''
455 export HOME="${stateDir}"
456 export ORIGINAL_RECIPIENT="''${2:-1}"
457 export PATH="${makeBinPath cfg.path}:$PATH"
458 exec ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
459 ''} \${original_recipient} \${nexthop}"
463 systemd.sockets = mkMerge (
467 mkIf (cfg.${proto}.enable && cfg.${proto}.port != null) {
468 "public-inbox-${proto}d" = {
469 listenStreams = [ (toString cfg.${proto}.port) ];
470 wantedBy = [ "sockets.target" ];
480 systemd.services = mkMerge [
481 (mkIf cfg.imap.enable {
482 public-inbox-imapd = mkMerge [
483 (serviceConfig "imapd")
486 "public-inbox-init.service"
487 "public-inbox-watch.service"
489 requires = [ "public-inbox-init.service" ];
491 ExecStart = escapeShellArgs (
492 [ "${cfg.package}/bin/public-inbox-imapd" ]
494 ++ optionals (cfg.imap.cert != null) [
498 ++ optionals (cfg.imap.key != null) [
507 (mkIf cfg.http.enable {
508 public-inbox-httpd = mkMerge [
509 (serviceConfig "httpd")
512 "public-inbox-init.service"
513 "public-inbox-watch.service"
515 requires = [ "public-inbox-init.service" ];
517 BindReadOnlyPaths = map (c: c.dir) (lib.attrValues cfg.settings.coderepo);
518 ExecStart = escapeShellArgs (
519 [ "${cfg.package}/bin/public-inbox-httpd" ]
522 # See https://public-inbox.org/public-inbox.git/tree/examples/public-inbox.psgi
523 # for upstream's example.
525 (pkgs.writeText "public-inbox.psgi" ''
526 #!${cfg.package.fullperl} -w
530 use PublicInbox::WWW;
532 my $www = PublicInbox::WWW->new;
536 # If reached through a reverse proxy,
537 # make it transparent by resetting some HTTP headers
538 # used by public-inbox to generate URIs.
539 enable 'ReverseProxy';
541 # No need to send a response body if it's an HTTP HEAD requests.
544 # Route according to configured domains and root paths.
545 ${concatMapStrings (path: ''
546 mount q(${path}) => sub { $www->call(@_); };
556 (mkIf cfg.nntp.enable {
557 public-inbox-nntpd = mkMerge [
558 (serviceConfig "nntpd")
561 "public-inbox-init.service"
562 "public-inbox-watch.service"
564 requires = [ "public-inbox-init.service" ];
566 ExecStart = escapeShellArgs (
567 [ "${cfg.package}/bin/public-inbox-nntpd" ]
569 ++ optionals (cfg.nntp.cert != null) [
573 ++ optionals (cfg.nntp.key != null) [
584 any (inbox: inbox.watch != [ ]) (attrValues cfg.inboxes)
585 || cfg.settings.publicinboxwatch.watchspam != null
588 public-inbox-watch = mkMerge [
589 (serviceConfig "watch")
592 wants = [ "public-inbox-init.service" ];
594 "public-inbox-init.service"
595 ] ++ optional (cfg.settings.publicinboxwatch.spamcheck == "spamc") "spamassassin.service";
596 wantedBy = [ "multi-user.target" ];
598 ExecStart = "${cfg.package}/bin/public-inbox-watch";
599 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
608 PI_CONFIG = gitIni.generate "public-inbox.ini" (
609 filterAttrsRecursive (n: v: v != null) cfg.settings
613 (serviceConfig "init")
615 wantedBy = [ "multi-user.target" ];
616 restartIfChanged = true;
617 restartTriggers = [ PI_CONFIG ];
621 install -D -p ${PI_CONFIG} ${stateDir}/.public-inbox/config
623 + optionalString useSpamAssassin ''
624 install -m 0700 -o spamd -d ${stateDir}/.spamassassin
625 ${optionalString (cfg.spamAssassinRules != null) ''
626 ln -sf ${cfg.spamAssassinRules} ${stateDir}/.spamassassin/user_prefs
630 mapAttrsToList (name: inbox: ''
631 if [ ! -e ${stateDir}/inboxes/${escapeShellArg name} ]; then
632 # public-inbox-init creates an inbox and adds it to a config file.
633 # It tries to atomically write the config file by creating
634 # another file in the same directory, and renaming it.
635 # This has the sad consequence that we can't use
636 # /dev/null, or it would try to create a file in /dev.
637 conf_dir="$(mktemp -d)"
639 PI_CONFIG=$conf_dir/conf \
640 ${cfg.package}/bin/public-inbox-init -V2 \
644 "${stateDir}/inboxes/${name}"
653 ln -sf ${inbox.description} \
654 ${stateDir}/inboxes/${escapeShellArg name}/description
656 export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git
657 if test -d "$GIT_DIR"; then
658 # Config is inherited by each epoch repository,
659 # so just needs to be set for all.git.
660 ${pkgs.git}/bin/git config core.sharedRepository 0640
666 RemainAfterExit = true;
668 "public-inbox/.public-inbox"
669 "public-inbox/.public-inbox/emergency"
670 "public-inbox/inboxes"
677 environment.systemPackages = with pkgs; [ cfg.package ];
679 meta.maintainers = with lib.maintainers; [