エンジニアの仕事には、技術を「使う」ことと同じくらい、技術を「説明する」ことが含まれていると思っています。特に、相手がエンジニアではないとき。
以下は、架空の整骨院の院長先生にあてて書いた、架空の手紙です。ホームページのお問い合わせフォームから届くはずのメールが、迷惑メールのフォルダに入ってしまう。そんなご相談への、お返事という設定です。院長先生も整骨院も実在しませんが、ご相談の中身は、ホームページの保守をしていると、本当によく出会うものです。
手紙のあとに、同封した「技術メモ」を付けています。こちらは、エンジニアの方に向けた内容です。
拝啓
残暑の候、院長先生におかれましては、ますますご健勝のこととお慶び申し上げます。毎日たくさんの患者さまを診ていらっしゃる先生に、長いお手紙をお送りすることを、まずお許しください。
先日、お電話でご相談いただいた件について、調べた結果をお知らせいたします。
「ホームページのお問い合わせフォームから送られてくるメールが、ときどき迷惑メールのフォルダに入っている。気づかずに、予約のお問い合わせを3日も放っておいてしまった」というお話でした。患者さまにご迷惑をおかけしたと、先生がとても気にされていたのが、電話越しにも伝わってまいりました。
結論から申し上げますと、これは先生のパソコンやメールソフトの不具合ではありません。そして、整骨院さまだけに起きている問題でもありません。ここ数年で、メールの世界の「ルール」が厳しくなったことが、原因でございます。
メールは、はがきのようなもの
少しだけ、メールの仕組みのお話をさせてください。
メールは、実は、とても「はがき」に似ています。はがきの差出人の欄には、誰の名前でも書けてしまいます。整骨院さまの名前を書いて、まったく別の人がポストに入れることもできてしまう。そうやって、有名な会社や銀行のふりをした「なりすましメール」が、毎日、世界中で大量に送られています。
そこで、メールを受け取る側(GmailやYahoo!メール、携帯電話会社のメールなど)は、ここ数年、**「このメールは、本当に差出人の欄に書かれた人から来たのか」**を、厳しく確かめるようになりました。たとえばGoogleは、2024年2月から、Gmailにメールを送るすべての送り手に、本人であることを確かめるための設定を求めています。日本でも、2025年9月に、総務省が通信事業者の団体に対して、なりすましメールへの対策を強めるよう要請を出しました。
確かめる方法は、大きく3つあります。郵便にたとえると、こんなイメージです。
- SPF(エス・ピー・エフ):「整骨院さまのメールは、この郵便局から出します」と、あらかじめ届け出ておく仕組みです。届け出ていない郵便局から出されたメールは、怪しまれます
- DKIM(ディーキム):手紙の封に、整骨院さまだけが持っている印鑑を押す仕組みです。途中で誰かが中身を書き換えると、印影が合わなくなるので分かります
- DMARC(ディーマーク):「もし、整骨院さまの名前で、届け出のない郵便局から、印鑑のない手紙が届いたら、捨ててください」と、受け取る側にあらかじめお願いしておく仕組みです
先生のホームページで、何が起きていたか
調べてみたところ、原因は2つございました。
1つ目は、お問い合わせフォームの「差出人」です。
現在のフォームは、患者さまがフォームに入力されたメールアドレス(たとえば、患者さまのGmailのアドレス)を、そのまま差出人にして、整骨院さまにメールを送る設定になっていました。
一見、自然に思えます。返信ボタンを押せば、患者さまに返事ができますから。でも、受け取る側から見ると、このメールは「Gmailの人の名前で、整骨院さまのホームページのサーバーから送られてきた手紙」です。Gmailの届け出の郵便局から出たものでもなく、Gmailの印鑑も押されていない。つまり、なりすましメールと、まったく同じ形をしているのです。迷惑メールのフォルダに入ってしまうのも、無理はありません。
「李下に冠を正さず」という言葉がございます。すももの木の下で冠をかぶり直すと、実を盗んでいるように見えてしまうので、疑われるようなことはしない方がよい、という意味です。フォームのメールは、何も悪いことはしていないのに、疑われる格好をしてしまっていた、というわけです。
2つ目は、「郵便局の届け出」が多すぎることです。
整骨院さまのドメイン(@ から後ろの部分)には、これまでの数年間で、予約システム、ネットのお知らせメールの配信サービス、以前使っていたメールのサービスなど、いくつものサービスが「届け出」として追加されていました。
この届け出の仕組みには、確かめるために、ほかの場所に問い合わせに行ける回数は10回まで、という決まりがございます。整骨院さまの届け出は、たどっていくと12回の問い合わせが必要になっていて、決まりを超えてしまっていました。決まりを超えると、届け出そのものが「読めない」扱いになり、どの郵便局から出したメールも、確かめようがなくなってしまいます。
これから、どうするか
次の順番で、直していくことをご提案いたします。
- お問い合わせフォームの差出人を、整骨院さまのアドレスに変えます。 患者さまのアドレスは、「返信先」という別の欄に入れます。これで、先生がメールソフトで「返信」を押したときには、これまでどおり患者さまに返事が届きます。先生の操作は、何も変わりません
- 使っていないサービスの届け出を外します。 以前お使いだったメールのサービスと、今はお休みしているお知らせメールの配信サービスの届け出が残っておりました。これを外せば、問い合わせの回数は10回以内に収まります
- ホームページのサーバーから送るメールに、印鑑(DKIM)を押す設定をします
- 最後に、DMARCで「なりすましがあったら、教えてください」とお願いする設定をします。 最初は「教えてもらうだけ」にして、しばらく様子を見ます。整骨院さまの名前で、思いもよらないところからメールが送られていないかを確かめてから、少しずつ「迷惑メールに入れてください」「受け取らないでください」へと強めていきます
1と2は、私の側の作業で、合わせて1時間ほどで終わります。3と4は、ドメインを管理している会社の管理画面での設定が必要になりますので、ログインのための情報を、お手すきのときにお知らせいただけますでしょうか。
最後に
先生がお電話で、「患者さまからの連絡を見落とすのが、いちばん怖い」とおっしゃっていたのが、心に残っております。
メールは、目に見えないところで、たくさんの確認をくぐり抜けて届いております。その確認に、整骨院さまのメールがきちんと合格できるようにすることが、今回の私の仕事です。設定が終わりましたら、実際にいくつかのメールのサービスに向けてテストのメールを送り、迷惑メールに入らずに届くことを確かめたうえで、ご報告いたします。
まだまだ暑い日が続きます。毎日、たくさんの方の体を支えていらっしゃる先生ご自身も、どうかお体を大切になさってください。
敬具
担当エンジニアより
追伸 それまでのあいだ、念のため、一日に一度だけ、迷惑メールのフォルダものぞいていただけますと安心です。お手数をおかけいたしますが、どうぞよろしくお願いいたします。
同封物:技術メモ(エンジニアの方へ)
ここからは、手紙の裏で実際にやったことを、エンジニア向けに書く。手紙では「郵便局の届け出」と書いたが、もちろん中身はDNSのTXTレコードだ。
何を調べたか
SPFとDMARCのレコードを確かめ、SPFの評価で発生するDNSの参照回数を数えるスクリプトを、Pythonの標準ライブラリだけで書いた。DNSの問い合わせには、DNS over HTTPS(CloudflareのJSON API)を使っている。
"""ドメインの SPF と DMARC を、DNS over HTTPS で調べる(標準ライブラリだけ)。
- SPF: レコードの有無と、DNSの参照回数(RFC 7208 の上限は10回)
- DMARC: ポリシー(p=)と、集計レポートの送り先(rua=)
"""
import json
import sys
import urllib.parse
import urllib.request
DOH = "https://cloudflare-dns.com/dns-query"
LOOKUP_MECHS = ("include:", "a", "mx", "ptr", "exists:", "redirect=") # 回数に数えるもの
def txt_records(name: str, fake: dict | None = None) -> list[str]:
if fake is not None:
return fake.get(name, [])
url = f"{DOH}?{urllib.parse.urlencode({'name': name, 'type': 'TXT'})}"
req = urllib.request.Request(url, headers={"accept": "application/dns-json"})
with urllib.request.urlopen(req, timeout=10) as res:
answers = json.load(res).get("Answer", [])
# TXT は "..." "..." のように分割されて返ることがあるので、つなげる
return ["".join(part.strip('"') for part in a["data"].split('" "')) for a in answers if a["type"] == 16]
def spf_record(domain: str, fake=None) -> str | None:
found = [r for r in txt_records(domain, fake) if r.lower().startswith("v=spf1")]
return found[0] if len(found) == 1 else (None if not found else "複数あり(それ自体がエラー)")
def count_lookups(domain: str, fake=None, depth: int = 0) -> int:
"""SPF の評価で発生する DNS 参照の回数を数える(include / redirect の先も、たどって数える)"""
record = spf_record(domain, fake)
if not record or record.startswith("複数"):
return 0
total = 0
for term in record.split()[1:]:
t = term.lstrip("+-~?").lower()
if t == "a" or t.startswith(("a:", "a/")) or t == "mx" or t.startswith(("mx:", "mx/")) \
or t.startswith(("ptr", "exists:")):
total += 1
elif t.startswith(("include:", "redirect=")):
total += 1
if depth < 10:
total += count_lookups(t.split(":", 1)[-1].split("=", 1)[-1], fake, depth + 1)
return total
def dmarc(domain: str, fake=None) -> dict[str, str] | None:
found = [r for r in txt_records(f"_dmarc.{domain}", fake) if r.lower().startswith("v=dmarc1")]
if not found:
return None
return dict(kv.strip().split("=", 1) for kv in found[0].split(";") if "=" in kv)
def report(domain: str, fake=None) -> None:
print(f"■ {domain}")
spf = spf_record(domain, fake)
print(f" SPF : {spf or 'なし'}")
if spf and not spf.startswith("複数"):
n = count_lookups(domain, fake)
print(f" DNS参照 {n}回 {'(上限10回を超えている → SPFは permerror)' if n > 10 else '(上限10回以内)'}")
d = dmarc(domain, fake)
if d is None:
print(" DMARC : なし(受け取る側は、なりすましを判断する手がかりを持てない)")
else:
policy = {"none": "監視のみ", "quarantine": "迷惑メールへ", "reject": "受け取り拒否"}.get(d.get("p", ""), "不明")
print(f" DMARC : p={d.get('p')}({policy}) レポート送り先={d.get('rua', 'なし')}")
# 説明用の架空のドメイン(.example は、例示のために予約されているトップレベルドメイン)
FAKE_DNS = {
"sakura-seikotsu.example": ["v=spf1 include:_spf.mail-a.example include:_spf.mail-b.example "
"include:_spf.form.example include:_spf.news.example a mx ~all"],
"_spf.mail-a.example": ["v=spf1 include:_netblocks1.mail-a.example include:_netblocks2.mail-a.example "
"include:_netblocks3.mail-a.example ~all"],
"_spf.mail-b.example": ["v=spf1 include:spf1.mail-b.example include:spf2.mail-b.example ~all"],
"_spf.form.example": ["v=spf1 ip4:192.0.2.0/24 include:relay.form.example ~all"],
"_spf.news.example": ["v=spf1 ip4:198.51.100.0/24 ~all"],
}
if __name__ == "__main__":
if len(sys.argv) > 1:
for name in sys.argv[1:]:
report(name)
else:
report("sakura-seikotsu.example", FAKE_DNS)
架空の整骨院のドメインで実行した結果
ドメインやレコードは、説明のために作った架空のものだ(.example は、RFC 2606 で例示用に予約されているトップレベルドメイン)。スクリプトの中の FAKE_DNS を、DNSの代わりに引いている。
$ python mailauth_check.py
■ sakura-seikotsu.example
SPF : v=spf1 include:_spf.mail-a.example include:_spf.mail-b.example include:_spf.form.example include:_spf.news.example a mx ~all
DNS参照 12回 (上限10回を超えている → SPFは permerror)
DMARC : なし(受け取る側は、なりすましを判断する手がかりを持てない)
SPFの評価で発生するDNSの参照は、RFC 7208 で10回までと決められている。include:、a、mx、ptr、exists:、redirect= がそれぞれ1回と数えられ、include の先のレコードの中の参照も、すべて合算される。ip4: や ip6:、all は数えない。
このドメインは、トップレベルの include 4つと a、mx で6回、その先の include で6回、合計12回になる。上限を超えると、SPFの評価結果は permerror になり、正しい送信元から送ったメールでも、SPFでは認証に失敗する。
サービスを導入するたびに、言われるがままに include: を足していくと、こうなる。足すことはあっても、外すことはない。気づいたときには、誰も全体を把握していない。
実在のドメインでも動くか
引数にドメインを渡すと、本物のDNSを引く。たとえば、例示用に予約されている example.com で試すと、こうなる。
$ python mailauth_check.py example.com
■ example.com
SPF : v=spf1 -all
DNS参照 0回 (上限10回以内)
DMARC : p=reject(受け取り拒否) レポート送り先=なし
dig で直接引いた結果とも一致した。
$ dig +short TXT _dmarc.example.com
"v=DMARC1;p=reject;sp=reject;adkim=s;aspf=s"
example.com は、メールを一切送らないドメインなので、SPFで「どこからも送らない」(-all)と宣言し、DMARCで「なりすましは受け取り拒否」(p=reject)を指定している。メールを送らないドメインにも、なりすまし対策のためにこうした設定をしておく、というのは、よく知られた良い習慣だ。
原因1への対処:フォームの From と Reply-To
お問い合わせフォームで、訪問者が入力したアドレスを From: にして、Webサーバーから送る構成は、今のメールの世界では、ほぼ確実にDMARCで落ちる。From: のドメイン(たとえば gmail.com)と、実際に送信したサーバーやDKIMの署名のドメインが一致しない(アライメントが取れない)からだ。
From: のドメインがDMARCのレコードを公開していれば、受け取り側には「このメールは、差出人のドメインの認証に失敗している」ことがはっきり分かる。そのドメインのポリシーが quarantine(迷惑メールへ)や reject(受け取り拒否)なら、そのとおりに扱われる。ポリシーが none(監視のみ)の場合でも、認証に失敗したという事実は、迷惑メールかどうかを判断する材料として使われる。どちらにしても、訪問者のアドレスを From: にすることに、得はひとつもない。
直し方は、From: を自分のドメインのアドレスにして、訪問者のアドレスは Reply-To: に入れる。
WordPressのContact Form 7なら、「メール」タブで、次のように設定する。
送信元: [_site_title] <wordpress@sakura-seikotsu.example>
追加ヘッダー: Reply-To: [your-email]
これで、院長先生がメールソフトで「返信」を押せば、Reply-To: の患者さまのアドレスに返信される。先生の操作は変わらない。手紙に書いたとおりだ。
原因2への対処:include を減らす
使っていないサービスの include: を外すのが、いちばん安全で効果が大きい。それでも10回に収まらない場合は、次の手を検討する。
-
送信の用途ごとにサブドメインを分ける:お知らせメールの配信は
news.sakura-seikotsu.exampleから送るようにして、そのサブドメインに、配信サービス用のSPFを別に設定する。親ドメインのSPFから、そのinclude:を外せる -
IPアドレスを直接書く(フラット化):
include:の先を展開してip4:で列挙すれば、参照回数は減る。ただし、サービス側のIPアドレスが変わったときに追従できなくなるので、自動で更新する仕組みがない限り、おすすめしにくい
DKIMとDMARCの導入順
DMARCは、いきなり p=reject にしない。手紙に書いたとおり、段階を踏む。
-
p=noneとrua=(集計レポートの送り先)を設定する:受け取り側から、「このドメインの名前で、どこから、どれだけのメールが届いたか」のレポートが送られてくるようになる - レポートを数週間読む:自分たちが把握していない送信元(昔の予約システム、外部の業者が使っている配信サービスなど)がないかを確かめる。ここで見落とすと、正規のメールまで止めてしまう
p=quarantine(迷惑メールへ)に上げる- 問題がなければ
p=reject(受け取り拒否)に上げる
Googleの送信者のガイドラインでは、2024年2月から、Gmailの個人アカウントにメールを送るすべての送信者に、SPFまたはDKIMの設定を求めている。1日に5,000通以上を送る送信者には、さらにSPFとDKIMの両方、DMARCの設定、そして From: のドメインとSPFまたはDKIMのドメインの一致(アライメント)が求められる。整骨院さまのような小さなサイトでも、「すべての送信者」の側には入っている。
このメモを書いて思ったこと
技術的には、今回の修正は、どれも数行の設定の変更で終わる。難しいのは、それをなぜやるのかを、エンジニアではない人に、納得してもらうことのほうだ。
院長先生への手紙では、SPF、DKIM、DMARCという言葉を、一度ずつしか使わなかった。代わりに、はがき、郵便局の届け出、印鑑、という言葉を使った。たとえ話は、正確さを少しだけ犠牲にする。でも、相手が「だから迷惑メールに入っていたのか」と腹落ちしてくれなければ、ドメインの管理画面のログイン情報を預けてもらうことも、DMARCを段階的に強めていく数週間を待ってもらうことも、できない。
技術を正しく使うことと、技術を正しく伝えること。その両方がそろって、ようやく、患者さまからの予約のメールが、ちゃんと院長先生のもとに届くのだと思う。
