はじめに
前に「受信メールのパース地獄」について書きました。
今回はもっと手前、ユーザー登録フォームの「このメールアドレス、正しい形式?」を判定するだけの話です。\S+@\S+ でも書いておけば十分だろう——と思っていて、正直またしてもナメてました。
真面目に詰めると地獄でした。"john doe"@example.com は有効、user@example.com は構文が完璧なのにメールが届かない。同じ Gmail に届く 6 個の別アドレスが DB には 6 人で入る。\S+@\S+ に至っては、改行を混ぜた攻撃文字列まで「有効」と言い張ります。
この記事は、その地獄を自分で再現して一つずつ攻略した記録です。「判定が直感に反する 14 個のアドレス」を用意し、よくある正規表現 4 種にわざと殴らせて、最後はライブラリと運用で攻略します。
結論
- メールアドレスの構文検証を正規表現一発で済ませてはいけない。
\S+@\S+は改行インジェクションすら通す - 「RFC 5322 Official Standard」を名乗る怪物正規表現でも、引用符内スペースを取りこぼし、国際化アドレスを全部弾く
- 構文は
email-validatorに任せる。長さ・国際化・正規化まで面倒を見てくれる(ただし 64 文字制限はstrict=Trueが要る) - 構文 OK ≠ 届く。
example.comは構文完璧でも Null MX で受信できない - 重複アカウント対策は別レイヤー。Gmail のドット無視・
+タグ・大小無視は自分で正規化する
対象読者
- 登録フォームのメール検証を「とりあえず正規表現」で書こうとしている人
email-validatorを使うか自前正規表現でいくか迷っている人- 「
user+tag@example.comでエラーになるんですけど」とユーザーに怒られたことがある人
検証環境
Python : 3.14.4
ライブラリ : email-validator 2.3.0, dnspython 2.8.0
OS : macOS (Apple Silicon)
構文と国際化は email-validator、ドメインの生存確認(MX レコード)に dnspython を使います。
python3 -m pip install email-validator dnspython
まず"地獄"を自分で並べる
文字化けメールと違って、こちらは手元でいくらでも作れます。狙ったのは「人間の直感と、素朴な実装の判定がズレる」14 ケースです。各アドレスに「実務的に受理すべきか(期待値)」を付けておきます。
# (address, 期待する正否, メモ)
CASES = [
("user@example.com", True, "ごく普通"),
("user.name+tag@example.co.jp", True, "+タグ・複数ドット・サブドメイン"),
('"john doe"@example.com', True, "引用符付きローカル(スペース) ※RFC的に有効"),
("user@[192.168.0.1]", True, "ドメインリテラル(IPv4) ※RFC的に有効"),
("ツ@example.com", True, "国際化ローカル(EAI/SMTPUTF8)"),
("user@日本語.jp", True, "国際化ドメイン(IDN)"),
("a..b@example.com", False, "連続ドット"),
(".user@example.com", False, "先頭ドット"),
("user.@example.com", False, "末尾ドット"),
("user@@example.com", False, "@が2つ"),
("user@-bad.com", False, "ラベル先頭ハイフン"),
("plainaddress", False, "@なし"),
("a" * 65 + "@example.com", False, "ローカル65文字(64超)"),
("user@example.com\nbcc: evil@x.com", False, "改行インジェクション"),
]
"john doe"@example.com や user@[192.168.0.1] が「有効」なのに違和感がありますか? 私もそうでした。でも RFC 5321/5322 上はどちらも合法です。引用符で囲めばローカルパートにスペースを入れられるし、ドメイン部に IP アドレスを角括弧で直書きすることも許されています。実際に出会うことはほぼないですが、「有効」と言われると弾きにくい。
逆に a..b@example.com(ドット連続)や .user(先頭ドット)は無効です。ドットは区切り文字であって、連続させたり端に置いたりはできない。このあたりが、素朴な正規表現の踏み抜きポイントになります。
第1幕:正規表現4種に殴らせる
用意した「武器」は 4 つです。雑なものから、ネットで神格化されている怪物まで。
# 1) 最も雑。新人の自分が書いていたやつ
NAIVE = r"\S+@\S+"
# 2) ネットでよく拾う「それっぽい」正規表現(@とドットを要求)
ITTOKI = r"[^@\s]+@[^@\s]+\.[^@\s]+"
# 3) emailregex.com などで "RFC 5322 Official Standard" として出回る怪物
RFC5322 = (
r"""(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*"""
r'''|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]'''
r'''|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")'''
r"""@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"""
r"""|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}"""
r"""(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\])"""
)
# 4) HTML5(WHATWG) が <input type=email> 用に定める実用正規表現
HTML5 = (
r"[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+"
r"@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*"
)
14 ケースに対して、偽陽性(FP、通すべきでないのに通した)と偽陰性(FN、通すべきなのに弾いた)を数えます。走らせた結果がこれです。
== (1) naive \S+@\S+ 正解 6/14 ==
FP 通すな!: 'a..b@example.com', '.user@example.com', 'user.@example.com',
'user@@example.com', 'user@-bad.com', 'aaa…(65字)@example.com',
'user@example.com\nbcc: evil@x.com'
FN 弾くな!: '"john doe"@example.com'
== (2) it-toki x@x.x 正解 8/14 ==
FP 通すな!: 'a..b@example.com', '.user@example.com', 'user.@example.com',
'user@-bad.com', 'aaa…(65字)@example.com'
FN 弾くな!: '"john doe"@example.com'
== (3) RFC5322 monster 正解 10/14 ==
FP 通すな!: 'aaa…(65字)@example.com'
FN 弾くな!: '"john doe"@example.com', 'ツ@example.com', 'user@日本語.jp'
== (4) HTML5 standard 正解 6/14 ==
FP 通すな!: 'a..b@example.com', '.user@example.com', 'user.@example.com',
'aaa…(65字)@example.com'
FN 弾くな!: '"john doe"@example.com', 'user@[192.168.0.1]',
'ツ@example.com', 'user@日本語.jp'
最高得点でも 10/14。順番に見ていきます。
(1) \S+@\S+ は改行インジェクションを通す
これがいちばん肝を冷やしました。FP の最後、'user@example.com\nbcc: evil@x.com' を見てください。\S+@\S+ が、改行を含んだ文字列を「有効なアドレス」と判定しているんです。
理由は単純で、re.match() は先頭からの部分一致で、末尾を $ でも \Z でも縛っていない。だから user@example.com までマッチした時点で「OK」を返し、その後ろの \nbcc: evil@x.com を見ていない。
re.match(r"\S+@\S+", "user@example.com\nbcc: evil@x.com") # マッチしてしまう
このアドレスをそのままメールヘッダに埋め込んだら、Bcc: ヘッダを注入されます。メールヘッダインジェクションという、古典的だけど今でも刺さる脆弱性です。「検証が緩い」どころか「攻撃の入口を自分で開けている」レベル。
正規表現で検証するなら、re.match() ではなく re.fullmatch() を使う(または \A...\Z で囲む)。$ は「行末」にもマッチするため、\n を含む文字列で抜けられます。アンカーの付け忘れは事故に直結します。
(2) ネット拾いの"それっぽい"やつ
[^@\s]+@[^@\s]+\.[^@\s]+。@ とドットを要求するぶん、改行や @@ は弾けるようになりました。でも a..b(連続ドット)、.user(先頭ドット)、user@-bad.com(ハイフン始まりのラベル)は素通り。「ドットがどこにあってもいい」「ハイフンがどこにあってもいい」と言っているので当然です。
おもしろいのは、このザルな正規表現が ツ@example.com と user@日本語.jp(国際化)を**「たまたま」正解している**点です。[^@\s] が「@ と空白以外」なので、Unicode 文字を平然と通してしまう。雑すぎて、逆に国際化対応できている。皮肉な話です。
(3) 「RFC 5322 Official Standard」怪物のほころび
emailregex.com などで「これが公式の正規表現だ」と崇められている、あの怪物です。実際、構文の精度は飛び抜けていて、FP は 65 文字のケースだけ(長さは後述)。a..b も user@-bad.com もきっちり弾く。さすがです。
ところが FN を見ると、"john doe"@example.com を弾いている。引用符付きローカルパートに対応しているはずの怪物が、です。これは予想と違ったので、文字クラスを目を凝らして読みました。
"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[...])*"
~~~~ ↑ \x20(スペース)が無い
\x1f の次が \x21 で、\x20(スペース)が範囲からすっぽり抜けている。引用符の中ならスペースは合法なのに、この「公式」は通さない。コピペで持ってきた正規表現が、名前ほど万能ではなかったわけです。
そしてもう一つ、ツ@example.com と user@日本語.jp も全滅。怪物は ASCII しか相手にしていません。国際化アドレス(EAI)は構造上、この正規表現の外側にいます。
「RFC 5322 準拠」を名乗る正規表現は世にたくさんありますが、引用符・コメント・国際化まで完全に網羅したものは現実的に書けません。仮に書けても、半年後の自分がメンテできない呪文になります。
(4) HTML5 標準は、ドットの位置を見ない
ブラウザの <input type="email"> が内部で使う、WHATWG 制定の正規表現です。これは「実用的な落とし所」として有名で、私も最初は本命だと思っていました。
結果は 6/14。FP に a..b、.user、user. が並びます。HTML5 のローカルパートは [a-zA-Z0-9.!#$%&'*+/=?^_{|}~-]+` で、ドットを文字クラスの一員として扱っている。だから連続ドットも端のドットも通る。これは WHATWG が「あえて RFC に違反している(willful violation)」と公言している部分で1、厳密さより実装しやすさを取った結果です。割り切りとしては正しいけれど、「HTML5 標準だから安心」ではない。
幕間:正規表現では、誰も「長さ」を見ない
4 つすべての FP に、判で押したように 'aaa…(65字)@example.com' が残り続けたのに気づきましたか。あの精密な怪物正規表現ですら、65 文字のローカルパートを通しています。
メールアドレスには長さ制限があります。RFC 5321 で、ローカルパートは 64 オクテット、ドメインは 255 オクテット、そしてアドレス全体は 254 文字まで2。でも、これは「文字の並び」のルールではなく「長さ」のルールなので、正規表現で表現しようとすると {1,64} のような量指定子を正しい位置に入れる必要があり、ただでさえ複雑な式がさらに崩壊します。
つまり、長さチェックは正規表現の外で別途やるしかない。「正規表現一発で完結」という発想自体が、ここで破綻します。
第2幕:email-validator に任せる
自前正規表現での消耗をやめて、email-validator(2.3.0)に投げます。やってくれることが多いので、まずデフォルトのまま 8 ケースを流しました(check_deliverability=False で DNS は引かず、構文だけ)。
from email_validator import validate_email, EmailNotValidError
def run(addr, **opts):
try:
v = validate_email(addr, check_deliverability=False, **opts)
return f"OK normalized={v.normalized!r}"
except EmailNotValidError as e:
return f"NG: {e}"
user.name+tag@example.co.jp OK normalized='user.name+tag@example.co.jp'
"john doe"@example.com NG: Quoting the part before the @-sign is not allowed here.
user@[192.168.0.1] NG: A bracketed IP address after the @-sign is not allowed here.
ツ@example.com OK normalized='ツ@example.com'
user@日本語.jp OK normalized='user@日本語.jp'
a..b@example.com NG: An email address cannot have two periods in a row.
user@-bad.com NG: An email address cannot have a hyphen immediately after the @-sign.
aaa…(65字)@example.com OK ← えっ
エラーメッセージが日本語の登録フォームにそのまま出せそうなくらい親切です。+タグ は通り、連続ドットやハイフン始まりはちゃんと理由付きで落ちる。
注目は 2 点。"john doe"(引用符)と [192.168.0.1](ドメインリテラル)が、デフォルトではわざと拒否されること。そして 65 文字がなぜか通っていること。
危ない構文は、オプトインで開ける
email-validator は「RFC 的には有効だが、現実のフォームで受け取りたくないもの」をデフォルトで閉じています。引用符付きやドメインリテラルは、必要なときだけ明示的に開ける設計です。
validate_email('"john doe"@example.com', allow_quoted_local=True, check_deliverability=False)
validate_email("user@[192.168.0.1]", allow_domain_literal=True, check_deliverability=False)
"john doe"@example.com + allow_quoted_local=True
-> normalized='"john doe"@example.com' domain_address=None
user@[192.168.0.1] + allow_domain_literal=True
-> normalized='user@[192.168.0.1]' domain_address=IPv4Address('192.168.0.1')
開けると通り、ドメインリテラルのときは domain_address に ipaddress.IPv4Address が入る。「閉じた状態が安全側のデフォルト」というのは、自前正規表現には絶対に真似できない気配りです。
64文字制限は strict=True で初めて効く
65 文字が通った件。これは strict がデフォルト False だからでした。
default -> OK 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaa…'
strict=True -> NG The email address is too long before the @-sign (1 character too many).
(1 character too many) まで言ってくれる。長さを厳密に見たいなら strict=True を足す、という一手で済みました。正規表現で量指定子と格闘していたのが馬鹿らしくなります。
国際化アドレスの正規化が気持ちいい
ここがライブラリを使う最大の理由かもしれません。国際化ドメイン(IDN)や国際化ローカル(EAI)を、保存用・送信用に整えてくれます。
for a in ["user@日本語.jp", "ツ@example.com", "USER@Example.COM"]:
v = validate_email(a, check_deliverability=False)
print(a, v.normalized, v.ascii_email, v.smtputf8)
user@日本語.jp
normalized = 'user@日本語.jp'
ascii_email = 'user@xn--wgv71a119e.jp' ← Punycode変換
ascii_domain = 'xn--wgv71a119e.jp'
smtputf8 = False
ツ@example.com
normalized = 'ツ@example.com'
ascii_email = None ← ローカルが非ASCIIだと None
smtputf8 = True ← SMTPUTF8 が必要
USER@Example.COM
normalized = 'USER@example.com' ← ドメインは小文字化、ローカルは保持
日本語.jp が xn--wgv71a119e.jp という Punycode に変換される。ツ@ のようにローカルパートが非 ASCII だと ascii_email は None になり、smtputf8=True(送信に SMTPUTF8 対応サーバが要る)と教えてくれる。
そして地味に重要なのが USER@Example.COM の正規化結果です。ドメインは小文字化されるのに、ローカルパートは大文字のまま。RFC 上、ローカルパートの大小は区別される(理論上 USER@ と user@ は別人になりうる)ので、ライブラリは勝手に潰しません。この「正しさ」が、次の重複アカウント問題で牙をむきます。
第3幕:構文が完璧でも、メールは届かない
ここまでは「形」の話。でも user@example.com は形が完璧でも、現実にはメールが届きません。ドメインがメールを受け取れるか(MX レコードがあるか)は、DNS を引かないと分からない。email-validator は check_deliverability=True(デフォルト)でこれをやってくれます。
for addr in ["user@gmail.com", "user@example.com",
"user@adinte.co.jp", "user@zzz-no-such-domain-9x8q7.jp"]:
try:
v = validate_email(addr, check_deliverability=True)
print("OK ", addr, v.mx)
except EmailNotValidError as e:
print("NG ", addr, e)
OK user@gmail.com MX=[(5,'gmail-smtp-in.l.google.com'), (10,'alt1...'), ...]
NG user@example.com The domain name example.com does not accept email.
OK user@adinte.co.jp MX=[(1,'aspmx.l.google.com'), (5,'alt1.aspmx...'), ...]
NG user@zzz-no-such-domain-9x8q7.jp The domain name zzz-no-such-domain-9x8q7.jp does not exist.
example.com が「does not accept email」で落ちました。サンプルの定番で、なんとなく「有効なアドレスの代表」だと思い込んでいたのですが、実は IANA が予約しているドメインで、Null MX(RFC 7505)が設定されていてメールを一切受け取らない。構文は満点なのに届かない、の完璧な実例です。
存在しないドメインは「does not exist」と、別の理由で落ちる。エラーの粒度が分かれているのもありがたい。
check_deliverability=True は実 DNS を引くので、ネットワーク遅延や一時的な解決失敗の影響を受けます。フォームの同期バリデーションで毎回叩くと詰まることがある。本番では caching_resolver() でキャッシュする、タイムアウトを短くする、あるいは登録後に非同期で確認する、といった運用が現実的です。
なお MX があっても「そのメールボックスが実在するか」までは分かりません。そこまで知りたいなら SMTP で叩くか、外部の検証 API に頼ることになります(やりすぎると相手サーバに嫌われます)。
第4幕:6個のアドレスが、同じ受信箱に届く
最後の地獄は「重複アカウント」です。構文 OK・配信 OK でも、運用ではもう一段ある。下の 6 つはすべて同じ Gmail の受信箱に届きます。つまり同一人物です。
SAME_PERSON = [
"john.doe@gmail.com",
"johndoe@gmail.com", # ドット無視
"j.o.h.n.d.o.e@gmail.com", # ドットは何個でも無視
"JohnDoe@Gmail.com", # 大文字小文字
"johndoe+shopping@gmail.com", # +以降は無視
"johndoe@googlemail.com", # googlemail も gmail も同じ
]
ところが email-validator の normalized に通しても、6 つは 6 つのまま残ります。
john.doe@gmail.com -> normalized=john.doe@gmail.com
johndoe@gmail.com -> normalized=johndoe@gmail.com
j.o.h.n.d.o.e@gmail.com -> normalized=j.o.h.n.d.o.e@gmail.com
JohnDoe@Gmail.com -> normalized=JohnDoe@gmail.com ← ローカルは大文字のまま
johndoe+shopping@gmail.com -> normalized=johndoe+shopping@gmail.com
johndoe@googlemail.com -> normalized=johndoe@googlemail.com
これは email-validator のバグではありません。「ドットや + を無視する」のは RFC のルールではなくGmail という事業者固有のポリシーなので、汎用ライブラリが勝手に潰したら逆に間違いです。第2幕で見た「ローカルパートを小文字化しない」のと同じ、正しい慎重さ。
なので、重複判定は自分のレイヤーで、ドメインを見てやります。
GMAILISH = {"gmail.com", "googlemail.com"}
def canonical(addr: str) -> str:
v = validate_email(addr, check_deliverability=False)
local, domain = v.normalized.rsplit("@", 1)
domain = domain.lower()
if domain in GMAILISH:
local = local.split("+", 1)[0] # +タグを落とす
local = local.replace(".", "") # ドットを落とす
local = local.lower() # Gmailはローカルも大小無視
domain = "gmail.com" # googlemail を gmail に寄せる
return f"{local}@{domain}"
canonical=johndoe@gmail.com
<- john.doe@gmail.com
<- johndoe@gmail.com
<- j.o.h.n.d.o.e@gmail.com
<- JohnDoe@Gmail.com
<- johndoe+shopping@gmail.com
<- johndoe@googlemail.com
6個のアドレス -> 1人
6 個が 1 人に寄りました。無料トライアルの多重取得やクーポンの不正利用を防ぎたいなら、保存用の normalized とは別に、この「重複判定用キー」を持っておくのが効きます。
この正規化は Gmail のポリシーに合わせた割り切りです。+ タグを落とすのは「同一人物判定」には有効ですが、ユーザーが +shopping を意図的に使い分けている場合もある。「重複検知のためのキー」と「実際に送る宛先」は分けて保存するのが安全です。
最後にまとめ。
14 ケースを踏み抜いて出た結論は、「メールアドレス検証は単一の関数ではなく、目的の違う層の積み重ねだ」ということでした。
レイヤーごとに道具が違います。
| レイヤー | 何を見る | 道具 |
|---|---|---|
| 構文 | 形が正しいか |
email-validator(正規表現を自作しない) |
| 長さ | 64/254 オクテット | strict=True |
| 配信 | ドメインが受け取れるか |
check_deliverability=True(MX/Null MX) |
| 重複 | 同じ受信箱か | 事業者ポリシーで自前正規化 |
| 保存 | DB に何を入れるか |
normalized(送信用)と重複キー(検知用)を分ける |