Multiple domains for inbound mail on postfix

I have a NixOS server running postfix and I need this server to receive e-mail for multiple domains. After reading postfix documentation and various blog posts I’m under the impression I need a virtual_domains file, but the NixOS module doesn’t have any option to directly generate this.

Does anyone have a similar setup with some configuration they can share, or insight in how to best achieve this?

Thanks!

Mulitiple domains are supported by nixos-mailserver. If this project is more than what you need, you could still take a look at the Postfix implementation since it (partially) uses the Postfix NixOS module.

Thanks for the suggestion @lewo. Unfortunately for this project nixos-mailserver isn’t going to be possible. This is the relevant configuration I have added to make this work:

  services.postfix.config.virtual_alias_domains = [ "${config.services.postfix.virtualMapType}:/etc/postfix/virtual-domains" ];
  services.postfix.mapFiles.virtual-domains = pkgs.writeText "postfix-virtual-domains" ''
    test1.domain.com # domain
    test2.domain.com # domain
    test3.domain.com # domain
  '';

This is based on a few sources including upstream documentation. I guess I should open a PR against the module in nixpkgs to add this functionality, but I’m no mail expert so would benefit from some other opinions.

All feedback on how to integration this into the existing postfix module is welcome.

ping @Jerith @typetetris @asbachb @qyliss @peti @Mic92 - module authors who may have opinions.

I think I might be doing what you want, in an obscurely-documented way.

I have only defined the services.postfix.virtual config parameter, which creates the virtual_alias_maps entry in main.cf. I don’t have a separate virtual_alias_domains entry in main.cf.

However, the documentation for virtual_alias_domains says that its default value is virtual_alias_maps! So, as discussed in the virtual(5) manpage, you can have:

       /etc/postfix/virtual:
           virtual-alias.domain    anything (right-hand content does not matter)

… to “simulate” a separate virtual_alias_maps file. This works quite well on my mailserver.

I’ve written some custom nix helper functions to help manage all my virtual domains and addresses, which I should probably clean up, generalize, and share. :slight_smile:

However, all that being said, I don’t think it would be hard to extend the existing postfix.nix module to support an explicit list of virtual domains. I encourage you to give it a try – getting a working mailserver config was one of the first things I dove into with nix, and it proved to be a reasonably good training ground.

Thanks for that info @Jerith. That looks like it will make my configuration nice and clean.

This code can be included from configuration.nix on any number of Internet servers. Based on the server’s host name, the code will configure all known user addresses and hosts such that incoming messages …

  1. are delivered locally if the current host is listed in “imapServers” for the target address. A locally confgured IMAP server will serve the mailbox.

  2. If the current server is not in “imapServers” for the target address, then the message will be accepted locally. but forwarded to the host that’s listed first in the list of imapServers. In other words, the machine acts as a secondary MX.

There is another convenience layer for mailing lists. The define “lists” gives all known mailing lists, which are hosted on “myListServer”. All other hosts always forward to this one. myListServer itself is not configured by this module, because NixOS modules like mailman.nix do this automatically for us. Anyhow, in addition to the list itself, we also define all kinds of administrative aliases, which all just forward to the list server.

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

let

  inherit (lib) head tail elem filter concatLists zipAttrsWith mapAttrsToList
                unique sort lessThan concatMapStrings concatMap optional;

  myHostName = config.services.postfix.hostname;
  myListServer = "mx2.example.net";

  parseAddr = x: let r = builtins.match "([^@]+)@(.+)" x;
                 in { user = head r; host = head (tail r); };

  lists = [
    "orgateam@lists.example.net" "party@lists.example.net" "pia-politik@lists.example.net"
  ];

  listVariants = ["" "-bounces" "-confirm" "-join" "-leave" "-owner" "-request" "-subscribe" "-unsubscribe"];

  users = {

    "joe"    = { imapServers = ["mx1.example.net" "mx2.example.net"];
                 aliases     = ["postmaster@example.net" "joe@example.net" "joe.smith@example.net"];
               };

    "jane" = { imapServers = ["mx1.example.net"];
               aliases     = ["jane@example.net" "jane.smith@example.net" "jane.smith@nospf.example.net"];
             };
  };

  mkUserConfigMaster = user: { imapServers, aliases }: {
    localRecipients = [ (user + "@" + myHostName) ];
    destination = [ myHostName ] ++ map (x: (parseAddr x).host) aliases;
    transport = [];
    virtual = map (x: x + " " + user + "@" + myHostName) aliases;
  };

  mkUserConfigSlave = user: { imapServers, aliases }: {
    localRecipients = aliases;
    destination = map (x: (parseAddr x).host) aliases;
    transport = map (x: x + " smtp:[" + head imapServers + "]") aliases;
    virtual = [];
  };

  mkUserConfig = user: cfg: (if elem myHostName cfg.imapServers then mkUserConfigMaster else mkUserConfigSlave) user cfg;

  mailUserConfig = mapAttrsToList mkUserConfig users;

  mkListConfigSlave = { user, host }: {
    localRecipients = [ (user + "@" + host) ];
    destination = [host];
    transport = [ (user + "@" + host + " smtp:[" + myListServer + "]") ];
    virtual = [];
  };

  mkListConfigMaster = { user, host }: {
    localRecipients = optional (myListServer != host) (user + "@" + myListServer);
    destination = optional (myListServer != host) myListServer;
    transport = [];
    virtual = optional (myListServer != host) (user + "@" + myListServer + " " + user + "@" + host);
  };

  mkListConfig = mkListConfig' (if myHostName == myListServer then mkListConfigMaster else mkListConfigSlave);
  mkListConfig' = mkList: { user, host }: map (x: mkList { user = user+x; inherit host; }) listVariants;

  mailListConfig = concatMap (x: mkListConfig (parseAddr x)) lists;

  mailConfig = zipAttrsWith (k: v: sort lessThan (unique (concatLists v))) (mailUserConfig ++ mailListConfig);

