Two ways to put a customer’s mail VM on the public internet:

  • Standalone - Mail VM is the public face. Direct MX delivery, ISP must let port 25 out, public PTR mandatory.

  • Smarthost (relay) - All outbound mail leaves through an upstream provider (Gmail / M365 / SendGrid / Mailgun / Mailjet / a PMG you operate). Skips ISP port-25 blocks. Provider handles reputation.

Both share the same TLS / SPF / DKIM / DMARC story but differ on rDNS, port 25, and credentials.

Customer builder code

Code lives in Customers/<CustomerName>.cs. Mail VM itself is implicit (injected by LiberatorWithWinDCTranslator); the customer file only needs to declare the domain and (optionally) the smarthost.

Standalone (no relay)

var config = new NETSetupConfig(ConfigName.From("ExampleCorp"))
{
    CustomerDomain = "example.com",
    // ... rest of customer setup
};
// no AddMailRelay call -> postfix does direct MX delivery from the mail VM.

Smarthost / relay

config.AddMailRelay(
    host:         "smtp.gmail.com",
    saslUser:     "noreply@example.com",
    saslPassword: "abcd-efgh-ijkl-mnop".ToSecretString(),
    port:         587);  // default 587, omit for the common case

The extension lives in src/NETSetup/Extensions/MailRelayExtensions.cs. Behind the scenes it adds a User to config.DirectoryService.Users with role NETSetupRoles.MailRelay; NixTemplateFactory.BuildRelayFromUsers picks it up at template-render time and emits host.mail.relay into the Mail VM config.

Alternative (explicit POCO, useful for tests or non-587 ports without the user-role shape):

config.MailRelay = new MailRelayOptions
{
    Host          = "smtp.example.com",
    Port          = 2525,
    User          = "user",
    PasswordPlain = "pw",
};

config.MailRelay wins over the role-tagged user when both are set.

Dyndns (dynamic WAN IP, no static public IP)

Most Liberator sites have a dynamic public IP, not a static one. dyndns keeps the public A records pointed at the current WAN IP. Declared on the company:

Typical topology is hosting-anywhere → CNAME → No-IP → dyndns (full walkthrough with screenshots in dyndns-domain-setup.adoc). The domain is the dyndns TARGET zone (the No-IP zone), separate from CustomerDomain (the real domain):

company.AddDomainProvider(
    provider: "noip",                          // any qdm12/ddns-updater provider (noip, duckdns, dyn, cloudflare, ...)
    secret:   "your-token-or-password".ToSecretString(),
    hosts:    new[] { "laufentaler" },         // labels updated under `domain` -> laufentaler.ddns.net
    username: "your-login",                    // optional - omit for token-only providers (duckdns)
    secretKey: "password",                     // "password" or "token" - the ddns-updater settings key
    domain:   "ddns.net");                     // dyndns TARGET zone (No-IP), NOT CustomerDomain. Null => own zone.

What happens:

  • Translator inserts a User named DomainProvider whose Password is the secret (role NETSetupRoles.DomainProvider), and sets config.DomainProvider (non-secret knobs). Same config-driven-credentials posture as the mail relay.

  • NixDnsTemplate emits host.dns.ddns.; the *dns VM runs ddns-updater (qdm12). It is decoupled from ACME - dyndns works with no Let’s Encrypt at all (DnsProvider may stay null).

  • Public IP is learned by HTTP echo from the dns VM: primary https://api.ipify.org, fallback https://icanhazip.com. The dns VM NATs out through OPNsense, so the echo returns the OPNsense WAN public IP (correct for any non-CGNAT site). No cross-VLAN query, no OPNsense/Mikrotik credentials needed.

  • OPNsense is unaffected by dyndns - it keeps doing firewall port-forwards + DNS-forwarder. dyndns is a dns-VM concern only.

Static-IP escape hatch: if you call company.WithPublicIP("203.0.113.5"), dyndns is skipped entirely (a static A record needs no updater - set it once at the registrar).

Domain-side configuration (provider account, zone topology, which records the updater touches vs the static MX/SPF/DKIM/DMARC, PTR/smarthost implications): see dyndns-domain-setup.adoc.

Mode A: Standalone

Mail VM holds a public IP (port-forwarded or directly attached) and delivers to other mail servers itself.

Step 1 - public IP + reverse DNS (rDNS / PTR)

Non-negotiable. Without a PTR that matches the HELO name, Gmail / Outlook will silently drop or quarantine your mail.

  1. Get a static public IPv4 from the ISP. Co-located or business plan typically. AAAA + IPv6 PTR if dual-stack.

  2. Request rDNS from the ISP: <public-v4>mail.example.com. Many ISPs expose this through their portal; some require a ticket. Verify with dig -x <public-v4>.

  3. HELO name = mail.<domain>. Already set by services.postfix.hostname = "mail.${cfg.domain}" in nixos/modules/services/mail.nix.

Step 2 - DNS records (public authoritative zone)

Type Name Value

A

