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
UsernamedDomainProviderwhose Password is the secret (roleNETSetupRoles.DomainProvider), and setsconfig.DomainProvider(non-secret knobs). Same config-driven-credentials posture as the mail relay. -
NixDnsTemplateemitshost.dns.ddns.; the *dns VM runs ddns-updater (qdm12). It is decoupled from ACME - dyndns works with no Let’s Encrypt at all (DnsProvidermay stay null). -
Public IP is learned by HTTP echo from the dns VM: primary
https://api.ipify.org, fallbackhttps://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.
-
Get a static public IPv4 from the ISP. Co-located or business plan typically. AAAA + IPv6 PTR if dual-stack.
-
Request rDNS from the ISP:
<public-v4>→mail.example.com. Many ISPs expose this through their portal; some require a ticket. Verify withdig -x <public-v4>. -
HELO name =
mail.<domain>. Already set byservices.postfix.hostname = "mail.${cfg.domain}"innixos/modules/services/mail.nix.
Step 2 - DNS records (public authoritative zone)
| Type | Name | Value |
|---|---|---|
A |
|
|
AAAA |
|
|
MX |
|
|
TXT |
|
|
TXT |
|
|
TXT |
|
|
TXT |
|
|
DKIM key generation runs first-boot on the mail VM:
-
Service:
opendkim-genkey(oneshot, seemail.nixline ~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 ( |
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
-
https://www.mail-tester.com/- send a test mail from the VM; aim for 10/10. -
https://mxtoolbox.com/SuperTool.aspx- check MX / SPF / DKIM / DMARC / blacklist / rDNS. -
swaks --to test@gmail.com --from postmaster@example.com --server localhost --tlsfrom the mail VM.
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) |
|
587 |
No (paid Workspace plan) |
Microsoft 365 |
|
587 |
No (paid M365 plan) |
SendGrid |
|
587 |
100/day free |
Mailgun |
|
587 |
Trial only, then paid |
Mailjet |
|
587 |
200/day free |
AWS SES |
|
587 |
62k/month free if sent from EC2 |
Self-hosted PMG |
|
25 / 587 |
Free |
Step 2 - obtain SMTP credentials
Per provider:
Gmail / Workspace
-
https://myaccount.google.com → Security → 2-Step Verification (must be on).
-
Same page → App passwords → generate one named "netsetup-relay".
-
Username = the full Workspace address (
noreply@example.com). -
Password = the 16-char app password.
Microsoft 365
-
Admin Center → Users → Active users → select user → Mail tab → Manage email apps → enable "Authenticated SMTP".
-
Modern auth: an app password is required only if MFA is on. Otherwise the user’s own password works.
-
Username = full UPN (
relay@example.onmicrosoft.com).
SendGrid
-
https://app.sendgrid.com → Settings → API Keys → Create API Key → "Restricted Access" with "Mail Send" only.
-
SMTP credentials: Username is literally
apikey, password is the API key string starting withSG..
Mailgun
-
https://app.mailgun.com → Sending → Domains → Add your domain → verify the DNS records they require.
-
SMTP credentials section → create new user → copy generated password.
-
Username =
postmaster@mg.example.com(their domain prefix).
Mailjet
-
https://app.mailjet.com → Account Settings → SMTP & SEND API Settings → copy API Key (user) + Secret Key (password).
AWS SES
-
SES console → Verified identities → create + verify your domain (DNS records required).
-
SMTP settings → Create SMTP credentials → downloads CSV with IAM-derived username + password.
-
Move out of SES sandbox if you want to send to arbitrary recipients (support ticket).
Self-hosted PMG
-
PMG UI → Configuration → Mail Proxy → Relay Domains → add
example.com. -
PMG UI → Configuration → Mail Proxy → Transport →
example.com→<mail-vm-ip>:26. -
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 inAddMailRelay.
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 |
|
Microsoft 365 |
|
SendGrid |
|
Mailgun |
|
Mailjet |
|
AWS SES |
|
PMG (self) |
|
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 → |
Relay receives + forwards |
MX → provider’s inbound host (e.g. |
PMG in front |
MX → PMG’s public hostname. PMG filters, relays to mail VM port 26 via |
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 reusedefault. -
Roundcube identity bug - if
mail_domain/username_domaininmail.nixis missing, From showsuser@localhostand recipients with bare local-parts fail. Fix already inmail.nixextraConfig.
Where things live in the repo
| File | Role |
|---|---|
|
Per-customer config + |
|
|
|
|
|
POCO emitted into nix. |
|
Renders |
|
|
|
postfix + dovecot + roundcube + opendkim + nginx + relay tmpfiles. |
|
Original design doc for the whole stack. |
|
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.