No description
  • Nix 81.9%
  • Shell 12.6%
  • Sieve 5.5%
Find a file
2026-04-11 12:54:55 +02:00
modules feat(lldap): tweak auth_bind 2026-04-11 12:54:55 +02:00
pkgs/postfix-mta-sts-resolver refactor(all): much better structure, and getting closer 2026-04-05 13:35:56 +02:00
scripts refactor(all): much better structure, and getting closer 2026-04-05 13:35:56 +02:00
sieve refactor(all): much better structure, and getting closer 2026-04-05 13:35:56 +02:00
.gitignore refactor(all): much better structure, and getting closer 2026-04-05 13:35:56 +02:00
flake.lock chore(lock): flake lock 2026-04-05 11:57:59 +02:00
flake.nix refactor(all): much better structure, and getting closer 2026-04-05 13:35:56 +02:00
README.md feat(hardening): some minor hardening and updated readme 2026-04-10 18:40:34 +02:00

cnixpost

NixOS mail server module. Postfix · Dovecot · Rspamd · ClamAV · Redis · Pigeonhole Sieve · fail2ban

Design:

  • Postfix and Dovecot bind to 127.0.0.1 / ::1. Traefik owns external ports and forwards with PROXY protocol v2 so real client IPs reach postscreen, fail2ban, and Rspamd
  • DKIM signing, ARC signing, and SPF/DMARC verification handled by Rspamd
  • Messages scoring above the add_header threshold are auto-filed into Junk via a global sieve before-script (sieve/spam-to-junk.sieve)
  • Moving mail into/out of Junk triggers rspamc learn_spam/learn_ham via imapsieve
  • Passwords and secrets are agenix-encrypted, injected at runtime. Nothing sensitive in the nix store
  • Dovecot authenticates via LDAP auth_bind against lldap. No per-account Dovecot passwords needed. Per-account quotas still apply.
  • Outbound TLS: DANE (TLSA/DNSSEC) by default, optional MTA-STS resolver for domains that publish MTA-STS but not DANE (Gmail, Outlook, Yahoo, etc.)
  • Autoconfig/Autodiscover endpoints for automatic mail client setup (Thunderbird, Outlook, Apple Mail)

Prerequisites

  • NixOS 25.11
  • Public IPv4 with rDNS → your fqdn
  • Port 25 unblocked by your hosting provider
  • DNSSEC-signed zone (required for DANE)
  • Traefik running with a letsencrypt resolver and wildcard cert for *.domain
  • lldap running on 127.0.0.1:3890

Importing

In your host flake:

inputs = {
  cnixpost.url = "github:cnsta/cnixpost";
  cnixpost.inputs.nixpkgs.follows = "nixpkgs";
};

In your host module:

imports = [ inputs.cnixpost.nixosModules.default ];

If using the MTA-STS outbound resolver, also apply the overlay:

nixpkgs.overlays = [ inputs.cnixpost.overlays.default ];

Setup

Everything below is a one-time manual process done before or just after the first deploy.

1. Infrastructure secrets

# Redis password
openssl rand -hex 32 | tr -d '\n' > /tmp/mailRedisPw
agenix -e secrets/mailRedisPw.age        # paste, save

# Rspamd controller password (bcrypt hash, not plaintext)
rspamadm pw -p 'choose-a-password'       # prints $2$... hash
agenix -e secrets/mailRspamdCtrlPw.age   # paste hash, save
# Redis password
openssl rand -hex 32 | tr -d '\n' | save /tmp/mailRedisPw
agenix -e secrets/mailRedisPw.age        # paste, save

# Rspamd controller password (bcrypt hash, not plaintext)
rspamadm pw -p 'choose-a-password'       # prints $2$... hash
agenix -e secrets/mailRspamdCtrlPw.age   # paste hash, save

2. DKIM key

Run on the server (requires openssl and rspamd in the system):

sudo bash /path/to/scripts/generate-dkim.sh yourdomain.com
# Prints the DNS TXT record to publish
# Key is written to /var/lib/rspamd/dkim/yourdomain.com.mail.key

Or generate locally:

openssl genrsa -out mail.key 2048
openssl rsa -in mail.key -pubout -out mail.pub

