Inbox
articleJune 7, 20266 min read

TLS-RPT Setup: Aggregate Reports for SMTP TLS Failures

Publish a TLS-RPT TXT record and ingest the JSON aggregate reports that Gmail, Microsoft, and other receivers send. Spot MTA-STS and DANE failures before users do.

By SESMetric Editorial

You enforced MTA-STS or DANE on your domain and the world went quiet. That silence is the problem: when a receiver fails to negotiate TLS to your mail server, the message is rejected and you never hear about it. TLS-RPT fixes that by asking remote receivers to mail you a daily JSON summary of every TLS handshake they attempted with your MX.

This guide covers the full TLS-RPT setup: the DNS record, the report schema defined in RFC 8460, where to land the inbound reports, and how to read each failure result-type.

Why TLS-RPT exists

MTA-STS and DANE are strict TLS policies for SMTP. When a sender's MTA cannot validate your STS policy or your TLSA record, it must refuse to send mail in cleartext and bounce or defer the message. Without telemetry, those failures look identical to a quiet day. RFC 8460 introduces SMTP TLS Reporting (TLS-RPT) so receivers send you a structured daily report of every successful and failed TLS attempt against your domain.

The signal is unique. Bounces tell you a message was rejected; SMTP logs tell you what your MTA did. TLS-RPT is the only channel that reports what remote MTAs saw when they tried to deliver to you, before any DATA command was issued.

Step 1: publish the _smtp._tls TXT record

Add a single TXT record at _smtp._tls.<domain> listing one or more URIs where you want reports delivered. The record advertises TLS-RPT support; receivers that implement RFC 8460 will pick it up and start sending reports within 24 hours.

_smtp._tls.example.com. 3600 IN TXT "v=TLSRPTv1; rua=mailto:tlsrpt@example.com"

Multiple destinations are allowed, comma-separated, and https:// URIs are valid too:

_smtp._tls.example.com. 3600 IN TXT "v=TLSRPTv1; rua=mailto:tlsrpt@example.com,https://tlsrpt.example.com/ingest"

Three things to check after publishing:

  • The owner name is _smtp._tls.<domain> — the same domain you publish MX records on, not your MTA hostname.
  • The value starts with v=TLSRPTv1; exactly. Receivers will silently skip records with any other version string.
  • The rua= destination is reachable. A bounced report destination means no future reports get delivered and the receiver may stop trying.

Validate with dig:

dig +short TXT _smtp._tls.example.com

If you operate multiple sending domains, publish a record under each. Reports cover one policy-domain at a time and cannot be redirected via CNAME aliasing.

Step 2: receive the aggregate reports

Each reporting domain (Google, Microsoft, Yahoo, Comcast, and a long tail of others) sends one report per UTC day, per policy-domain. The payload is a gzipped JSON document attached to an email or POSTed to your HTTPS endpoint. The filename follows RFC 8460 §5.1:

google.com!example.com!1715990400!1716076800.json.gz

That is <reporter>!<policy-domain>!<start-unix>!<end-unix>.json.gz. Reports arrive with Content-Type: application/tlsrpt+gzip and the message subject begins with Report Domain: <policy-domain>.

You have two viable ingestion paths.

Option A: dedicated mailbox

Provision a real IMAP mailbox at tlsrpt@example.com and have a small worker poll it. This is the path most teams start on because it requires no DNS-fronted public endpoint. A working ingest loop looks like:

# fetch, gunzip, and pretty-print one report
mkdir -p /var/lib/tlsrpt/raw
imap-fetch tlsrpt@example.com --unread \
  --save /var/lib/tlsrpt/raw \
  --mark-read

