免責事項
- 当記事内容の一部にはChatGPTが生成した文章に基づく内容やコードが含まれます
- 自分はSPFの専門家ではないので、当記事の正確性は保証しません
- 誤りを見つけた方はご指摘いただけましたら検討いたしますが、複雑な話なので実は誤りを指摘した側の方が誤っていたということのないようにお願いいたします
要約
同一ドメイン(メールサーバー)内からのメールのSPFをpyspfで検証したらsoftfail(あるいはfail)になったが、そもそも同一ドメイン内からのメールはSPFの検証対象外にすべきらしい。
動機
2023年にGoogleの発表により世界中のメールサーバーがDKIM, DMARCやSPFの設定対応に追われたことは、人によっては比較的記憶に新しいことではないかと思うが、せっかくみんなが苦労して対応したのだからSPF, DKIM, DMARCを自分で検証してみようと思った。
現象
ChatGPTに書かせたコードに、少しだけ手を加えたものがこちら(依存ライブラリ: pyspf, publicsuffix2, dnspython, dmarc, dkimpy)。
dmarc.py(抜粋):
from typing import Literal
import spf
SPFResult = Literal[
"pass", "fail", "softfail", "neutral",
"none", "temperror", "permerror"
]
def verify_spf(
ip: str | None,
mail_from: str | None,
helo: str | None,
) -> tuple[SPFResult, str | None]:
"""
Verify SPF.
SMTP情報を元にSPF検証を行う。
Args:
ip (str | None): 送信元IP。
mail_from (str | None): Envelope From。
helo (str | None): HELOドメイン。
Returns:
tuple[str, str | None]: (spf結果, spfドメイン)
"""
if not ip or not mail_from:
return "none", None
try:
result: str
result, _ = spf.check2(i=ip, s=mail_from, h=helo or "")
return result, mail_from
except Exception:
return "error", None
dmarc.py(全文)
"""
SPF, DKIM, DMARC対応.
ChatGPTで作成.
DmarcVerifyReport
フィールドごとの解釈
1) dkim: bool と dkim_domains: list[str]
意味:署名が壊れていないか(改ざん検知)+どのドメインが責任を持つか
✔ 解釈
True
→ 途中改ざんされていない可能性が高い(信頼度↑)
False
→ 改ざん or 転送で破壊(単独では即スパムではない)
🧠 重要ポイント
dkim_domains と From のalignmentが核心(DMARCで評価)
複数署名がある場合、どれか1つでも整合すればOK(一般的実装)
2) spf: Literal[...] と spf_domain: str
意味:そのIPがそのドメインから送ってよいか
✔ 解釈(重要度順)
"pass"
→ 正規サーバから送信(ただしFrom一致とは限らない)
"fail"
→ なりすましの強いシグナル
"softfail"
→ グレー(誤設定も多い)
"neutral" / "none"
→ 判断材料にならない
"temperror"
→ DNS一時障害(再試行価値あり)
"permerror"
→ 設定不正(スパム寄りシグナル)
⚠️ 罠
SPFは転送で壊れる → 単独で強く罰しない
3) dmarc: Literal["pass", "fail", "none"]
意味:Fromドメインに対して“正当な送信か”
👉 スパム判定の中核
✔ 解釈
"pass"
→ なりすましでない可能性が高い(最重要ポジティブ)
"fail"
→ なりすましの強い疑い(最重要ネガティブ)
"none"
→ ポリシーなし(判断保留)
🧠 重要ポイント
DKIM or SPF どちらかが alignment すれば pass
ここが通ればかなり安心
4) arc: Literal["pass", "fail", "none"]
意味:転送経路の“証言”が信用できるか
✔ 解釈
"pass"
→ 転送で壊れた可能性を補完(救済材料)
"fail"
→ チェーン破損(信用低い)
"none"
→ ARCなし(普通)
⚠️ 重要
👉 ARCは信用できる送信者かどうかが前提
Gmail系 → ARCは参考程度
Microsoft系 → 信頼リストあり
5) final: Literal[...]
意味:あなたの統合判定
✔ 解釈例
"pass"
→ 正常配送
"pass (arc)"
→ 本来failだが転送で救済(要注意)
"fail"
→ 拒否 or 隔離候補
"none"
→ 判断材料不足
🔥 実務での“組み合わせ評価”
ここが一番重要です。
✅ パターン1(安全)
DKIM = True
DMARC = pass
👉 ほぼ正当メール
⚠️ パターン2(グレー)
DKIM = False
SPF = pass
DMARC = pass
👉 転送や再配送の可能性
👉 許可だが軽く警戒
🚨 パターン3(危険)
SPF = fail
DKIM = False
DMARC = fail
👉 強いなりすましシグナル
⚠️ パターン4(ARC救済)
DMARC = fail
ARC = pass
👉 転送の可能性あり
👉 ただし:
ARC signerが信用できるか?
メーリングリストか?
👉 ここで差が出る
🤔 パターン5(情報不足)
SPF = none
DKIM = False
DMARC = none
👉 何もわからない(スパム寄りに傾ける)
🧠 スコアリングの考え方(おすすめ)
DMARC pass +50
DKIM pass +20
SPF pass +10
ARC pass +10(条件付き)
DMARC fail -50
SPF fail -30
DKIM fail -10
ARC fail -10
👉 合計で判定
⚠️ よくある落とし穴
❌ SPF passだけで信頼
👉 Fromと無関係な場合あり
❌ DKIM passだけで信頼
👉 d=が別ドメインだと意味薄い
❌ ARC passで無条件OK
👉 危険(なりすまし可能)
🧠 最重要まとめ
👉 DMARC(alignment)が核
その上で:
DKIM → 改ざん検知
SPF → 経路検証
ARC → 転送救済
👍 一言でまとめると
👉 「Fromドメインと整合しているか」を中心に、証拠を積み上げる
2026/5/3
"""
import re
from dataclasses import dataclass
from email import message_from_bytes
from email.message import EmailMessage
from email.utils import parseaddr
from enum import Enum
from typing import Literal
import dkim
import dns.resolver
import spf
from publicsuffix2 import get_sld
SPFResult = Literal[
"pass", "fail", "softfail", "neutral",
"none", "temperror", "permerror"
]
DMARCResult = Literal["pass", "fail", "none"]
ARCResult = Literal["pass", "fail", "none"]
class FinalResult(str, Enum):
"""総合判定."""
PASS = "pass"
FAIL = "fail"
NONE = "none"
PASS_ARC = "pass (arc)" # 独自仕様
@dataclass(frozen=True, slots=True)
class DmarcVerifyReport:
"""
検証結果出力.
"""
dkim: bool
dkim_domains: list[str]
spf: SPFResult
spf_domain: str
dmarc: DMARCResult
arc: ARCResult
final: FinalResult
def get_from_domain(msg: EmailMessage) -> str | None:
"""
Extract Fromヘッダのドメイン.
Fromヘッダからメールアドレスを取得し、ドメイン部分を抽出する。
Args:
msg (EmailMessage): パース済みメールオブジェクト。
Returns:
str | None: Fromドメイン。
"""
addr: str = parseaddr(msg.get("From"))[1]
if "@" not in addr:
return None
return addr.split("@")[-1].lower()
def get_return_path_domain(msg: EmailMessage) -> str | None:
"""
Extract Return-Pathのドメイン.
エンベロープFromからドメインを抽出する。
Args:
msg (EmailMessage): メールオブジェクト。
Returns:
str | None: Return-Pathドメイン。
"""
rp: str | None = msg.get("Return-Path")
if not rp:
return None
addr: str = parseaddr(rp)[1]
if "@" not in addr:
return None
return addr.split("@")[-1].lower()
def extract_client_ip(msg: EmailMessage) -> str | None:
"""
Extract Receivedヘッダから送信元IP.
最下段のReceivedヘッダからIPv4を抽出する。
Args:
msg (EmailMessage): メールオブジェクト。
Returns:
str | None: IPアドレス。
"""
received: list[str] | None = msg.get_all("Received")
if not received:
return None
last: str = received[-1]
m: re.Match[str] | None = re.search(r"\[([0-9\.]+)\]", last)
return m.group(1) if m else None
def get_organizational_domain(domain: str | None) -> str | None:
"""
Extract 組織ドメイン.
Public Suffix Listを用いてeTLD+1を取得する。
Args:
domain (str | None): ドメイン。
Returns:
str | None: 組織ドメイン。
"""
return get_sld(domain) if domain else None
def relaxed_align(d1: str, d2: str) -> bool:
"""
Check relaxed alignment.
組織ドメイン単位で一致判定する。
Args:
d1 (str): ドメイン1。
d2 (str): ドメイン2。
Returns:
bool: 一致すればTrue。
"""
return get_organizational_domain(d1) == get_organizational_domain(d2)
def strict_align(d1: str, d2: str) -> bool:
"""
Check strict alignment.
完全一致で比較する。
Args:
d1 (str): ドメイン1。
d2 (str): ドメイン2。
Returns:
bool: 完全一致ならTrue。
"""
return d1 == d2
def verify_dkim(raw_bytes: bytes) -> tuple[bool, list[str]]:
"""
Verify DKIM署名.
DKIM検証と署名ドメイン抽出を行う。
Args:
raw_bytes (bytes): メールデータ。
Returns:
tuple[bool, list[str]]: (検証結果, DKIMドメイン一覧)
"""
try:
ok: bool = dkim.verify(raw_bytes)
except Exception:
ok = False
domains: list[str] = []
try:
d = dkim.DKIM(raw_bytes)
for sig in d.signatures:
m: re.Match[bytes] | None = re.search(br"d=([^;]+)", sig)
if m:
domains.append(m.group(1).decode())
except Exception:
pass
return ok, domains
def verify_spf(
ip: str | None,
mail_from: str | None,
helo: str | None,
) -> tuple[SPFResult, str | None]:
"""
Verify SPF.
SMTP情報を元にSPF検証を行う。
Args:
ip (str | None): 送信元IP。
mail_from (str | None): Envelope From。
helo (str | None): HELOドメイン。
Returns:
tuple[str, str | None]: (spf結果, spfドメイン)
"""
if not ip or not mail_from:
return "none", None
try:
result: str
result, _ = spf.check2(i=ip, s=mail_from, h=helo or "")
return result, mail_from
except Exception:
return "error", None
def get_dmarc_record(domain: str | None) -> str | None:
"""
Fetch DMARCレコード.
DNSからDMARC TXTを取得する。
Args:
domain (str | None): ドメイン。
Returns:
str | None: DMARCレコード。
"""
if not domain:
return None
try:
answers = dns.resolver.resolve(f"_dmarc.{domain}", "TXT")
for r in answers:
txt: str = b"".join(r.strings).decode()
if txt.startswith("v=DMARC1"):
return txt
except Exception:
pass
return None
def parse_dmarc_policy(record: str | None) -> dict[str, str]:
"""
Parse DMARCポリシー.
DMARCレコードを辞書化する。
Args:
record (str | None): レコード文字列。
Returns:
dict[str, str]: ポリシー。
"""
policy: dict[str, str] = {}
if not record:
return policy
for part in record.split(";"):
if "=" in part:
k, v = part.strip().split("=", 1)
policy[k] = v
return policy
def check_alignment(
from_domain: str,
dkim_domains: list[str],
spf_domain: str | None,
adkim: str,
aspf: str,
) -> tuple[bool, bool]:
"""
Check DMARC alignment.
DKIM/SPFのalignmentを評価する。
Args:
from_domain (str): Fromドメイン。
dkim_domains (list[str]): DKIMドメイン群。
spf_domain (str | None): SPFドメイン。
adkim (str): DKIMモード。
aspf (str): SPFモード。
Returns:
tuple[bool, bool]: (dkim_aligned, spf_aligned)
"""
dkim_func = strict_align if adkim == "s" else relaxed_align
spf_func = strict_align if aspf == "s" else relaxed_align
dkim_aligned: bool = any(dkim_func(from_domain, d) for d in dkim_domains)
spf_aligned: bool = bool(
spf_domain and spf_func(from_domain, spf_domain)
)
return dkim_aligned, spf_aligned
def evaluate_dmarc(
policy: dict[str, str],
dkim_ok: bool,
spf_result: str,
dkim_aligned: bool,
spf_aligned: bool,
) -> DMARCResult:
"""
Evaluate DMARC結果.
DKIM/SPF結果からDMARC判定を行う。
Args:
policy (dict[str, str]): DMARCポリシー。
dkim_ok (bool): DKIM結果。
spf_result (str): SPF結果。
dkim_aligned (bool): DKIM alignment。
spf_aligned (bool): SPF alignment。
Returns:
str: 判定結果。
"""
if not policy:
return "none"
if dkim_ok and dkim_aligned:
return "pass"
if spf_result == "pass" and spf_aligned:
return "pass"
return "fail"
def verify_arc(raw_bytes: bytes) -> bytes:
"""
Verify ARCチェーン.
ARCの整合性を検証する。
Args:
raw_bytes (bytes): メールデータ。
Returns:
bytes: ARC結果。
"""
try:
cv: bytes
cv, _, _ = dkim.arc_verify(raw_bytes)
return cv
except Exception:
return b"none"
def verify_email(raw_bytes: bytes) -> DmarcVerifyReport:
"""
Perform メール認証検証.
DKIM/SPF/DMARC/ARCを統合して最終判定する。
Args:
raw_bytes (bytes): メールデータ。
Returns:
AuthResult: 検証結果。
"""
msg: EmailMessage = message_from_bytes(raw_bytes)
from_domain: str | None = get_from_domain(msg)
return_domain: str | None = get_return_path_domain(msg)
ip: str | None = extract_client_ip(msg)
dkim_ok, dkim_domains = verify_dkim(raw_bytes)
spf_result, spf_domain = verify_spf(ip, return_domain, from_domain)
record: str | None = get_dmarc_record(from_domain)
policy: dict[str, str] = parse_dmarc_policy(record)
adkim: str = policy.get("adkim", "r")
aspf: str = policy.get("aspf", "r")
if from_domain is None:
return {"final": "fail"}
dkim_aligned, spf_aligned = check_alignment(
from_domain, dkim_domains, spf_domain, adkim, aspf
)
dmarc_result: str = evaluate_dmarc(
policy, dkim_ok, spf_result, dkim_aligned, spf_aligned
)
arc_cv: bytes = verify_arc(raw_bytes)
final: FinalResult
if dmarc_result == "fail" and arc_cv == b"pass":
final = FinalResult.PASS_ARC
else:
final = FinalResult(dmarc_result)
return DmarcVerifyReport(
dkim_ok,
dkim_domains,
spf_result,
spf_domain or "",
dmarc_result,
arc_cv.decode(),
final
)
ところが、実際に判定させたところ、自分のメールアドレスから同じドメイン内の別アドレスへ送信した正規のメールが、DNSのSPF, DKIM, DMARCをきちんと設定しているにもかかわらずspf='softfail'となり、不正と判定されてしまった。
原因
ChatGPTと色々やりとりした結果、「同一ドメイン内のメール送信はSPFによる検証の対象外」ということが判明した。
対応
以下のようなコードをChatGPTに生成させ、「同一ドメイン内でSMTPサーバーの認証を通っている」メールはSPFの対象外とした。
(後述の理由によりコード非公開)
結果
修正後のSPF判定はspf='none'となり、問題は解決した。SPFの検証を回避しているのでspf='pass'とはしなかったが、最終的な判断が'fail'になることはなかった。
考察
ChatGPTに論拠を提示させたところ、「そのまま規定している“単一の公式仕様書”は存在しません」とのこと。以下の文書を基に実装すると、結果的に同一ドメイン(メールサーバー)内でのメールのやり取りはSPFによる検証の対象外になるということらしい。
RFC 7208(SPF仕様)
SPF is used to check the host that is delivering the message
SPFは「受信側で」「接続してきた相手」を検証するもの
SPF evaluation occurs during the SMTP transaction
外部からのSMTP接続時に評価する前提
RFC 6409(Submission仕様)
The message submission server SHOULD enforce authentication
Submissionは認証で信頼を担保する設計
Messages submitted are considered trustworthy
Submissionされたメールは“信頼済み扱い”
つまり、SPFで再チェックする意味が薄い、とのこと。
感想
自己のドメイン内でのメールのやり取りというのは、メール全体の割合としても少なくないはずなのに、仕様書に明示的な記載がないというのは混乱の元ではないかと思った。
SPFを解説しているサイトはどれも公式な見解をなぞるばかりで、こういった実践に踏み込むとぶち当たる壁について触れているものはなく、同じような内容が数ばかりあっても全部同じ浅さであることは残念に感じた。
ただ、「もしかしてスパム業者に対策させないために秘匿していたりするんだろうか」という疑問はあったので、対応コードの公開は見合わせることとした。もっともChatGPTによればスパム業者がこの部分を偽装すると他に綻びが出るとのことなので、そこまで深刻ではないのかもしれない。
もっと深く考えれば何か違った事情が出てくるかもしれないが、とりあえず自分としての目標は達成されたので、今回はここで筆を置くことにする。
SPF, DKIM, DMARC全体に対する感想として、メールの転送などについて穴が多く、GoogleがGMailのシェアを盾に世界中を巻き込んで強制したにしては実効性が低いと感じた。メール中継のタイミングでの署名検証や再署名などをもう少し厳格にしないと世界に強制するだけの価値は生じないように思う。
(AIについての)雑感
本件と直接関わりのない感想として、ChatCPTにはコード生成の段階で指摘して欲しかったとは感じた。一方で、こういう明示的に書かれていない仕様というのは、ChatCPTに聞かないで自分で調べていただけではたどり着けなかっただろうなとも思った。
更新履歴
日付降順。表現の修正などは除く。
- 2026/5/17: サンプルコードを追加して公開
- 2026/5/14: 結果など追記
- 2026/5/6: 初版