DNS records & live verification
Purpose. Tell the admin exactly which DNS records a domain needs and whether the world can see them, so that a fresh domain becomes deliverable without guesswork.
Behaviour.
GET /domains/{id}/dns(UI: Domains →→ DNS & routing ; the wizard’s last step) lists the records: A (mail host, whenPUBLIC_IPis known), MX, SPF (v=spf1 mx -all, plus the relay’s term when one is configured — see Outbound relays below), one DKIM TXT per key (DKIM signing), DMARC (p=nonemonitor policy withrua=mailto:dmarc-reports@<domain>— DMARC aggregate reports), IMAP/SMTP SRV autodiscover,TXT <hostname>SPF for SRS-rewritten forwards (SRS — sender rewriting for forwarded mail), PTR. Each row has a stablekey(mx,spf,dkim:<selector>,dmarc,srv:imaps, …) and arequiredflag: A (and AAAA while IPv6 is on), MX (local domains only), SPF and the active DKIM key are required for the domain to be ready; the rest are recommended.- A domain routed to another server (
routing: remote) gets no MX, MTA-STS, TLS-RPT,autoconfig.orautodiscover.rows — its own server publishes those, and a mail client following ours would be pointed at a server its mailboxes do not live on — and an SPF row ofv=spf1 a:<mail host> … -alllabelled to be merged into the record the domain already has, becausemxwould authorise the other server and replacing the record would fail the mail that server sends. The two SRV rows,srv:imapsandsrv:submission, are still listed for it, pointing at this host; they are recommended rather than required, and should not be published for such a domain. - Verify now (
POST /domains/{id}/dns/verify) queries every record against the system resolver and Cloudflare (1.1.1.1), Google (8.8.8.8) and Quad9 (9.9.9.9) in parallel (3 s timeout each) and stores the result;GET /domains/{id}/dns/verifyreturns the last result (nullif never checked). Per record:ok— every resolver that answered returned a matching record;propagating— some resolvers see it, others do not yet;mismatch— a record of that kind exists but differs (the observed values are shown so the admin sees what to fix);missing— no such record;error— no resolver answered (network/DNS outage).- Matching rules: MX by exchange host (priority ignored); SPF exact for a local domain (other TXT records at the apex ignored); for a remote-routed domain, whose SPF is merged into the record it already has, every term of the generated record (
a:<hostname>, and the relay’s) must be present, in any order but before the record’s-allor~all(SPF never reads past it), or with aredirect=ending it; a record with+allor?allfails, and so do two SPF records, which every receiver treats as a permerror; DKIM by thep=public key (tag order and 255-byte chunking ignored); DMARC accepts anyp=policy — the admin may tighten it; SRV by port + target; A/CNAME/PTR by value. The mail host’s own SPF TXT (spf-host) and the MTA-STS and TLS-RPT TXT records are judged by exact value against every record at that name, without the SPF single-record or merge rules.
readyis true when every required record isokorpropagating; the UI shows Domain ready / Domain not ready with the check time.- Export / provider notes (
GET /domains/{id}/dns/export): a BIND-style zone snippet (relative names, DKIM TXT split into 255-byte strings, PTR omitted) and notes for Cloudflare (proxy off), Route 53 (quoting) and generic panels.
Outbound relays. relay.password is write-only: sealed at rest under SECRET_ENCRYPTION_KEY (purpose relay.password), never in a response, the audit log or a stored render. Postfix’s sasl_passwd is written at apply time, PUT /server/config with no password keeps the stored one for the same host and username (400 relay_password_required for a relay that has none, or a new host or username — a blank field must never send one provider’s password to another), and a password the server can no longer open is reported as relay.passwordUnreadable while Postfix keeps the credentials it already has for that host and username. When ServerConfig.relay is set, Postfix sends through it and SRS does not rewrite a hosted domain’s envelope sender (SRS — sender rewriting for forwarded mail), so a receiver evaluates SPF for that domain against the relay’s IP. mx does not cover the relay and -all is a hard fail, so an unqualified record tells receivers to reject the domain’s own mail. A relay also changes what the server’s own DKIM signature and ARC seal cover: Date and Message-ID are left out, because a relay such as SES rewrites both (DKIM signing).
relay.spfholds one SPF mechanism, written by whoever configured the relay —include:amazonses.com,ip4:203.0.113.0/24,a:smtp.example.net. Both SPF rows then readv=spf1 mx <term> -allandv=spf1 a <term> -all; a remote-routed domain’s row readsv=spf1 a:<hostname> <term> -all.- It cannot be derived from
relay.host, and the product does not try. For Amazon SES the relay host isemail-smtp.<region>.amazonaws.com, which publishes no TXT record at all (soinclude:<host>is a permerror) and whose A records are the submission endpoint, not the sending fleet (soa:<host>still fails). The term that works,include:amazonses.com, is a different name that only the operator’s documentation gives you. - A relay configured with no term leaves the record unchanged and marks the SPF rows with a warning saying it hard-fails — the warning travels on the record because that is the screen where the admin is being told to publish it.
- Removing the relay makes the published record
mismatch, and that is deliberate. The generated value loses the term, so the drift check flags the stale one. A staleinclude:leaves a third party authorised to send as the domain indefinitely. - Green means you published what we generated, not SPF passes at receivers. Proving the latter is a send test.
Configuration. PUBLIC_IP in .env (written by mailctl install) enables the A and PTR rows; PUBLIC_IP6 with IPV6_ENABLED=true the AAAA and IPv6 PTR rows. Resolver list and timeout are code constants (packages/deliverability/src/dns-verify.ts).
Limits & edge cases.
- The worker re-runs the same verification for every active domain every 15 minutes (
DNS_CHECK_INTERVAL_MINUTES) and raises a drift alert when a required record that verified before stops verifying — see Blocklist, reverse-DNS and certificate monitoring (alerts). - A resolver that times out is ignored for the verdict (it “has no opinion”); only if all four fail is the record
error. Docker Desktop’s embedded resolver, for instance, times out on non-existent TLDs instead of returning NXDOMAIN. - Which resolvers decide an
AorAAAArow. All four are asked, but thesystemresolver’s stack-internal addresses (the listisStackInternalAddressreads: loopback, 172.16.0.0/12, 192.168.0.0/16 andfd00:6d61:696c::/64) are not counted. Inside Compose that resolver is Docker’s embedded DNS, which up to0.4.1answered the mail hostname with thepostfixcontainer’s own address, becausecompose.yamlgavepostfixhostname: ${MAIL_HOSTNAME}. From0.4.2it does not, and the name resolves as it does outside. The same answer had sent a migration from a source sharing the name to our own postfix. The rule stays, because any stack-internal answer from the local resolver still says nothing about the public record. An answer made only of such addresses is shown asnot countedand has no opinion, like a timeout. The public part of a mixed answer still counts. Cloudflare, Google and Quad9 are judged strictly: a private address from any of them is a realmismatch. So the row can reachokinside the stack, a wrong publicAstill readsmismatch, and drift still alerts on it within one cycle (jobs.test.ts). Postfix’smyhostname, banner and HELO are unchanged. Up to0.4.1the rule covered the Docker default pools only: a Compose network on an address pool outside that list (10.0.0.0/8, say) made the container’s address count, and the row readmismatch— the same gaplogin-rate-limiting.mdnames for bans. Until 2026-09-26 the row stayedpropagatingfor ever inside Compose. - PTR is checked by reverse lookup of
PUBLIC_IP(and the IPv6 PTR ofPUBLIC_IP6); A and AAAA answers are compared in one canonical spelling; it is set at the VPS provider, not the DNS host. - IPv6 rows appear only while the server serves IPv6. With
IPV6_ENABLED=trueand the host’s IPv6 address inPUBLIC_IP6(detected bymailctl install, or bymailctl upgradewhen IPv6 is switched on later), the DNS & routing section lists the mail host’sAAAA(required) beside itsA, and the IPv6PTR(advisory, set at the provider). The SPF records need no change:mxandamatch a host’sAAAAas well as itsA. While IPv6 is off neither row is offered, whatever the address, because anAAAAwith nothing answering on IPv6 sends senders and certificate challenges to a closed port (IPv6).PUBLIC_IPitself must be IPv4. - The autoconfig/autodiscover CNAMEs appear with ACME TLS; the MTA-STS + TLS-RPT rows need ACME TLS and
mtaSts.modeother thannone(MTA-STS and TLS-RPT, Client autoconfiguration (autoconfig / autodiscover)). - There is no
webmail.<domain>row here, and that is deliberate. The mail client is one host for the whole server —webmail.<the server's own domain>— not one per hosted domain, so it is a record the operator publishes once rather than a row in each domain’s DNS & routing section (Webmail). It was per-domain until 2026-09-12; an install is one organisation, so the extra names were second names for the same staff, and each was its own browser origin and therefore its own sign-in. - The wizard’s DNS step is a checklist (no session yet); its Sign in and verify DNS button signs in and opens the domain’s DNS & routing section, where verification runs.