SPF・DKIM・DMARCで「誰が送ったメールか」は検証できるようになりました。ただ、これらが守っているのは送信者の身元であって、メールが運ばれる経路は別の問題です。サーバ間のSMTPは今でも日和見TLS(opportunistic TLS)が基本で、STARTTLSが使えなければ平文で送る、という動きが既定のままです。
ここを埋めるのがMTA-STS(RFC 8461)とTLS-RPT(RFC 8460)です。この記事では、RFCの要件を一次資料で確認したうえで、主要30ドメインの導入状況を実際に調べ、設定を検査するスクリプトを書きます。例によって、実際に手を動かして踏んだ落とし穴も残しておきます。
MTA-STSが解決する問題
STARTTLSはネゴシエーションで始まります。送信側MTAが「TLSを使えるか」を確認し、受信側が対応していれば暗号化に切り替わる。この仕組みの弱点は、経路上に能動的な攻撃者がいる場合、STARTTLSのケイパビリティ広告を握りつぶされると通信が平文にフォールバックしてしまうことです(いわゆるSTARTTLS剥がし)。DNS応答の改ざんで偽のMXへ誘導される可能性もあります。
MTA-STSは、受信側のドメインが「うちのMXへはTLS必須で、証明書はこのホスト名で検証すること」というポリシーを公開し、対応した送信側MTAがそれを守る仕組みです。方向を取り違えやすいのですが、ポリシーを公開するのは受信側で、守られるのは自分のドメイン宛てに届くメールです。
同じ問題をDNSSEC前提で解くのがDANE(TLSA)ですが、RFC 8461の冒頭にあるとおり、MTA-STSは認証局(CA)ベースの証明書検証を使うことでDNSSECなしで運用できる設計になっています。DNSSECの導入が進んでいない環境でも使えることが存在意義です。
TLS-RPT(RFC 8460)はその観測手段です。送信側MTAが「あなたのドメインへの配送でTLSに失敗した」というレポートを日次で送ってくれる仕組みで、_smtp._tlsのTXTレコードに受け取り先を書いておきます。DMARCレポート(rua)のTLS版と考えると分かりやすいです。
主要30ドメインの導入状況を実測する
仕組みの前に、まず現実を見ます。2026年8月14日に、DNS over HTTPS(dns.google)で_mta-sts.<ドメイン>と_smtp._tls.<ドメイン>のTXTレコードを30ドメインについて確認しました。
結果は、MTA-STSを設定していたのは30ドメイン中2件だけでした。TLS-RPTも同じ2件です。
- 設定あり(2件):google.com、microsoft.com(いずれもMTA-STSとTLS-RPTの両方)
- 海外系10ドメイン中2件:apple.com、amazon.com、cloudflare.com、github.com、salesforce.com、zoom.us、slack.com、okta.comは未設定
- 日本企業系20ドメイン中0件:yahoo.co.jp、rakuten.co.jp、nifty.com、biglobe.ne.jp、ocn.ne.jp、so-net.ne.jp、sakura.ad.jp、cybozu.com、freee.co.jp、moneyforward.com、mercari.com、line.me、ntt.com、softbank.jp、kddi.com、jal.co.jp、ana.co.jp、mufg.jp、smbc.co.jp、japanpost.jp すべて未設定
通信キャリアも銀行も航空会社も含めて、日本企業系はゼロでした。DNS応答の内訳はNXDOMAINが21件、NOERRORだが回答なしが7件です。この2種類の「無い」の違いが、後述するスクリプトのバグにつながります。
導入していた2件のポリシーが対照的だったので載せておきます。
# google.com
version: STSv1
mode: enforce
mx: smtp.google.com
mx: aspmx.l.google.com
mx: *.aspmx.l.google.com
max_age: 86400
# microsoft.com
version: STSv1
mode: enforce
mx: *.mail.protection.outlook.com
max_age: 604800
Googleはmxを3行並べてmax_ageは1日、Microsoftはワイルドカード1行でmax_ageは1週間。どちらもmodeはenforceです。
導入率が低いから意味がない、とはなりません。MTA-STSは送信側が対応していて初めて効く仕組みなので、自ドメインでポリシーを公開する価値は「対応済みの送信側からどれだけメールを受けるか」で決まります。ここは推測ではなく、導入後にTLS-RPTのレポートで確かめるのが確実です。
仕組みと要件(RFC 8461で確認)
1. TXTレコード
_mta-sts.<ドメイン>にTXTレコードを置きます。フィールドは2つだけです。
_mta-sts.example.jp. IN TXT "v=STSv1; id=20260814T000000;"
v(必須)… 現在はSTSv1のみid(必須)… ポリシーの版を識別する短い文字列。ポリシーファイルを更新したら必ずこの値も変えます。送信側はidの変化でポリシーの更新を検知します。値の大小や順序に意味はありません
2. ポリシーファイル
ポリシー本体はDNSではなくHTTPSで配ります。場所は固定で、https://mta-sts.<ドメイン>/.well-known/mta-sts.txtです。ホスト名は「ポリシードメインの前にmta-stsを付ける」と決まっています。
version(必須)… STSv1mode(必須)… enforce/testing/noneのいずれか。testingは違反をレポートするだけで配送は継続、enforceは違反時に配送しませんmax_age(必須)… ポリシーをキャッシュしてよい秒数。上限は31557600(約1年)で、RFCは「数週間以上」を想定していますmx(modeがnone以外では1件以上必須)… 許可するMXのホスト名。ワイルドカードは左端のラベルにだけ使えます。国際化ドメインはPunycodeのA-labelで書きます
media typeはtext/plainで配信します。RFC 8461 3.2は、送信側がこれを確認すべき(SHOULD)としています。Webサーバが利用者に任意パスのコンテンツ設置を許している環境で、HTMLや画像をポリシーとして誤読しないための防御です。
3. 取得時のルール(見落としやすい)
ポリシーホストの証明書はmta-sts.<ドメイン>というホスト名(DNS-ID)に対して有効で、送信側が信頼するルートCAにチェーンし、期限内である必要があります。自己署名は使えません。
さらにRFC 8461 3.3には見落としやすい規定が2つあります。HTTP 3xxリダイレクトを追跡してはならず(MUST NOT)、HTTPキャッシュも使ってはならない(MUST NOT)。「とりあえずwwwへリダイレクトで逃がす」構成は仕様違反になります。
設定を検査するスクリプト
ここまでの要件を機械的に確認するスクリプトを書きました。Python標準ライブラリだけで動きます。DNSはDoH(dns.google)で引くため、社内リゾルバの設定に影響されません。
#!/usr/bin/env python3
"""mtastscheck.py - MTA-STS / TLS-RPT の設定を確認する
使い方:
./mtastscheck.py example.jp [example2.jp ...]
DNSはDoH(dns.google)で引くため、ローカルのリゾルバ設定に影響されません。
"""
import sys, json, ssl, urllib.request, urllib.error
TIMEOUT = 10
UA = 'mtastscheck/1.0'
def doh(name, rtype='TXT'):
"""DNS over HTTPS で問い合わせる。戻り値は (status, [レコード文字列])"""
url = 'https://dns.google/resolve?name=%s&type=%s' % (name, rtype)
req = urllib.request.Request(url, headers={'User-Agent': UA})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
j = json.load(r)
except Exception as e:
return ('ERR:%s' % type(e).__name__, [])
# NXDOMAIN(3) でも NOERROR(0) でも Answer が無いことがある。
# j['Answer'] と直接書くと KeyError で落ちる
answers = [a.get('data', '') for a in j.get('Answer', [])]
return (j.get('Status'), answers)
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""RFC 8461 3.3: HTTP 3xx redirects MUST NOT be followed"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
_opener = urllib.request.build_opener(_NoRedirect)
def fetch_policy(domain):
"""ポリシーを取得する。戻り値は (HTTPステータス, Content-Type, 本文, エラー)
注意: urllib は既定で 3xx を黙って追跡するが、RFC 8461 3.3 は
ポリシー取得でのリダイレクト追跡を禁止している(MUST NOT)。
ここでは追跡せず、3xx はエラーとして報告する。
"""
url = 'https://mta-sts.%s/.well-known/mta-sts.txt' % domain
req = urllib.request.Request(url, headers={'User-Agent': UA})
try:
with _opener.open(req, timeout=TIMEOUT) as r:
return (r.status, r.headers.get('Content-Type', ''), r.read().decode('utf-8', 'replace'), None)
except urllib.error.HTTPError as e:
if 300 <= e.code < 400:
return (e.code, '', '', 'HTTP %d リダイレクト。RFC 8461 3.3 は追跡を禁止(MUST NOT)' % e.code)
return (e.code, e.headers.get('Content-Type', ''), '', 'HTTP %d' % e.code)
except ssl.SSLCertVerificationError as e:
# 証明書は mta-sts.<ドメイン> に対して有効でなければならない(RFC 8461 3.3)
return (None, '', '', '証明書検証エラー: %s' % e.verify_message)
except Exception as e:
return (None, '', '', '%s: %s' % (type(e).__name__, e))
def parse_policy(body):
"""ポリシーを辞書にする。mx は複数行あるのでリストで保持する"""
policy = {'mx': []}
for line in body.splitlines():
line = line.strip()
if not line:
continue
key, sep, value = line.partition(':')
if not sep:
continue
key, value = key.strip().lower(), value.strip()
if key == 'mx':
policy['mx'].append(value)
else:
policy[key] = value
return policy
def check(domain):
print('=' * 56)
print(domain)
print('=' * 56)
# 1) _mta-sts TXT レコード
status, recs = doh('_mta-sts.' + domain)
sts = [r for r in recs if 'STSv1' in r]
if not sts:
print(' MTA-STS TXT : なし(DNS status=%s)' % status)
else:
print(' MTA-STS TXT : %s' % sts[0])
fields = dict(
(p.split('=', 1)[0].strip().lower(), p.split('=', 1)[1].strip())
for p in sts[0].split(';') if '=' in p
)
if 'id' not in fields:
print(' [警告] id フィールドが無い。RFC 8461 3.1 で必須')
else:
print(' id = %s(ポリシー更新時はこの値も変える必要がある)' % fields['id'])
if len(sts) > 1:
print(' [警告] STSv1 のTXTが %d 件ある。1件にすること' % len(sts))
# 2) TLS-RPT
status, recs = doh('_smtp._tls.' + domain)
rpt = [r for r in recs if 'TLSRPTv1' in r]
print(' TLS-RPT : %s' % (rpt[0] if rpt else 'なし(DNS status=%s)' % status))
if not sts:
print(' → MTA-STS 未設定のためポリシー取得はスキップ')
return
# 3) ポリシー取得
code, ctype, body, err = fetch_policy(domain)
if err:
print(' ポリシー取得: 失敗 - %s' % err)
return
print(' ポリシー取得: HTTP %s / Content-Type: %s' % (code, ctype))
# media type は text/plain であることを確認する(RFC 8461 3.2)
# charset 等のパラメータが付くので、前方一致で見る
media = ctype.split(';')[0].strip().lower()
if media != 'text/plain':
print(' [警告] media type が text/plain ではない: %s' % media)
p = parse_policy(body)
print(' version : %s' % p.get('version', '(なし)'))
print(' mode : %s' % p.get('mode', '(なし)'))
print(' max_age : %s' % p.get('max_age', '(なし)'))
print(' mx : %d件' % len(p['mx']))
for m in p['mx']:
print(' %s' % m)
# 必須フィールドの検査(RFC 8461 3.2)
for k in ('version', 'mode', 'max_age'):
if k not in p:
print(' [警告] 必須フィールド %s が無い' % k)
if p.get('mode') != 'none' and not p['mx']:
print(' [警告] mode が none 以外なのに mx が1件も無い')
try:
if int(p.get('max_age', 0)) < 86400:
print(' [注意] max_age が 86400 未満。運用が安定したら伸ばす')
except ValueError:
print(' [警告] max_age が数値ではない')
if p.get('mode') == 'testing':
print(' [情報] mode=testing。ポリシー違反でも配送は継続される')
if p.get('mode') == 'none':
print(' [情報] mode=none。MTA-STS を無効化する意味になる')
def main():
if len(sys.argv) < 2:
print(__doc__)
return 0
for d in sys.argv[1:]:
check(d)
return 0
if __name__ == '__main__':
sys.exit(main())
google.comと未設定ドメイン(yahoo.co.jp)に対する出力です。DNS応答とポリシーは2026年8月14日に実際に取得したものを、検証のためスクリプトに通しています。
========================================================
google.com
========================================================
MTA-STS TXT : v=STSv1; id=20210803T010101;
id = 20210803T010101(ポリシー更新時はこの値も変える必要がある)
TLS-RPT : v=TLSRPTv1;rua=mailto:[email protected]
ポリシー取得: HTTP 200 / Content-Type: text/plain
version : STSv1
mode : enforce
max_age : 86400
mx : 3件
smtp.google.com
aspmx.l.google.com
*.aspmx.l.google.com
========================================================
yahoo.co.jp
========================================================
MTA-STS TXT : なし(DNS status=3)
TLS-RPT : なし(DNS status=3)
→ MTA-STS 未設定のためポリシー取得はスキップ
実データで踏んだ落とし穴
今回も、第1版を実データに通して直した箇所が3つ、仕様を読み直して直した箇所が1つあります。
1. mxを辞書で持つと黙って消える
ポリシーはkey/value形式なので、素朴に辞書へ詰めたくなります。
policy = {}
for line in body.splitlines():
k, _, v = line.partition(':')
policy[k.strip()] = v.strip()
google.comの実ポリシーをこれに通すと、mxが3行あるのに最後の1行しか残りません。
実際のmx行数 : 3
辞書に残ったmx: '*.aspmx.l.google.com'
smtp.google.comとaspmx.l.google.comが消えました。例外は出ません。この上にMX照合を実装すると、正当なMXを不一致と誤判定します。mxだけはリストで持つ必要があります。
2. DoHの応答にAnswerキーが無い
j['Answer']と書いていた第1版は、未設定ドメインを調べた瞬間にKeyErrorで落ちました。NXDOMAIN(Status 3)のときだけでなく、NOERROR(Status 0)でも回答が無ければAnswerキー自体が存在しません。
nxdomain(Status 3) -> KeyError: 'Answer'
noerror-noanswer(Status 0) -> KeyError: 'Answer'
今回の調査では30ドメイン中28件がこのどちらかでした。「未設定のドメインを調べる」というこのツールの主目的で必ず踏むバグです。j.get('Answer', [])にします。
3. Content-Typeを完全一致で比べない
text/plainの確認をctype == 'text/plain'と書くと、パラメータが付いた瞬間に偽になります。
'text/plain; charset=utf-8' -> 完全一致だと False
'TEXT/PLAIN; charset=UTF-8' -> 完全一致だと False
RFC 8461自身が「Content-Typeの追加パラメータは無視する」と明記しているので、セミコロンで切って小文字化してから比較します。
4. urllibは黙ってリダイレクトを追跡する(仕様の読み直しで発見)
これは実行ではなくRFCの読み直しで見つけました。前述のとおりポリシー取得では3xxを追跡してはいけませんが、urllibの既定は追跡です。素朴に書いたクライアントは、リダイレクト先から取ってきたポリシーを「正しく取得できた」と報告してしまいます。HTTPRedirectHandlerを差し替えて、3xxはエラーとして報告するようにしました。
なお「CRLF区切りだから改行の扱いで壊れるはず」と予想して検証もしましたが、これは外れました。strip()が\rを吸収するため実害はありません。事前の予想が外れた例として記録しておきます。
自ドメインに導入する手順
実務では次の順番が安全です。
- (1) TLS-RPTを先に置く。レポートを受け取るだけなので配送への影響はゼロです。
_smtp._tlsにTXTでv=TLSRPTv1; rua=mailto:[email protected]のように受信先を書きます。自ドメイン宛の配送でTLSがどれだけ失敗しているかが先に見えるようになります - (2) ポリシーホストを用意する。
mta-sts.<ドメイン>のHTTPSで/.well-known/mta-sts.txtをtext/plainで返せるようにします。中身は静的ファイル1個なので、サブドメインを1つ用意して置くだけです。証明書がこのホスト名で有効であること、リダイレクトを挟まないことに注意します - (3) mode: testingで公開する。max_ageは最初は短め(86400=1日など)にして、TLS-RPTのレポートで正当なメールが落ちていないことを確認します
- (4) enforceに上げ、max_ageを伸ばす。RFCの想定は数週間以上です。以後、MXを変更するときはポリシーファイルの更新とTXTレコードのid変更を必ずセットで行います。max_ageが長いほど、古いポリシーが送信側にキャッシュされている期間も長くなります
まとめ
- MTA-STSは受信側が公開し送信側が守る、SMTP配送経路のTLS強制の仕組み。SPF・DKIM・DMARCとは守る対象が違う
- 主要30ドメインの実測では導入は2件(google.com、microsoft.com)。日本企業系20ドメインはゼロだった(2026年8月14日時点)
- ポリシー取得には「証明書はmta-sts.ホスト名で有効」「3xx追跡禁止」「HTTPキャッシュ禁止」という見落としやすい規定がある
- 実装の落とし穴は、mxの複数行・DoHのAnswer欠落・Content-Typeのパラメータ・urllibのリダイレクト既定、の4つだった
- 導入はTLS-RPT→testing→enforceの順で。MX変更時はidの変更を忘れない
参考:RFC 8461(SMTP MTA Strict Transport Security)Section 3.1〜3.3、RFC 8460(SMTP TLS Reporting)
関連記事
- SPFのDNSルックアップ10回制限とPermErrorの原因・対処法 … 送信ドメイン認証側の落とし穴
- DMARC集計レポート(RUA)を自分で集計する … レポート運用の実装はこちら
- BIMIの設定手順 … DMARC enforcement後の次の一手