PUB=$(grep -v "^--" mail.pub | tr -d '\n')
echo "v=DKIM1; k=rsa; p=${PUB}"

sudo install -m 640 -o rspamd -g rspamd mail.key \
  /var/lib/rspamd/dkim/yourdomain.com.mail.key
openssl genrsa -out mail.key 2048
openssl rsa -in mail.key -pubout -out mail.pub

let pub = (open mail.pub | lines | where {|l| $l !~ "^--"} | str join)
print $"v=DKIM1; k=rsa; p=($pub)"

sudo install -m 640 -o rspamd -g rspamd mail.key /var/lib/rspamd/dkim/yourdomain.com.mail.key

The generated key files can be deleted after the install step. The private key now lives at the target path and the public key only needs to exist as a DNS TXT record.

3. Deploy

nixos-rebuild switch --flake .#yourhost --target-host root@mail.example.com
nixos-rebuild switch --flake $".#yourhost" --target-host root@mail.example.com

4. DANE TLSA record (after first deploy)

Once Traefik has issued a cert and /run/mail-certs/fullchain.pem exists:

sudo bash /path/to/scripts/generate-tlsa.sh /run/mail-certs/fullchain.pem
# Prints the TLSA record to publish at _25._tcp.mail.yourdomain.com

Configure

cnixpost = {
  enable = true;
  fqdn = "mail.example.com";
  primaryDomain = "example.com";
  certificateFile = "/run/mail-certs/fullchain.pem";
  keyFile = "/run/mail-certs/privkey.pem";

  lldap = {
    base = "dc=example,dc=com";
    userDnTemplate = "uid=%u,ou=people,dc=example,dc=com";
  };

  # Accounts define Postfix routing, quotas, and sender permissions.
  # Auth goes through lldap. Each user must exist in lldap with a uid
  # matching the local-part of their address.
  accounts."alice@example.com" = {
    quota   = "10G";
    aliases = [ "postmaster@example.com" "abuse@example.com" ];
  };
};

Adding a custom domain (e.g. for a user who owns clientsown.dev):

cnixpost = {
  # ...existing config...
  extraDomains = [ "clientsown.dev" ];

  accounts."bob@clientsown.dev" = {
    quota = "5G";
    aliases = [ "postmaster@clientsown.dev" ];
  };
};

The client must configure their DNS to point to your mail server (see DNS Records below). Generate a DKIM key for the new domain, the signing config picks up all domains in allDomains automatically.


DNS Records

Type Name Value
A mail.example.com <IPv4>
AAAA mail.example.com <IPv6>
MX example.com 10 mail.example.com
TXT example.com v=spf1 mx ~all
TXT mail._domainkey.example.com (from step 2 above)
TXT _dmarc.example.com v=DMARC1; p=quarantine; rua=mailto:postmaster@example.com
PTR <IP> mail.example.com
TLSA _25._tcp.mail.example.com (from step 4 above)
CNAME autoconfig.example.com mail.example.com
CNAME autodiscover.example.com mail.example.com
SRV _imaps._tcp.example.com 0 1 993 mail.example.com
SRV _submission._tcp.example.com 0 1 465 mail.example.com

MTA-STS (when enabled):

Type Name Value
TXT _mta-sts.example.com v=STSv1; id=<policyId>
A mta-sts.example.com <server IP>
TXT _smtp._tls.example.com v=TLSRPTv1; rua=mailto:tls-reports@example.com

Configuration Reference

Accounts

Option Default Description
quota "2G" Mailbox size limit
aliases [] Additional addresses delivered to this mailbox
sendOnly false Reject inbound mail with 5.1.1

Spam thresholds

Option Default Effect
spamScoreAddHeader 4.0 Add X-Spam headers + file to Junk
spamScoreGreylist 6.0 Greylist (retry after 5 min)
spamScoreReject 15.0 Reject at SMTP level

lldap

lldap = {
  uri = "ldap://127.0.0.1:3890";       # default
  base = "dc=example,dc=com";
  userDnTemplate = "uid=%u,ou=people,dc=example,dc=com";
  passFilter = "(uid=%u)";              # default
};

Dovecot authenticates via LDAP auth_bind against lldap. Users authenticate with their lldap credentials. Accounts must still be declared to define routing, quotas, and sender permissions.