mail.example.com

<public-v4>

AAAA

mail.example.com

<public-v6> (optional)

MX

example.com.

10 mail.example.com.

TXT

example.com.

v=spf1 mx -all

TXT

default._domainkey.example.com.

v=DKIM1; k=rsa; p=<pubkey> (see below)

TXT

_dmarc.example.com.

v=DMARC1; p=quarantine; rua=mailto:postmaster@example.com

TXT

example.com.

<google-site-verification-…​> (only if you also use Google Workspace)

DKIM key generation runs first-boot on the mail VM:

  • Service: opendkim-genkey (oneshot, see mail.nix line ~390)

  • Key path: /var/lib/opendkim/keys/<domain>/default.private

  • Pubkey for DNS: /var/lib/opendkim/<domain>.txt. Echoed to journal. SSH in and copy:

ssh root@<mail-vm-ip> cat /var/lib/opendkim/<domain>.txt

Step 3 - firewall (OPNsense WAN → Mail VM)

Port-forward each direction:

Port Proto Why

25

TCP

Inbound from other mail servers

465

TCP

SMTPS submission (Outlook / Thunderbird)

587

TCP

STARTTLS submission (most clients)

993

TCP

IMAPS (external clients reading mail)

443

TCP

Roundcube webmail (mail.<domain>)

80

TCP

Only if using Let’s Encrypt HTTP-01 challenge; close otherwise.

If clients are always internal/VPN, drop 465/587/993 from the WAN forward and keep 25/443 only.

Step 4 - TLS cert

Currently the dns VM pulls Let’s Encrypt via DNS-01 (lego, services.dns.nix) and syncs the cert path to the mail VM at /var/lib/acme/mail.<domain>/. Paths consumed by cfg.tlsCertPath / cfg.tlsKeyPath.

If DNS-01 isn’t available, switch the mail VM to HTTP-01 ACME (requires port 80 open).

Step 5 - ISP egress port 25

Most consumer / residential / many SME ISPs silently block outbound TCP/25. Verify before relying on standalone:

ssh root@<mail-vm-ip> 'nc -vz -w 5 gmail-smtp-in.l.google.com 25'

If it times out: standalone is dead, switch to smarthost.

Step 6 - validate

Mode B: Smarthost (relay)

Mail VM still owns the domain and the mailboxes; only outbound delivery goes through a third party. Inbound either still hits port 25 on your IP, or you point MX at the relay too (provider-dependent).

Step 1 - pick a provider and create an account

Provider Host Port Free tier?

Gmail (Workspace)

smtp.gmail.com

587

No (paid Workspace plan)

Microsoft 365

smtp.office365.com

587

No (paid M365 plan)

SendGrid

smtp.sendgrid.net

587

100/day free

Mailgun

smtp.mailgun.org

587

Trial only, then paid

Mailjet

in-v3.mailjet.com

587

200/day free

AWS SES

email-smtp.<region>.amazonaws.com

587

62k/month free if sent from EC2

Self-hosted PMG

<pmg-public-ip>

25 / 587

Free

Step 2 - obtain SMTP credentials

Per provider:

Gmail / Workspace

  1. https://myaccount.google.com → Security → 2-Step Verification (must be on).

  2. Same page → App passwords → generate one named "netsetup-relay".

  3. Username = the full Workspace address (noreply@example.com).

  4. Password = the 16-char app password.

Microsoft 365

  1. Admin Center → Users → Active users → select user → Mail tab → Manage email apps → enable "Authenticated SMTP".

  2. Modern auth: an app password is required only if MFA is on. Otherwise the user’s own password works.

  3. Username = full UPN (relay@example.onmicrosoft.com).

SendGrid

  1. https://app.sendgrid.com → Settings → API Keys → Create API Key → "Restricted Access" with "Mail Send" only.

  2. SMTP credentials: Username is literally apikey, password is the API key string starting with SG..

Mailgun

  1. https://app.mailgun.com → Sending → Domains → Add your domain → verify the DNS records they require.

  2. SMTP credentials section → create new user → copy generated password.

  3. Username = postmaster@mg.example.com (their domain prefix).

Mailjet

  1. https://app.mailjet.com → Account Settings → SMTP & SEND API Settings → copy API Key (user) + Secret Key (password).

AWS SES

  1. SES console → Verified identities → create + verify your domain (DNS records required).

  2. SMTP settings → Create SMTP credentials → downloads CSV with IAM-derived username + password.

  3. Move out of SES sandbox if you want to send to arbitrary recipients (support ticket).

Self-hosted PMG

  1. PMG UI → Configuration → Mail Proxy → Relay Domains → add example.com.

  2. PMG UI → Configuration → Mail Proxy → Transport → example.com<mail-vm-ip>:26.

  3. Outbound auth: either PMG accepts your mail VM’s IP via mynetworks, or you set up SASL on the PMG postfix and use those creds in AddMailRelay.

Step 3 - wire credentials into customer code

