I started this post with a dig and felt good about it:
$ dig +short TXT _mta-sts.mustafaerbay.com.tr
"v=STSv1; id=202605081438"
$ dig +short TXT _smtp._tls.mustafaerbay.com.tr
"v=TLSRPTv1; rua=mailto:mustafa@mustafaerbay.com.tr"
The policy file was in place too: mode: enforce, max_age: 604800. I had set it up in early May and had believed ever since that "no mail reaches this domain without TLS." Then I looked at the rua address, which is my own mailbox. Google had sent 25 TLS reports to that mailbox since May; 22 for this domain, 3 for another domain of mine whose policy lives on the same server. Twenty-four of the twenty-five carried the policy type no-policy-found. Exactly one, dated 23 May, said sts and echoed my policy lines back. The two "no policy" reports from the first half of May arrived before the setup was finished, and I understand those; what I did not understand was that all 22 reports for the two domains after 3 June said "could not find a policy."
My thesis: the fragile link in MTA-STS is not the DNS record, it is whether the policy can be fetched over HTTPS; and the RFC tells a sender that fails the fetch to "behave as if there were no policy." A misconfigured enforce policy is therefore identical to having no policy at all. Mail keeps flowing, nothing bounces, nobody shouts. The only thing that shouted for three months, I learned, was a pile of report emails I never opened.
I have written about email identity on this blog before, from the angle of hardening DMARC; that post asked "did this mail really come from me." This one asks "was this mail read on the way to me."
Two pieces of policy, one piece of trust
The arrangement RFC 8461 sets up looks simple. The domain owner publishes two things: a TXT record under _mta-sts.<domain> (v=STSv1; id=...) and a plain-text policy at https://mta-sts.<domain>/.well-known/mta-sts.txt. The policy carries version, mode, one or more mx patterns and a max_age in seconds. When a sending MTA is about to deliver to a domain, it first looks up the TXT record; if the record exists and no current policy is cached, it fetches the file over HTTPS.
The real work starts there. With mode: enforce, the sender does not deliver to any host that matches none of the mx patterns, offers no STARTTLS, or presents a certificate that cannot be validated against the MX name. With testing it delivers anyway but writes the failure into a TLS-RPT report. Certificate validation happens against the MX host's own name; none of my domains that receive mail through mail.sysroot.app need a certificate of their own, the MX server's certificate is enough. Wildcards are limited to a single label: *.example.com matches mail.example.com but neither example.com nor a.b.example.com.
The fetch rules are strict. HTTP 3xx redirects are not followed; the certificate must be valid for the name mta-sts.<domain>, unexpired, and chained to a root the sender trusts; the suggested timeout is one minute and the suggested maximum size is 64 KB. When you change the policy you must also change the id in the TXT record; instead of fetching the file every time, the sender compares the id with a cheap DNS query and refreshes its cache only when it differs. Microsoft's documentation suggests a timestamp such as id=<yyyymmddhh0000>Z for this; mine is a similar stamp, down to the minute.
The line that ought to be painted red in that diagram is the arrow from D to N. The RFC's sentence is unambiguous: if a valid TXT record is found but no policy can be fetched over HTTPS for any reason, and no unexpired policy exists in the cache, the sender MUST continue delivery as though the domain had not implemented MTA-STS. That is a deliberate design choice; otherwise a five-minute outage of your policy server would mean the world could not email you. But the same choice silences configuration mistakes. The cache lives for max_age; my 604800 seconds, seven days, must have run out seven days after Google's last successful fetch, so by 10 June at the latest. Google never saw me after that.
Anatomy of a three-month silence
I reconstructed the timeline from nginx logs, TLS-RPT reports and the server's journalctl output; none of it is from memory.
The policy file's modification time is 8 May 14:42 and the id in the TXT record carries the same minute. In its report dated 23 May, Google saw the policy as sts and echoed the lines back verbatim: mode: enforce, mx: mail.sysroot.app. Fine so far.
On 3 June I put a "Cloudflare only" lock on the blog's origin server. A separate nftables table named inet cf_lock accepts new connections to ports 80 and 443 only from Cloudflare's published IP ranges and drops everything else. The goal was to stop people walking around Cloudflare to reach the site it fronts, and for that purpose it is still the right tool. The problem: the same server also hosts the mta-sts.* virtual hosts for six domains, and those DNS records are unproxied in Cloudflare, pointing straight at the origin IP. Google's policy fetcher is not Cloudflare. Connection attempted, SYN dropped, timeout a minute later, "continue as if there were no policy."
The reports say precisely that. Every report from 19 June onward has policy-type set to no-policy-found, while total-failure-session-count is zero, because mail is delivered without trouble. One or two sessions a day, all successful, none of them under a policy. The nginx access log tells the same story: in the 17 days of logs I have, the only outsider that managed to read /.well-known/mta-sts.txt was a domain scanner that arrived on 10 September at 21:31:09. That timestamp is no coincidence. The server had rebooted at 21:30:50, nginx came up, and cf-lock.service loaded at 21:31:38. The scanner slipped through a 48-second window before the lock was in place. In seventeen days, the only external client that could read my policy was a bot exploiting a reboot gap.
The embarrassing part is where I started the diagnosis. I ssh'd into the server, ran curl https://mta-sts.mustafaerbay.com.tr/..., got a 200 and said "the file is there." Of course it returns 200; when a server connects to its own address the traffic goes over lo, and the lock table has an iif "lo" accept line. Then I tried from my laptop: the connection to 443 timed out after 15 seconds. From a customer's server, Test-NetConnection -Port 443: TcpTestSucceeded : False. From another VPS, nc -z: closed. Four vantage points, three gave the same answer, and the three were right. A curl run from the server itself is not evidence; it is a glance in the mirror.
How do you notice an unfetchable policy?
The only honest answer is TLS-RPT. RFC 8460 asks you to publish v=TLSRPTv1; rua=mailto:... (or an https: endpoint) under _smtp._tls.<domain>; senders then deliver a gzipped JSON report once a day covering 00:00–24:00 UTC. The subject line reads Report Domain: <domain> Submitter: <sender> Report-ID: <id> and the attachment type is application/tlsrpt+gzip. Inside, policy-type takes one of three values: sts, tlsa or no-policy-found. The result-type values in the failure details say what broke: starttls-not-supported, certificate-host-mismatch, certificate-expired, certificate-not-trusted, validation-failure, sts-policy-fetch-error, sts-policy-invalid, sts-webpki-invalid, plus tlsa-invalid, dnssec-invalid and dane-required on the DANE side.
My reports contained no sts-policy-fetch-error at all; it appears Google reports the case "TXT found, file unfetchable" directly as no-policy-found. That is Google's choice; another sender may report the same situation under policy type sts with an sts-policy-fetch-error. So a reading that only hunts for error lines will miss this problem. The field to watch is whether policy-type equals sts, and the alert rule should cover both shapes. You do not even need a mail client to check this in your own mailbox; a ten-line script that pulls and decodes the reports is enough. The core of mine:
import base64, gzip, json, re
t = open("tlsrpt_mails.txt", errors="ignore").read()
blocks = re.findall(r'filename="([^"]+)"\s*Content-Transfer-Encoding: base64\s*\n\n(.*?)\n--', t, re.S)
for fn, b64 in blocks:
j = json.loads(gzip.decompress(base64.b64decode(re.sub(r"\s", "", b64))))
for p in j["policies"]:
s = p["summary"]
print(fn.split("!")[1], j["date-range"]["start-datetime"][:10],
p["policy"]["policy-type"], "ok=", s["total-successful-session-count"],
"fail=", s["total-failure-session-count"])
One output line looks like mustafaerbay.com.tr 2026-08-11 no-policy-found ok= 1 fail= 0. Seeing that line once beats seeing it three months late; but the real job is wiring it into a monitoring rule: alert when policy-type is anything other than sts two days in a row. Make sure the report itself cannot be stopped by TLS either; RFC 8460 tells senders to deliver reports despite TLS failures, unencrypted if necessary, so a report mailbox that refuses unencrypted mail will not receive the report. And verify that the rua address actually lands somewhere; for tlsrpt@sysroot.app I briefly panicked at "mailbox does not exist," then found Postfix's alias map redirecting it to my main mailbox. The fear was justified, the conclusion was not.
The fix: point at the right door, don't loosen the lock
The first reflex might be to add a "this host only" exception to cf_lock; but nftables works at layer 3, it sees no hostnames, only IPs and ports. mustafaerbay.com.tr sits on the same IP. An exception would mean reopening the whole origin. Wrong door.
The right door is to make the mta-sts.<domain> record proxied in Cloudflare. Once the record is behind the orange cloud, clients connect to Cloudflare's edge, the edge reaches the origin from a Cloudflare IP, and the lock already lets that through. The RFC's requirements are met as well: Universal SSL covers the apex and first-level subdomains, and mta-sts is first-level; on the nginx side location = /.well-known/mta-sts.txt returns 200 directly with no redirect in between. Two things to watch. Edge rules such as HTTP-to-HTTPS redirects apply only to port 80; the policy fetcher arrives on 443 anyway. The edge cache stays out of the way because .txt is not on Cloudflare's default list of cached extensions; but if the zone has a "cache everything" Cache Rule or a WAF rule with a JS challenge, exclude the policy host, or the fetcher receives a stale file or an HTML challenge page and, once again, silently concludes "no policy." On the certificate side my life is easy; I renew the mta-sts.* certificates with DNS-01, since the lock would drop Let's Encrypt's HTTP-01 validation as well. As this post goes live the record is still unproxied; the fix sits at the top of my to-do list for right after publishing, and I will be waiting for the next report to say sts.
You are not obliged to keep the policy on the origin at all. mailcow generates and serves the policy itself from the MTA-STS tab of the domain edit screen; it needs a CNAME for mta-sts.<domain> and a certificate valid for that name (setup page). Exchange Online does not host policies on customers' behalf; Microsoft's documentation walks through a setup on Azure Static Web App and says "start with testing, switch to enforce once validated." Wherever you keep it, the rule is the same: the policy file must be reachable from where the senders are. The cheapest way to test that is a single curl from a different network.
The sending side: who reads policies for Postfix?
MTA-STS is a two-way contract; there is publishing, and there is honouring everyone else's. Postfix has no MTA-STS in its core; what it needs is an smtp_tls_policy_maps entry that tells it which TLS level to use for a destination domain. One of the tools that fills that gap is postfix-tlspol: a small service that evaluates DANE/TLSA records and MTA-STS policies together and answers Postfix over a socketmap. mailcow packaged it as a separate container named postfix-tlspol-mailcow in its September 2025 release (the image in my installation is 1.8.23); the relevant settings in my installation's Postfix 3.10.12 are:
smtp_tls_security_level = dane
smtp_dns_support_level = dnssec
smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERY
The order matters: per-domain manual overrides first, then tlspol. (One note: the QUERYwithTLSRPT query instead of QUERY lets tlspol pass extra information for smtp_tls_enforce_sts_mx_patterns on Postfix 3.10 and later; the README recommends it, mailcow's main.cf still uses the older form.) When DANE and MTA-STS collide, DANE wins; RFC 8461 also forbids a sender from letting MTA-STS validation override a failing DANE validation. The service's log for the last ten days also says something about how widespread MTA-STS is in the field. Of the eight domains I sent mail to, exactly one has a policy:
INFO Evaluated policy for "gmail.com": secure match=smtp.google.com:gmail-smtp-in.l.google.com:.gmail-smtp-in.l.google.com servername=hostname (from cache, 18h29m3s remaining)
INFO No policy found for "seraytekstil.com.tr" (cached for 10m0s)
The first line is gmail.com's policy translated into Postfix's language: the secure level, the three mx patterns of the policy in match= (a leading dot means subdomain match in Postfix, and a wider one than the RFC's single-label wildcard; translating *. into . loosens the policy slightly, which is the reason for the QUERYwithTLSRPT note above), and servername=hostname setting SNI to the MX name. Eighteen hours remain because Google's max_age is 86400. The ten-minute "no policy" cache in the second line is tlspol's own choice, so that a domain without a policy does not trigger DNS and HTTPS on every message.
The trace of that decision in the Postfix log hides in a single word. Postfix writes its TLS connection summary in tls_log_summary inside tls_misc.c, and the leading adjective states the certificate's status: Anonymous if the peer presented no certificate, Untrusted if it did but the chain is not trusted, Trusted if the chain is trusted but no name match was required, and Verified if the chain is trusted and the name also matched per policy. Two lines from my own log:
postfix/smtp[3733]: Verified TLS connection established to gmail-smtp-in.l.google.com[142.251.127.27]:25: TLSv1.3 ...
postfix/smtp[14656]: Trusted TLS connection established to aspmx.l.google.com[142.251.127.26]:25: TLSv1.3 ...
Both go to Google, both with the same cipher suite. The difference is that the first destination's domain publishes MTA-STS. The Trusted line is not a problem, but it is not an assurance either; on that connection Postfix did not require the server to be validated by name. Comparing grep -c "Verified TLS" against grep -c "Trusted TLS" in your own logs shows how much of the email ecosystem really talks "knowing whom it connected to." Over my last ten days, domains that produced a secure policy: one in eight.
The big providers do this work for you, and that has consequences. Microsoft writes that every message leaving Exchange Online is validated against the destination domain's MTA-STS policy, that this is "always on" like other core SMTP features and that administrators have nothing to do; it mentions no off switch. If validation fails, the sender receives an NDR with 5.4.8 MX hosts of '{domain}' failed MTA-STS validation or 5.7.5 Remote certificate failed MTA-STS validation. Google Workspace likewise states that mail it sends complies with the destination's policy when that policy is in enforce mode. So if you set MTA-STS to enforce and forget to add your new MX host to the mx line, the world's two largest mail clouds stop sending to you; and you will see it not in your own log but in the other side's NDR.
Checklist before going live
After this mistake lay under my own lock for three months, I rewrote my list. I keep it short; every item is the kind that will save me a day at some point.
- The
_mta-stsand_smtp._tlsTXT records exist, theruaaddress really lands in a mailbox, and that mailbox also accepts mail without TLS. - The policy file returns 200 to
curl -sS --max-time 60from at least two different networks: a laptop, another VPS, ideally a customer's server. A curl from the server itself does not count. - The
mta-sts.<domain>certificate is valid for exactly that name; the response is not a redirect; the body is plain text under 64 KB. - The
mxpatterns match the real MX records one to one; a wildcard covers a single label only. - No origin lock, WAF, bot challenge, geo-block, IP allowlist or "cache everything" rule at layers 3–7 covers the policy host; if one does, the host sits behind a proxy or somewhere else entirely.
-
mode: testingfor the first week; switch toenforceand bump the TXTidonce TLS-RPT showspolicy-typeasstsconsistently. - An alert on TLS-RPT for
policy-type != stsorsts-policy-fetch-error; since senders only report on days with traffic, the "no report" alert needs a threshold of several days. - After a reboot, the firewall is verified to load before nginx (
Before=nginx.servicein the unit file); my 48-second window proved the opposite. - On the sending side, the
Verified/Trustedratio in the Postfix logs is known; if nosecurepolicy is produced for a destination, the reason has been investigated.
Silent security is not security
The designers of MTA-STS said "if you can't fetch it, ignore it" so that an outage of a policy server would not halt the world's email, and they were right. But that choice set up a system where the mechanism's failure is noticed only by people who read the reports. enforce in DNS does not show that the door is locked; it shows only that there is a "locked" sign on the door. If the only person testing the door is pushing it from inside the house, the sign always looks correct.
The lasting lesson here is not specific to MTA-STS. If a security control does not make itself heard when it fails, there is no reason to assume it works. When the place that says the control is "on" and the party that applies the control are different (here, DNS versus Google's fetcher), you have to watch the path between them as well. In the middle of my path stood an nftables table I had put there with my own hands. I opened the reports after three months; open yours next week.
Official Sources
- RFC 8461 — SMTP MTA Strict Transport Security (MTA-STS)
- RFC 8460 — SMTP TLS Reporting
- Google Workspace — About MTA-STS and TLS reporting
- Microsoft Learn — Enhance mail flow with MTA-STS (Exchange Online)
- postfix-tlspol — MTA-STS and DANE resolver / Postfix TLS policy socketmap
- mailcow 2025-09 release notes — the postfix-tlspol-mailcow container
- Postfix source — tls_misc.c, tls_log_summary (Anonymous/Untrusted/Trusted/Verified)
- Cloudflare Docs — Universal SSL coverage












