Skip to content

MTA-STS and TLS-RPT

Purpose. Tell sending servers that mail for your domains must arrive over verified TLS to your MX (MTA-STS, RFC 8461), and get reports when they could not (TLS-RPT, RFC 8460). Closes the downgrade / MX-spoofing hole that SPF, DKIM and DMARC leave open.

Behaviour.

  • With tls.mode = acme and mtaSts.mode ≠ none (default testing), every active domain routed to this server (routing: local) gets three more DNS rows: _mta-sts.<domain> TXT "v=STSv1; id=<id>", mta-sts.<domain> CNAME <hostname> (the policy host) and _smtp._tls.<domain> TXT "v=TLSRPTv1; rua=mailto:dmarc-reports@<domain>", and the server answers https://mta-sts.<domain>/.well-known/mta-sts.txt with
    version: STSv1
    mode: testing
    mx: <hostname>
    max_age: 604800
    Caddy routes mta-sts.<domain> hosts to the API and issues the mta-sts.<domain> certificate on demand at the first request; before issuing, it asks the API (/api/v1/internal/tls-ask?domain=) which says yes only for mta-sts., autoconfig. or autodiscover. on an active domain routed to this server (none of the three for a remote domain), and for the server’s own two hosts, postmaster. and webmail. of the server’s domain — no certificates for arbitrary names. The admin publishes the generated CNAME for mta-sts.<domain> → the mail hostname (an A to the same address works as well) plus the two TXT rows. Caddy’s host matcher needs an equal label count and * stands for exactly one label, so the route is not a single mta-sts.* — serviceHostPatterns() emits one pattern per domain depth, up to a five-label domain. A deeper domain gets no route, and because certificates are issued by SNI rather than by route it would answer an empty 200 behind a valid certificate rather than an error. scripts/ci/caddy-routes.sh asserts the whole set against a real Caddy — but only what renderCaddy emits, not what a running server has loaded. Those are different things: an upgrade swaps images and does not re-render, so a server installed on an older build kept serving that build’s Caddy config — this route was dead on our own host after upgrading to a version containing the fix. The API now re-applies at boot when the build that rendered the live configuration is not the one running, which closes that gap. See Upgrade → Configuration is re-rendered after an upgrade (and what to check if it is not).
  • The policy id is a hash of the policy text: it changes when mode, max_age or the hostname changes, which is what tells receivers to re-fetch. DNS verification checks both TXT rows exactly.
  • Settings → MTA-STS: MTA-STS policy mode (none / testing / enforce) and max_age (between 86400, a day, and 31557600, a year; default 604800). Use testing until the TLS reports section shows no failures, then enforce — that tab is what makes the advice actionable rather than aspirational.

Configuration. ServerConfig.mtaSts.{mode,maxAgeSeconds}; requires tls.mode = acme (BYO and none cannot obtain per-domain certificates — the rows and the endpoint are simply absent then).

Limits & edge cases.

  • TLS-RPT reports (JSON, .json.gz) arrive in the same dmarc-reports@ mailbox as DMARC reports and are parsed by the same ingest job, every 30 minutes. Stored per domain in tls_report, shown under Domains → a domain → TLS reports, and — the point of the whole thing — a report carrying failed sessions raises an alert (tls-rpt:<domainId>, warning).

    The routing between the two report kinds is by label first (application/tlsrpt+gzip, .json.gz) and by the decoded body second, because both kinds are commonly gzipped with a generic content type: a DMARC report starts <, a TLS-RPT one {. An attachment that decodes to nothing is ignored, exactly as before.

    The alert resolves only on a later clean report that saw traffic. Both conditions matter. A reporter with nothing to say says nothing about whether the problem is fixed, and a clean report covering the same window as the failures is a second opinion about the same hours rather than evidence they stopped — resolving on either would make the alert flap and teach people to ignore it.

  • On-demand issuance needs the mta-sts.<domain> name to resolve to this host and ports 80/443 reachable; the DNS row generated for it is CNAME mta-sts.<domain> → <hostname> (an A to PUBLIC_IP works too) — add it at your provider.

  • A remote-routed domain gets no policy from here: its mail is received by its own server, so /.well-known/mta-sts.txt answers 404 for it and tls-ask refuses a certificate for its mta-sts. name, rather than serve a policy naming this host as its MX. The same refusal covers its autoconfig. and autodiscover. names, whose endpoints answer nothing for a remote domain either.

  • enforce with a wrong mx: line blocks inbound mail from MTA-STS-aware senders for max_age; the policy always names the configured hostname, so only change the hostname with mode = testing.

Why the reports are read, and not just kept

Section titled “Why the reports are read, and not just kept”

This feature exists because of what happened without it. MTA-STS was ticked as delivered in this product and did not work for its entire life — the routes served nothing, no sender could fetch a policy, and nobody knew. The channel that would have said so, daily and from every major sender, was TLS-RPT: arriving in a mailbox, unparsed.

So two things are deliberate in the TLS reports section. A window with no reports at all says so loudly rather than rendering an empty table, because silence is exactly how a broken policy stays invisible and an empty table reads like “no problems”. And a no-policy-found policy type is surfaced as a finding rather than filtered out — it means that sender could not fetch a policy at all, which is the same failure this feature was built to catch.