/.well-known/mta-sts.txt`, and the accompanying TLS-RPT * (`_smtp._tls`) reporting record. */ class MtaStsChecker { /** @return array MTA-STS / TLS-RPT report for the given domain. */ public function check(string $domain): array { $report = [ 'domain' => $domain, 'supported' => false, // TXT + fetchable, parseable policy 'enforced' => false, // policy mode is "enforce" 'errors' => [], 'warnings' => [], 'txt' => null, // the _mta-sts TXT record contents 'txt_id' => null, 'policy_url'=> 'https://mta-sts.' . $domain . '/.well-known/mta-sts.txt', 'policy_raw'=> null, // raw fetched policy body 'policy' => null, // parsed: version, mode, mx[], max_age 'http' => null, // http status / content-type of the fetch 'tlsrpt' => null, // the _smtp._tls TXT record contents ]; // 1) The MTA-STS DNS record lives at _mta-sts. and signals a policy. $this->checkTxtRecord($domain, $report); // 2) The policy itself is served over HTTPS from the mta-sts. subdomain. $this->fetchPolicy($report); // 3) TLS-RPT is optional but strongly recommended alongside MTA-STS. $this->checkTlsRpt($domain, $report); $mode = $report['policy']['mode'] ?? null; $report['supported'] = ($report['txt'] !== null && $report['policy'] !== null); $report['enforced'] = ($report['supported'] && $mode === 'enforce'); return $report; } /** Looks up and validates the _mta-sts. TXT record. */ private function checkTxtRecord(string $domain, array &$report): void { $host = '_mta-sts.' . $domain; $records = @dns_get_record($host, DNS_TXT) ?: []; $sts = []; foreach ($records as $rec) { $txt = $rec['txt'] ?? implode('', $rec['entries'] ?? []); if (stripos($txt, 'v=STSv1') !== false) { $sts[] = $txt; } } if (!$sts) { $report['errors'][] = 'No "v=STSv1" TXT record found at ' . $host . '.'; return; } if (count($sts) > 1) { $report['warnings'][] = 'Multiple STS TXT records found at ' . $host . ' — RFC 8461 requires exactly one.'; } $txt = $sts[0]; $report['txt'] = $txt; // Parse the semicolon-separated key=value fields; "id" is mandatory. $fields = []; foreach (explode(';', $txt) as $pair) { if (str_contains($pair, '=')) { [$k, $v] = explode('=', $pair, 2); $fields[strtolower(trim($k))] = trim($v); } } if (($fields['v'] ?? '') !== 'STSv1') { $report['warnings'][] = 'TXT record does not start with "v=STSv1".'; } if (empty($fields['id'])) { $report['errors'][] = 'TXT record is missing the required "id" field.'; } elseif (!preg_match('/^[A-Za-z0-9]{1,32}$/', $fields['id'])) { $report['warnings'][] = 'The "id" value should be 1–32 alphanumeric characters.'; } $report['txt_id'] = $fields['id'] ?? null; } /** Fetches the HTTPS policy file and parses its key/value directives. */ private function fetchPolicy(array &$report): void { $url = $report['policy_url']; // RFC 8461: the policy MUST be served over HTTPS with a valid certificate, // and redirects MUST NOT be followed. We verify the chain explicitly. $ctx = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => POLICY_TIMEOUT, 'follow_location'=> 0, 'ignore_errors' => true, // so we still see 4xx/5xx bodies + headers 'header' => "User-Agent: mta-sts-check (tool.medowar.de)\r\n", ], 'ssl' => [ 'verify_peer' => true, 'verify_peer_name' => true, 'SNI_enabled' => true, ], ]); $body = @file_get_contents($url, false, $ctx, 0, POLICY_MAX_BYTES); // $http_response_header is populated by the HTTP wrapper on any response. $status = null; $ctype = null; foreach ($http_response_header ?? [] as $h) { if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) { $status = (int) $m[1]; // last one wins (in case of intermediates) } elseif (stripos($h, 'Content-Type:') === 0) { $ctype = trim(substr($h, strlen('Content-Type:'))); } } $report['http'] = ['status' => $status, 'content_type' => $ctype]; if ($body === false) { $err = error_get_last()['message'] ?? ''; $err = trim(preg_replace('/\s+/', ' ', preg_replace('#^file_get_contents\([^)]*\):\s*#', '', $err))); $report['errors'][] = 'Could not fetch the policy over HTTPS' . ($err !== '' ? ': ' . $err : '. The mta-sts host or its certificate may be misconfigured.'); return; } if ($status !== null && $status !== 200) { $report['errors'][] = 'Policy fetch returned HTTP ' . $status . ' (expected 200).'; return; } if ($ctype !== null && stripos($ctype, 'text/plain') === false) { $report['warnings'][] = 'Policy Content-Type is "' . $ctype . '"; RFC 8461 requires "text/plain".'; } $report['policy_raw'] = $body; $this->parsePolicy($body, $report); } /** Parses the mta-sts.txt body into version/mode/mx[]/max_age and validates it. */ private function parsePolicy(string $body, array &$report): void { $policy = ['version' => null, 'mode' => null, 'mx' => [], 'max_age' => null]; foreach (preg_split('/\r\n|\r|\n/', $body) as $line) { $line = trim($line); if ($line === '' || !str_contains($line, ':')) { continue; } [$key, $val] = explode(':', $line, 2); $key = strtolower(trim($key)); $val = trim($val); switch ($key) { case 'version': $policy['version'] = $val; break; case 'mode': $policy['mode'] = strtolower($val); break; case 'mx': if ($val !== '') { $policy['mx'][] = $val; } break; case 'max_age': $policy['max_age'] = (int) $val; break; } } if ($policy['version'] !== 'STSv1') { $report['errors'][] = 'Policy "version" is not "STSv1".'; } if (!in_array($policy['mode'], ['enforce', 'testing', 'none'], true)) { $report['errors'][] = 'Policy "mode" is missing or invalid (expected enforce, testing or none).'; } if ($policy['mode'] !== 'none' && empty($policy['mx'])) { $report['errors'][] = 'Policy declares no "mx" host patterns.'; } if ($policy['max_age'] === null) { $report['errors'][] = 'Policy is missing the required "max_age" field.'; } elseif ($policy['max_age'] < 86400) { $report['warnings'][] = 'A "max_age" below 86400 (1 day) is unusually short; long-lived caching is the point of MTA-STS.'; } elseif ($policy['max_age'] > 31557600) { $report['warnings'][] = 'A "max_age" above 31557600 (1 year) exceeds the RFC 8461 recommended maximum.'; } // Cross-check: the TXT id should change whenever the policy changes, but // a mode of "testing" means failures are reported, not enforced. if ($policy['mode'] === 'testing') { $report['warnings'][] = 'Policy mode is "testing" — TLS failures are reported but mail is still delivered.'; } if ($policy['mode'] === 'none') { $report['warnings'][] = 'Policy mode is "none" — this actively signals that any previous MTA-STS policy is withdrawn.'; } $report['policy'] = $policy; } /** Looks up the optional TLS-RPT (_smtp._tls.) reporting record. */ private function checkTlsRpt(string $domain, array &$report): void { $host = '_smtp._tls.' . $domain; $records = @dns_get_record($host, DNS_TXT) ?: []; foreach ($records as $rec) { $txt = $rec['txt'] ?? implode('', $rec['entries'] ?? []); if (stripos($txt, 'v=TLSRPTv1') !== false) { $report['tlsrpt'] = $txt; return; } } $report['warnings'][] = 'No TLS-RPT (v=TLSRPTv1) record at ' . $host . ' — you would not receive reports about TLS delivery failures.'; } } $domain = trim((string) ($_GET['domain'] ?? '')); $report = null; $inputError = null; if ($domain !== '') { // Accept a bare domain, an email address, or a pasted URL — reduce to the domain. $domain = preg_replace('#^\w+://#', '', $domain); $domain = explode('/', $domain)[0]; if (str_contains($domain, '@')) { $domain = substr($domain, strrpos($domain, '@') + 1); } $domain = strtolower(trim($domain, ". \t")); if (str_contains($domain, ':')) { $domain = explode(':', $domain)[0]; } if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $domain)) { $inputError = 'Please enter a valid domain (e.g. example.com).'; } else { $report = (new MtaStsChecker())->check($domain); } } function fmtMaxAge(?int $secs): string { if ($secs === null) { return '—'; } $days = $secs / 86400; if ($days >= 1) { return $secs . ' s (' . rtrim(rtrim(number_format($days, 1), '0'), '.') . ' days)'; } return $secs . ' s'; } ?> MTA-STS Checker

📮 MTA-STS Checker

Checks whether a domain supports MTA-STS (SMTP MTA Strict Transport Security, RFC 8461). It reads the _mta-sts DNS record, fetches the HTTPS policy at mta-sts.<domain>/.well-known/mta-sts.txt, and looks for a TLS-RPT reporting record. Nothing is sent — only public DNS and the policy file are read.
✅ MTA-STS is supported and set to enforce for .
⚠️ MTA-STS is published (mode: ) but not enforcing.
does not have a working MTA-STS policy.
%s
%s
%s
', $cls, $ic, htmlspecialchars($label), htmlspecialchars($detail) ); }; $renderCheck($report['txt'] !== null, 'DNS TXT record', $report['txt'] !== null ? '_mta-sts record found (id=' . ($report['txt_id'] ?? '?') . ')' : 'No _mta-sts TXT record'); $renderCheck($report['policy'] !== null, 'HTTPS policy', $report['policy'] !== null ? 'Fetched and parsed over HTTPS' : 'Policy missing or invalid'); $mode = $report['policy']['mode'] ?? null; $renderCheck($mode === 'enforce', 'Enforcing', $mode === 'enforce' ? 'mode: enforce' : ('mode: ' . ($mode ?? '—')), false); $renderCheck($report['tlsrpt'] !== null, 'TLS-RPT reporting', $report['tlsrpt'] !== null ? 'Reporting record present' : 'No _smtp._tls record', false); ?>
⚠️

DNS record

Host_mta-sts.
TXT
Policy id

HTTPS policy

URL
HTTP status
Content-Type
Version
Mode 'g', 'testing' => 'o', 'none' => 'r'][$p['mode']] ?? 'r'; echo '' . htmlspecialchars($p['mode'] ?? '—') . ''; ?>
MX patterns
max_age

TLS-RPT

Host_smtp._tls.
TXT
Raw policy file