MTA-STS

mtaSts = {
  enable   = true;
  mode     = "testing";   # -> "enforce" after a week of clean TLSRPT reports
  policyId = "20240601120000";
  mxHosts  = [ "mail.example.com" ];

  # Outbound MTA-STS enforcement (complements DANE for non-DANE destinations)
  enableOutboundCheck = true;
};

Other options

Option Default Description
clamav.enable true Disable on hosts with < 1.5 GiB RAM
messageSizeLimit 52428800 50 MiB
recipientDelimiter "+" Address extensions (alice+tag@), "" to disable
vmailUid 5000 UID for vmail user. Change if it collides
vmailGid 5000 GID for vmail group. Change if it collides
debug false Verbose logging on all services
dkimSelector "mail" Active DKIM selector
autoconfig.enable true Serve autoconfig/autodiscover XML endpoints

DKIM Key Rotation

# 1. Generate new key with new selector
sudo bash scripts/generate-dkim.sh example.com mail2

# 2. Publish mail2._domainkey.example.com in DNS

# 3. Update config
#    cnixpost.dkimSelector = "mail2";

# 4. Deploy
nixos-rebuild switch --flake .#yourhost

# 5. Wait 48h for DNS TTL, then remove the old mail._domainkey record

Diagnostics

# Service status
systemctl status postfix dovecot2 rspamd redis-rspamd clamav-daemon

# Postfix queue
postqueue -p     # inspect
postqueue -f     # flush deferred

# Rspamd web UI: https://rspamd.yourdomain.com
# Protected by authelia + controller password

# Score a message
rspamc -h localhost:11334 symbols < msg.eml

# Force Bayes training
rspamc -h localhost:11334 learn_spam < spam.eml
rspamc -h localhost:11334 learn_ham  < ham.eml
rspamc -h localhost:11334 stat | grep -i bayes

# Verify DKIM
rspamadm dkim_sign -d example.com -s mail < test.eml | grep DKIM

# Test SMTP
swaks --to alice@example.com --from postmaster@example.com --server localhost

# Tail logs
journalctl -u postfix -u dovecot2 -u rspamd -u clamav-daemon -f

# fail2ban
fail2ban-client status dovecot
fail2ban-client status postfix-sasl

# MTA-STS resolver (if enabled)
systemctl status postfix-mta-sts-resolver
# Service status
systemctl status postfix dovecot2 rspamd redis-rspamd clamav-daemon

# Postfix queue
postqueue -p     # inspect
postqueue -f     # flush deferred

# Score a message
open msg.eml | rspamc -h localhost:11334 symbols

# Force Bayes training
open spam.eml | rspamc -h localhost:11334 learn_spam
open ham.eml | rspamc -h localhost:11334 learn_ham
rspamc -h localhost:11334 stat | grep -i bayes

# Verify DKIM
open test.eml | rspamadm dkim_sign -d example.com -s mail | grep DKIM

# Test SMTP
swaks --to alice@example.com --from postmaster@example.com --server localhost

# Tail logs
journalctl -u postfix -u dovecot2 -u rspamd -u clamav-daemon -f

# fail2ban
fail2ban-client status dovecot
fail2ban-client status postfix-sasl

Ports

Traefik binds all external-facing ports and proxies to the loopback services with PROXY protocol v2.

Port Protocol Direction Purpose
25 SMTP Inbound Server-to-server mail
465 SMTPS Inbound Client submission (implicit TLS)
587 Submission Inbound Client submission (STARTTLS)
993 IMAPS Inbound Client mailbox access
143 IMAP Inbound Client mailbox access (STARTTLS)
4190 ManageSieve Inbound Sieve filter management

Minimum required: 25, 465, 993. Add 587 if any clients don't support implicit TLS on 465. Ports 143 and 4190 can be restricted to VPN/Tailscale if all clients support IMAPS and you manage sieve filters locally.


Memory footprint (idle)

Service RSS
Postfix ~35 MiB
Dovecot ~65 MiB
Rspamd + Redis ~230 MiB
ClamAV ~700 MiB
Total with ClamAV ~1.0 GiB
Total without ClamAV ~330 MiB