config.AddMailRelay(
    host:         "smtp.sendgrid.net",
    saslUser:     "apikey",
    saslPassword: "SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxx".ToSecretString());

Plaintext password lands in the nix store on the mail VM. Lab-grade by design - same posture as the per-user mailbox passwords. Hardening path: set passwordPlain = "" in MailRelayOptions and populate /run/secrets/relay-pass via SOPS-nix instead.

Step 4 - DNS records (still required)

Same as standalone for SPF / DKIM / DMARC, but adjust SPF to authorise the provider’s sending IPs:

Provider SPF include

Gmail / Workspace

v=spf1 include:_spf.google.com -all

Microsoft 365

v=spf1 include:spf.protection.outlook.com -all

SendGrid

v=spf1 include:sendgrid.net -all

Mailgun

v=spf1 include:mailgun.org -all

Mailjet

v=spf1 include:spf.mailjet.com -all

AWS SES

v=spf1 include:amazonses.com -all

PMG (self)

v=spf1 mx -all (PMG sends from your own IP)

You can combine: v=spf1 mx include:_spf.google.com -all for a mixed setup.

DKIM keys: provider-managed. Each provider gives a CNAME or TXT record to publish. Replace the standalone default._domainkey.<domain> entry above with whatever the provider tells you.

Step 5 - inbound MX choice

Choice Records

Mail VM still public on port 25

MX → mail.<domain> (your IP). Inbound bypasses relay entirely.

Relay receives + forwards

MX → provider’s inbound host (e.g. inbound-smtp.<region>.amazonaws.com for SES). Relay then SMTPs to your mail VM on 25 or 587 with SASL. Most provider-dependent setup; see provider docs.

PMG in front

MX → PMG’s public hostname. PMG filters, relays to mail VM port 26 via pmgSourceCidr (already wired in mail.nix).

Step 6 - validate

# from the mail VM
journalctl -u postfix -f
swaks --to test@gmail.com --from noreply@example.com --server localhost --port 587 --tls --auth-user noreply@example.com --auth-password 'app-pw'

Look in the log for relay=smtp.gmail.com[…​]:587 and status=sent. If you see Relay access denied / 5.7.0 Authentication Required the SASL creds are wrong.

Gotchas common to both modes

  • HELO / EHLO name = FQDN that matches PTR. postfix.myhostname = mail.<domain> is already set; don’t override.

  • IPv6 rDNS - Gmail penalises if you advertise AAAA but the v6 PTR doesn’t resolve back. Either don’t publish AAAA, or set both.

  • Greylist tax - first delivery to a new recipient often delayed 5-15 min. Not a config bug.

  • MTA-STS / TLS-RPT (TXT records) - optional but recommended for high-volume domains. Out of scope here.

  • DNSSEC - if the customer’s domain is DNSSEC-signed, every record you change must be re-signed by the registrar.

  • Reverse DNS only at the IP holder - if you’re behind an ISP NAT or use a relay that has its own IPs, you can’t set rDNS yourself. Use a smarthost.

  • Don’t open an open relay - test with swaks --server <ip> -f spoof@elsewhere -t test@gmail.com. Must be rejected. Postfix is closed by default here; only the customer’s mailbox users can submit via SASL.

  • DKIM key rotation - first-boot generates one key, selector default. Rotate manually by regenerating + publishing a new selector; don’t reuse default.

  • Roundcube identity bug - if mail_domain / username_domain in mail.nix is missing, From shows user@localhost and recipients with bare local-parts fail. Fix already in mail.nix extraConfig.

Where things live in the repo

File Role

Customers/<Customer>.cs

Per-customer config + AddMailRelay call.

src/NETSetup/Extensions/MailRelayExtensions.cs

AddMailRelay(host, user, pw, port) extension.

src/NETSetup/Entities/NETSetupRoles.cs

MailRelay role constant.

src/NETSetup/Entities/Mail/MailRelayOptions.cs

POCO emitted into nix.

src/NETSetup/Config/Nix/Templates/NixMailTemplate.cs

Renders host.mail.* options.

src/NETSetup/Config/Nix/Templates/NixTemplateFactory.cs

BuildRelayFromUsers + BuildMailboxes mailbox/relay split.

src/NETSetup/nixos/modules/services/mail.nix

postfix + dovecot + roundcube + opendkim + nginx + relay tmpfiles.

docs/FSD-PublicMail.adoc

Original design doc for the whole stack.

docs/firewall.adoc

Gateway / OPNsense WAN port-forward background.

Quick decision tree

ISP allows outbound 25?
+- yes -> public IPv4 + rDNS available?
|   +- yes -> Standalone (no AddMailRelay)
|   +- no  -> Smarthost (AddMailRelay -> any provider)
+- no  -> Smarthost (AddMailRelay -> any provider)

High volume / strict deliverability needed?
-> Smarthost. Provider IPs have established reputation; your /24 doesn't.

Customer needs E2E control / no third-party?
-> Standalone. Be ready for the deliverability fight.