in

{

  environment = {
    etc."procmailrc".text = ''
      COMSAT="off"
      SENDMAIL="/run/wrappers/bin/sendmail"
      DEFAULT="/var/spool/mail/$LOGNAME"
      DROPPRIVS="yes"
    '';
    systemPackages = [pkgs.procmail];
  };

  security.acme.certs.${myHostName}.postRun = ''
    systemctl reload postfix.service dovecot2.service
  '';

  services = {

    dovecot2 = {
      enable = true;
      mailLocation = "mbox:~/Mail:INBOX=/var/spool/mail/%u";
      sslServerCert = config.services.postfix.sslCert;
      sslServerKey = config.services.postfix.sslKey;
      extraConfig = ''
        service auth {
          unix_listener /run/postfix-sasl-auth {
            mode = 0660
            user = ${config.services.postfix.user}
            group = ${config.services.postfix.group}
          }
        }
      '';
    };

    postfix = {
      enable = true;
      enableSubmission = true;
      hostname = with config.networking; "${hostName}.${domain}";
      domain = config.networking.domain;
      tlsTrustedAuthorities = "/etc/pki/tls/certs/ca-bundle.crt";
      sslCert = "${config.security.acme.certs.${myHostName}.directory}/fullchain.pem";
      sslKey = "${config.security.acme.certs.${myHostName}.directory}/key.pem";
      postmasterAlias = "joe+postmaster";
      rootAlias = "";
      recipientDelimiter = "+";
      config = {
        mail_spool_directory = "/var/spool/mail";   # no trailing slash signifies for mbox format
        mailbox_command = ''${pkgs.procmail}/bin/procmail -a "$EXTENSION"'';
        mailbox_size_limit = "0";
        masquerade_classes = "";
        smtpd_recipient_restrictions = "reject_non_fqdn_recipient";
        smtpd_sasl_path = "/run/postfix-sasl-auth";
        smtpd_sasl_type = "dovecot";
        strict_rfc821_envelopes = "yes";
      };
      destination = mailConfig.destination;
      localRecipients = mailConfig.localRecipients
    };

    redis = {
      enable = true;
      bind = "127.0.0.1";
      databases = 1;
    };

    rspamd = {
      enable = true;
      postfix.enable = true;
      locals = {
        "logging.inc".text = ''
          type = "console";
          level = "warning";
          systemd = true;
        '';
        "redis.conf".text = ''servers = "127.0.0.1";'';
        "dkim_signing.conf".text = ''
          path = "/etc/pki/dkim/$domain-key.txt";
          allow_hdrfrom_mismatch = true;
          allow_hdrfrom_multiple = false;
          allow_username_mismatch = true;
          selector = "dkim";
          sign_local = true;
          symbol = "DKIM_SIGNED";
          try_fallback = true;
          use_domain = "envelope";
          use_domain_sign_networks = "envelope";
          use_domain_sign_local = "envelope";
          use_esld = true;
          use_redis = false;
          check_pubkey = true;
          allow_pubkey_mismatch = false;
        '';
        "multimap.conf".text = ''
          local_bl_domain {
            type = "from";
            filter = "email:domain:tld";
            prefilter = true;
            action = "reject";
            description = "Blacklisted domain";
            map = "${pkgs.writeText "rspamd-from-bl" ''
              hilton.com
              schwab.com
            ''}";
          }
          '';
        "groups.conf".text = ''
          group "multimap" {
            "LOCAL_BL_FROM" {
              weight = 3.0;
              description = "Sender FROM listed in local blacklist";
            }
          }
        '';
      };
    };

  };

}

Interesting read @peti, thanks for sharing!