summary refs log tree commit diff
path: root/fleet/modules/mlmmj.nix
diff options
context:
space:
mode:
Diffstat (limited to 'fleet/modules/mlmmj.nix')
-rw-r--r--fleet/modules/mlmmj.nix271
1 files changed, 271 insertions, 0 deletions
diff --git a/fleet/modules/mlmmj.nix b/fleet/modules/mlmmj.nix
new file mode 100644
index 0000000..070e5ff
--- /dev/null
+++ b/fleet/modules/mlmmj.nix
@@ -0,0 +1,271 @@
+# SPDX-FileCopyrightText: V <v@unfathomable.blue>
+# SPDX-License-Identifier: OSL-3.0
+
+# TODO(V): Get mlmmj to log to the journal, somehow. Currently it only writes to a file in each mailing list's directory.
+
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.mlmmj;
+
+  # TODO(V): Clean this up when upstreaming.
+  encodeKnob = knob:
+         if knob == true then ""
+    else if isInt knob then toString knob
+    else if isList knob then concatStrings (map (line: "${line}\n") knob)
+    else if isString knob then knob
+    else throw "invalid value";
+
+  writeKnob = name: value:
+    pkgs.writeTextDir name
+      (encodeKnob value);
+
+  mergeKnobs = knobs:
+    if isList (head knobs)
+      then concatLists knobs
+      else last knobs;
+
+  lists = flatten
+    (mapAttrsToList (domain: lists:
+      mapAttrsToList (name: config: rec {
+        # The name and domain of this list.
+        inherit name domain;
+
+        # The title of this list.
+        inherit (config) description;
+
+        # Where this list's state is stored
+        path = "/var/spool/mlmmj/${domain}/${name}";
+
+        # The address of this list. Pretty self-explanatory!
+        address = "${name}@${domain}";
+
+        # A dummy address, used for internal routing
+        conduit = "${address}.mlmmj.alt";
+
+        # An identifier safe for inclusion in unit names
+        unitName = "${domain}-${name}";
+
+        # The list's control directory. See http://mlmmj.org/docs/tunables/
+        controlDir = pkgs.buildEnv {
+          name = "mlmmj-control";
+          paths = mapAttrsToList writeKnob (zipAttrsWith (_: mergeKnobs) [
+            {
+              listaddress = address;
+
+              # FIXME(V): This is broken! But it's what the default config that mlmmj-make-ml gives you has.
+              # > mlmmj-send.c:742: No @ in address, ignoring postmaster: Success
+              owner = "postmaster";
+
+              # This is honestly a bit of a mess! These really ought to
+              # be synthesized by the mailing-list software instead.
+              customheaders = [
+                "Reply-To: ${address}"
+                # TODO(V): Ensure the description is quoted/encoded properly
+                "List-ID: ${config.description} <${name}.${domain}>"
+                "List-Post: <mailto:${address}>"
+                "List-Help: <mailto:${name}+help@${domain}>"
+                "List-Subscribe: <mailto:${name}+subscribe@${domain}>"
+                "List-Unsubscribe: <mailto:${name}+unsubscribe@${domain}>"
+              ];
+              # TODO(V): Add some/all of the above headers to delheaders?
+            }
+
+            (optionalAttrs (config.moderators != null) {
+              moderated = true;
+              inherit (config) moderators;
+            })
+
+            cfg.control
+
+            config.control
+          ]);
+        };
+      }) lists) cfg.lists);
+
+  listOpts = {
+    options = {
+      description = mkOption {
+        description = "A description for this list. Used in the List-ID header, and for its public inbox if integration is enabled.";
+        type = types.str;
+        example = "Test mailing list";
+      };
+
+      moderators = mkOption {
+        description = "A list of moderators for this list. See http://mlmmj.org/docs/tunables/ for options that make use of them.";
+        type = with types; nullOr (listOf str);
+        default = null;
+        example = [
+          "jan@example.org"
+        ];
+      };
+
+      control = mkOption {
+        description = "A set of control knobs for this list. See http://mlmmj.org/docs/tunables/ for more information.";
+        # TODO(V): Restrict the ability to include newlines in these strings?
+        type = with types; attrsOf (oneOf [ str int bool (listOf str) ]);
+        default = {};
+        example = {
+          modonlypost = true;
+        };
+      };
+    };
+  };
+in {
+  disabledModules = [
+    "services/mail/mlmmj.nix"
+  ];
+
+  options.services.mlmmj = {
+    enablePostfix = mkEnableOption "Postfix integration";
+    enablePublicInbox = mkEnableOption "public-inbox integration";
+
+    # TODO(V): add a template language option, possibly allowing a custom package or path to be used
+
+    control = mkOption {
+      description = "A set of control knobs applied to all lists. See http://mlmmj.org/docs/tunables/ for more information.";
+      # TODO(V): Restrict the ability to include newlines in these strings?
+      type = with types; attrsOf (oneOf [ str int bool (listOf str) ]);
+      default = {};
+      example = {
+        modonlypost = true;
+      };
+    };
+
+    lists = mkOption {
+      description = "A set of lists to be managed, organised by domain.";
+      type = types.attrsOf (types.attrsOf (types.submodule listOpts));
+      default = {};
+      example = { "example.org".mlmmj-test = {}; };
+    };
+  };
+
+  config = mkMerge [
+    # TODO(V): replace this check with an mlmmj.enable option?
+    (mkIf (lists != []) {
+      users.users.mlmmj = {
+        isSystemUser = true;
+        uid = config.ids.uids.mlmmj;  # Only used for the siphon
+        group = "mlmmj";
+      };
+
+      users.groups.mlmmj = {
+        gid = config.ids.gids.mlmmj;  # Only used for the siphon
+      };
+
+      # This does the equivalent of running mlmmj-make-ml (http://mlmmj.org/docs/mlmmj-make-ml, http://mlmmj.org/docs/readme )
+      systemd.tmpfiles.packages = map ({ unitName, path, controlDir, ... }:
+        pkgs.writeTextDir "lib/tmpfiles.d/mlmmj-${unitName}.conf" (''
+          d ${path} - mlmmj mlmmj
+          f ${path}/index - mlmmj mlmmj
+          L+ ${path}/control - - - - ${controlDir}
+          L+ ${path}/text - - - - ${pkgs.mlmmj}/share/mlmmj/text.skel/en
+        '' + concatMapStrings (dir: ''
+          d ${path}/${dir} - mlmmj mlmmj
+        '') [
+          "incoming" "queue" "queue/discarded" "requeue" "archive"
+          "subconf" "unsubconf"
+          "bounce" "moderation"
+          "subscribers.d" "digesters.d" "nomailsubs.d"
+        ])) lists;
+
+      systemd.services = listToAttrs (map ({ unitName, address, path, ... }:
+        nameValuePair "mlmmj-maintd-${unitName}" {
+          description = "mlmmj list maintenance for ${address}";
+          serviceConfig = {
+            ExecStart = "${pkgs.mlmmj}/bin/mlmmj-maintd -F -L ${path}";
+            # TODO(V): Should this be Type=exec or Type=oneshot?
+            User = "mlmmj";
+            # TODO(V): Hardening
+          };
+        }) lists);
+
+      systemd.timers = listToAttrs (map ({ unitName, address, ... }:
+        nameValuePair "mlmmj-maintd-${unitName}" {
+          description = "mlmmj list maintenance timer for ${address}";
+          wantedBy = [ "timers.target" ];
+          timerConfig.OnCalendar = "00/2:00";  # Every two hours, as suggested by upstream.
+          # TODO(V): set OnBootSec to 0, and use OnUnit(In)ActiveSec, as with the rss2email module, etc?
+          # Is there a compelling reason to actually run this at calendar times?
+          # TODO(V): Increase/decrease the frequency? Every 2 hours is pretty arbitrary, so perhaps we should think a bit about this.
+        }) lists);
+    })
+
+    # TODO(V): make conditional on mlmmj in general, also?
+    # Adapted from http://mlmmj.org/docs/readme-postfix
+    (mkIf cfg.enablePostfix {
+      services.postfix = {
+        masterConfig.mlmmj = {
+          privileged = true;
+          command = "pipe";
+          args = [
+            "flags=ORhu"
+            "user=mlmmj"
+            "argv=${pkgs.mlmmj}/bin/mlmmj-receive -F -L $nexthop"
+          ];
+        };
+
+        # TODO(V): ensure mlmmj is in authorized_submit_users (but only if this is defined!)
+
+        config.mlmmj_destination_recipient_limit = "1";
+
+        # TODO(V): Make this configurable? Make control.delimiter respect this? Get the user to set it instead?
+        recipientDelimiter = "+";
+
+        # TODO(V): Should this module be automatically adding domains to virtual_alias_domains?
+        # Actually, the NixOS Postfix module should probably do this anyway.
+        config.virtual_alias_domains = mapAttrsToList (domain: _: domain) cfg.lists;
+
+        # TODO(V): Handle local domains too, via aliases(5) and a per-domain option.
+        # Actually, doesn't virtual(5) already handle rewrites for local addresses? It doesn't care how addresses are handled
+        virtual = concatStrings (map ({ address, conduit, ... }: "${address} ${address}.mlmmj-siphon.alt, ${conduit}\n") lists);
+
+        # Siphon incoming mail to a Maildir, so we can be sure we're not losing anything important.
+        # TODO(V): Remove this once we're more certain of the stability of our archival setup.
+        config.virtual_mailbox_domains = mapAttrsToList (domain: _: "${domain}.mlmmj-siphon.alt") cfg.lists;
+        config.virtual_mailbox_base = "/var/mail/mlmmj-siphon";
+        config.virtual_mailbox_maps = [ "hash:/etc/postfix/vmailbox" ];
+        config.virtual_uid_maps = "static:${toString config.ids.uids.mlmmj}";
+        config.virtual_gid_maps = "static:${toString config.ids.gids.mlmmj}";
+        mapFiles.vmailbox = pkgs.writeText "postfix-vmailbox"
+          (concatStrings (map ({ address, domain, name, ... }: ''
+            ${address}.mlmmj-siphon.alt ${domain}/${name}/
+          '') lists));
+
+        # TODO(V): Do we want to add owner-{list} -> {list}+owner aliases?
+        # From aliases(5):
+        # > In addition, when an alias exists for owner-name, this will override the enve‐
+        # > lope sender address, so that delivery diagnostics are directed to  owner-name,
+        # > instead  of the originator of the message (for details, see owner_request_spe‐
+        # > cial, expand_owner_alias and reset_owner_alias).  This is  typically  used  to
+        # > direct delivery errors to the maintainer of a mailing list, who is in a better
+        # > position to deal with mailing list delivery problems than  the  originator  of
+        # > the undelivered mail.
+
+        # TODO(V): Should we add {list}-request addresses per https://datatracker.ietf.org/doc/html/rfc2142 ? Seems kind of decrepit.
+
+        transport = concatStrings (map ({ conduit, path, ... }: "${conduit} mlmmj:${path}\n") lists);
+      };
+    })
+
+    (mkIf cfg.enablePublicInbox {
+      # TODO(V): public-inbox integration
+      services.public-inbox = {
+        enable = true;  # TODO: Remove, this should be up to the user to enable?
+
+        inboxes = listToAttrs (map ({ name, domain, description, path, ... }:
+          nameValuePair name {
+            inherit domain description;
+            watch = let maildir = path: pkgs.linkFarm "fake-maildir" [
+              { name = "new"; inherit path; }
+              { name = "cur"; path = /var/empty; }
+            ]; in "maildir:${maildir "${path}/archive"}";
+          }) lists);
+      };
+    })
+  ];
+
+  meta.maintainers = with maintainers; [ V ];
+}