# Declarative mail stack for mx1 (Simple NixOS Mailserver: Postfix + Dovecot + # Rspamd + OpenDKIM). Imported by machines/mx1 alongside the SNM flake module. # # Mailboxes are virtual (not system users): each address below is a login account # whose password is auto-generated by a clan vars generator as a four-word # passphrase with a trailing number (e.g. otter-lantern-cobalt-driftwood-42). The # generator stores both the passphrase and its sha-512 hash. To add a mailbox: # append the address to `accounts`, run `clan vars generate mx1`, redeploy mx1, # then hand the passphrase to the user: # clan vars get mx1 mail-passwd-/passphrase # (addr with @ and . replaced by -at- and -, e.g. mail-passwd-postmaster-at-cnx-email) { config, lib, pkgs, ... }: let hosts = import ./hosts.nix; fqdn = "mx1.cnx.email"; mtaStsHost = "mta-sts.cnx.email"; # Client-facing alias (CNAME -> mx1) so Thunderbird etc. can use mail.cnx.email # for submission/IMAP; added as a cert SAN so TLS validates against that name. clientHost = "mail.cnx.email"; # MTA-STS policy served at https://mta-sts.cnx.email/.well-known/mta-sts.txt. # enforce = a sending MTA that fetched this must use a valid, MX-matching TLS # cert or refuse to deliver. Bump the _mta-sts TXT id (in the zone) whenever # this changes. mtaStsPolicy = pkgs.writeText "mta-sts.txt" '' version: STSv1 mode: enforce mx: ${fqdn} max_age: 604800 ''; # The mailboxes mx1 serves. postmaster is required by RFC 5321. accounts = [ "postmaster@cnx.email" ]; genName = addr: "mail-passwd-" + lib.replaceStrings [ "@" "." ] [ "-at-" "-" ] addr; passwdGenerators = lib.listToAttrs ( map (addr: { name = genName addr; value = { files."passphrase".secret = true; # retrievable to hand to the user files."hash".secret = true; # consumed by SNM's hashedPasswordFile runtimeInputs = [ pkgs.xkcdpass pkgs.mkpasswd ]; script = '' pass="$(xkcdpass --numwords=4 --delimiter=- --case=lower)-$((RANDOM % 90 + 10))" printf '%s' "$pass" > "$out"/passphrase printf '%s' "$pass" | mkpasswd -s -m sha-512 > "$out"/hash ''; }; }) accounts ); loginAccounts = lib.listToAttrs ( map (addr: { name = addr; value.hashedPasswordFile = config.clan.core.vars.generators.${genName addr}.files."hash".path; }) accounts ) // { # DMARC report inbox (rua/ruf target in the cnx.email zone). Its password # comes from the *shared* mail-dmarc-cred generator instead of the per-machine # set above, so parsedmarc on control can read the same passphrase over the # mesh. Retrieve it with: clan vars get mx1 mail-dmarc-cred/passphrase "dmarc@cnx.email".hashedPasswordFile = config.clan.core.vars.generators.mail-dmarc-cred.files."hash".path; }; in { imports = [ ./dns/acme-mx1-secret.nix ./mail-dmarc-cred.nix ]; clan.core.vars.generators = passwdGenerators // { # Render the shared acme_mx1 TSIG secret into a lego rfc2136 env file. lego # (via security.acme below) uses it to write the _acme-challenge.mx1.cnx.email # TXT record to ns1, which authorizes the acme_mx1 key for exactly that owner. dns-acme-rfc2136 = { files."rfc2136.env".secret = true; # root-owned; systemd reads it as root dependencies = [ "dns-acme-mx1-secret" ]; script = '' printf 'RFC2136_NAMESERVER=${hosts.ns1.ipv4}:53\nRFC2136_TSIG_ALGORITHM=hmac-sha256.\nRFC2136_TSIG_KEY=acme_mx1\nRFC2136_TSIG_SECRET=%s\n' \ "$(cat "$in"/dns-acme-mx1-secret/secret)" > "$out"/rfc2136.env ''; }; }; mailserver = { enable = true; # Fresh install: declare the latest layout the nixos-25.11 branch ships (3), # so SNM uses the current dovecot mail directory layout with nothing to migrate. stateVersion = 3; inherit fqdn; domains = [ "cnx.email" ]; inherit loginAccounts; # Consume a security.acme cert we obtain ourselves via DNS-01 (below); no # web server and no inbound HTTP needed, so port 80 stays closed. Add the # MTA-STS host as a SAN so the one cert also covers the policy endpoint. certificateScheme = "acme"; certificateDomains = [ mtaStsHost clientHost ]; dkimSelector = "mail"; }; security.acme = { acceptTerms = true; defaults.email = "postmaster@cnx.email"; certs.${fqdn} = { dnsProvider = "rfc2136"; environmentFile = config.clan.core.vars.generators.dns-acme-rfc2136.files."rfc2136.env".path; # ns1 is the only nameserver that accepts the acme_mx1 UPDATE; check # propagation against it directly rather than a public resolver. dnsResolver = "${hosts.ns1.ipv4}:53"; # Keep the private key fixed across renewals so the DANE TLSA "3 1 1" # record (public-key digest, published in the zone) stays valid. extraLegoRenewFlags = [ "--reuse-key" ]; # Caddy serves the MTA-STS endpoint from explicit cert file paths, so it # won't notice a renewal on its own — reload it whenever the cert changes. # (Merges with the postfix/dovecot reloads SNM wires up for this cert.) reloadServices = [ "caddy.service" ]; }; }; # The mail cert is owned group=acme (SNM adds postfix/dovecot); Caddy serves the # MTA-STS endpoint from the same cert, so it needs to read the key too. users.users.caddy.extraGroups = [ "acme" ]; # MTA-STS policy endpoint, served by Caddy (same web server as control's docs). # The explicit `tls cert key` points at the lego-issued mail cert (which carries # mta-sts.cnx.email as a SAN) and disables Caddy's automatic ACME, so no extra # issuance happens and the DANE TLSA key stays stable. Only :443 is opened. services.caddy = { enable = true; virtualHosts.${mtaStsHost}.extraConfig = '' tls /var/lib/acme/${fqdn}/cert.pem /var/lib/acme/${fqdn}/key.pem root * ${pkgs.writeTextDir ".well-known/mta-sts.txt" (builtins.readFile mtaStsPolicy)} file_server ''; }; # DKIM private keys are generated on first start under this dir. They're # regenerable (rotate + republish the TXT), but declaring the path as clan # state lets a borg client back it up to avoid a needless DNS round-trip on # restore. Wiring mx1 into the borgbackup instance is a separate step. clan.core.state.mail-dkim.folders = [ config.mailserver.dkimKeyDirectory ]; }