Skip to content

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, when PUBLIC_IP is 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=none monitor policy with rua=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 stable key (mx, spf, dkim:<selector>, dmarc, srv:imaps, …) and a required flag: 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. or autodiscover. 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 of v=spf1 a:<mail host> … -all labelled to be merged into the record the domain already has, because mx would authorise the other server and replacing the record would fail the mail that server sends. The two SRV rows, srv:imaps and srv: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/verify returns the last result (null if 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 -all or ~all (SPF never reads past it), or with a redirect= ending it; a record with +all or ?all fails, and so do two SPF records, which every receiver treats as a permerror; DKIM by the p= public key (tag order and 255-byte chunking ignored); DMARC accepts any p= 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.
  • ready is true when every required record is ok or propagating; 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.spf holds 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 read v=spf1 mx <term> -all and v=spf1 a <term> -all; a remote-routed domain’s row reads v=spf1 a:<hostname> <term> -all.
  • It cannot be derived from relay.host, and the product does not try. For Amazon SES the relay host is email-smtp.<region>.amazonaws.com, which publishes no TXT record at all (so include:<host> is a permerror) and whose A records are the submission endpoint, not the sending fleet (so a:<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 stale include: 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 A or AAAA row. All four are asked, but the system resolver’s stack-internal addresses (the list isStackInternalAddress reads: loopback, 172.16.0.0/12, 192.168.0.0/16 and fd00:6d61:696c::/64) are not counted. Inside Compose that resolver is Docker’s embedded DNS, which up to 0.4.1 answered the mail hostname with the postfix container’s own address, because compose.yaml gave postfix hostname: ${MAIL_HOSTNAME}. From 0.4.2 it 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 as not counted and 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 real mismatch. So the row can reach ok inside the stack, a wrong public A still reads mismatch, and drift still alerts on it within one cycle (jobs.test.ts). Postfix’s myhostname, banner and HELO are unchanged. Up to 0.4.1 the 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 read mismatch — the same gap login-rate-limiting.md names for bans. Until 2026-09-26 the row stayed propagating for ever inside Compose.
  • PTR is checked by reverse lookup of PUBLIC_IP (and the IPv6 PTR of PUBLIC_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=true and the host’s IPv6 address in PUBLIC_IP6 (detected by mailctl install, or by mailctl upgrade when IPv6 is switched on later), the DNS & routing section lists the mail host’s AAAA (required) beside its A, and the IPv6 PTR (advisory, set at the provider). The SPF records need no change: mx and a match a host’s AAAA as well as its A. While IPv6 is off neither row is offered, whatever the address, because an AAAA with nothing answering on IPv6 sends senders and certificate challenges to a closed port (IPv6). PUBLIC_IP itself must be IPv4.
  • The autoconfig/autodiscover CNAMEs appear with ACME TLS; the MTA-STS + TLS-RPT rows need ACME TLS and mtaSts.mode other than none (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.