# SPDX-FileCopyrightText: V # 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: " "List-Help: " "List-Subscribe: " "List-Unsubscribe: " ]; # 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 ]; }