for f in /var/lib/tlsrpt/raw/*.json.gz; do
  gunzip -c "$f" | jq '.' > "${f%.gz}.json"
done

Then load each JSON file into a table keyed by (policy-domain, date-range.start-datetime, contact-info) so duplicate reports from the same window do not double-count.

Option B: HTTPS webhook receiver

If you already run a webhook stack, expose https://tlsrpt.example.com/ingest and accept a POST of application/tlsrpt+gzip. RFC 8460 §3 requires you to respond 201 Created on success and to authenticate the request by checking the Subject header — there is no signature, so treat any payload as untrusted until you have parsed and validated the JSON schema.

A minimal receiver in any language reads the body, gunzips, validates organization-name and report-id, and writes a row per policy block.

The report schema

A well-formed report looks like this. The fields below are exactly what RFC 8460 §4.4 specifies — no extras.

{
  "organization-name": "Google Inc.",
  "date-range": {
    "start-datetime": "2026-05-18T00:00:00Z",
    "end-datetime":   "2026-05-19T00:00:00Z"
  },
  "contact-info": "smtp-tls-reporting@google.com",
  "report-id": "2026-05-19T00:00:00Z_example.com",
  "policies": [
    {
      "policy": {
        "policy-type": "sts",
        "policy-string": [
          "version: STSv1",
          "mode: enforce",
          "mx: mx1.example.com",
          "mx: mx2.example.com",
          "max_age: 604800"
        ],
        "policy-domain": "example.com",
        "mx-host": ["mx1.example.com", "mx2.example.com"]
      },
      "summary": {
        "total-successful-session-count": 8423,
        "total-failure-session-count": 17
      },
      "failure-details": [
        {
          "result-type": "certificate-expired",
          "sending-mta-ip": "209.85.220.41",
          "receiving-mx-hostname": "mx1.example.com",
          "receiving-mx-helo": "mx1.example.com",
          "receiving-ip": "203.0.113.25",
          "failed-session-count": 12,
          "additional-information": "https://reports.google.com/tlsrpt/abc123",
          "failure-reason-code": ""
        },
        {
          "result-type": "starttls-not-supported",
          "sending-mta-ip": "209.85.220.73",
          "receiving-mx-hostname": "mx2.example.com",
          "failed-session-count": 5
        }
      ]
    }
  ]
}

The shape worth remembering:

  • policies[] is an array — a single report can cover both your STS and TLSA policies.
  • policy.policy-type is sts, tlsa, or the catch-all no-policy-found.
  • summary.total-successful-session-count and total-failure-session-count are session counts, not message counts. A retried delivery counts each attempt.
  • failure-details[] is omitted when the failure count is zero. Treat its absence as good news.
  • sending-mta-ip is the IP that opened the SMTP connection — usually the remote sender, not yours.

Parsing a report

Once the file is on disk, a five-line parser is enough to start:

gunzip -c google.com\!example.com\!1715990400\!1716076800.json.gz | jq '
  .policies[] |
  {
    domain: .policy."policy-domain",
    type:   .policy."policy-type",
    ok:     .summary."total-successful-session-count",
    fail:   .summary."total-failure-session-count",
    reasons: (.["failure-details"] // [] | map({(.["result-type"]): .["failed-session-count"]}) | add)
  }'

Run that across a week of reports and you get a per-policy success rate and a histogram of failure types. Anything above a 0.5% failure rate on an enforce-mode STS policy deserves investigation the same day.

Common failure result-types and what to do

RFC 8460 §4.3 enumerates the exact result-type values. The ones you will actually see, in rough order of frequency:

  • starttls-not-supported — the sender opened a session but your MX did not advertise STARTTLS. Almost always a misconfigured backup MX or a load balancer terminating before TLS. Inspect every MX hostname listed in your policy.
  • certificate-expired — your MX presented an expired leaf cert. Auto-renew with ACME and alert if notAfter is under 14 days.
  • certificate-not-trusted — the chain does not validate against the public roots. Usually a missing intermediate or a self-signed cert on a backup MX. Serve the full chain.
  • certificate-host-mismatch — the cert's SAN list does not include the MX hostname the sender connected to. Reissue with the exact mx1.example.com SAN rather than the apex domain.
  • validation-failure — TLSA record mismatch for DANE policies. Either the TLSA hash drifted after a cert rotation or the resolver is not DNSSEC-validating. Rotate the TLSA record alongside the cert, not after.
  • tlsa-invalid — your TLSA RR is syntactically wrong or points to a non-existent cert. Re-derive with openssl x509 -pubkey and republish.
  • dnssec-invalid — the sender's resolver got a SERVFAIL on your TLSA lookup. Check that your authoritative nameservers are serving DNSSEC for the MX zone.
  • sts-policy-fetch-error — the remote MTA could not fetch https://mta-sts.example.com/.well-known/mta-sts.txt. The HTTPS endpoint is down, the cert is expired, or the mta-sts host is missing.
  • sts-policy-invalid — the policy file fetched but did not parse. Look for stray BOMs, CRLF mixing, or the wrong version: line.
  • sts-webpki-invalid — the cert on mta-sts.example.com is not trusted by web PKI. Use a publicly trusted cert there, not the same cert as your MX.

A clean week looks like nothing but total-successful-session-count and empty failure-details arrays. Once you have that baseline, page on any new result-type that appears, on any sustained increase in failed sessions over a rolling 24-hour window, and on the simple absence of reports from a domain that used to send them daily.

TLS-RPT does not change how mail is delivered — it only tells you what happened. Pair it with an enforce-mode MTA-STS policy so the failures it reports are also the failures it prevents.

That is the entire TLS-RPT setup loop: one TXT record, a JSON ingest, and a dashboard keyed on result-type. The hard part is acting on what the reports say before a sender silently routes around your domain.

Tagssmtptlsmta-ststls-rpt