3
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

メールアドレスの正規表現、`\S+@\S+` で済ませていた自分へ。14ケースで全部踏み抜いた記録

3
Last updated at Posted at 2026-06-14

はじめに

前に「受信メールのパース地獄」について書きました。

今回はもっと手前、ユーザー登録フォームの「このメールアドレス、正しい形式?」を判定するだけの話です。\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 ケースです。各アドレスに「実務的に受理すべきか(期待値)」を付けておきます。

scripts/01_naive_regex.py(抜粋)
# (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.comuser@[192.168.0.1] が「有効」なのに違和感がありますか? 私もそうでした。でも RFC 5321/5322 上はどちらも合法です。引用符で囲めばローカルパートにスペースを入れられるし、ドメイン部に IP アドレスを角括弧で直書きすることも許されています。実際に出会うことはほぼないですが、「有効」と言われると弾きにくい。

逆に a..b@example.com(ドット連続)や .user(先頭ドット)は無効です。ドットは区切り文字であって、連続させたり端に置いたりはできない。このあたりが、素朴な正規表現の踏み抜きポイントになります。

第1幕:正規表現4種に殴らせる

用意した「武器」は 4 つです。雑なものから、ネットで神格化されている怪物まで。

scripts/01_naive_regex.py(抜粋)
# 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.comuser@日本語.jp(国際化)を**「たまたま」正解している**点です。[^@\s] が「@ と空白以外」なので、Unicode 文字を平然と通してしまう。雑すぎて、逆に国際化対応できている。皮肉な話です。

(3) 「RFC 5322 Official Standard」怪物のほころび

emailregex.com などで「これが公式の正規表現だ」と崇められている、あの怪物です。実際、構文の精度は飛び抜けていて、FP は 65 文字のケースだけ(長さは後述)。a..buser@-bad.com もきっちり弾く。さすがです。

ところが FN を見ると、"john doe"@example.com弾いている。引用符付きローカルパートに対応しているはずの怪物が、です。これは予想と違ったので、文字クラスを目を凝らして読みました。

"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[...])*"
                            ~~~~  ↑ \x20(スペース)が無い

\x1f の次が \x21 で、\x20(スペース)が範囲からすっぽり抜けている。引用符の中ならスペースは合法なのに、この「公式」は通さない。コピペで持ってきた正規表現が、名前ほど万能ではなかったわけです。

そしてもう一つ、ツ@example.comuser@日本語.jp も全滅。怪物は ASCII しか相手にしていません。国際化アドレス(EAI)は構造上、この正規表現の外側にいます。

「RFC 5322 準拠」を名乗る正規表現は世にたくさんありますが、引用符・コメント・国際化まで完全に網羅したものは現実的に書けません。仮に書けても、半年後の自分がメンテできない呪文になります。

(4) HTML5 標準は、ドットの位置を見ない

ブラウザの <input type="email"> が内部で使う、WHATWG 制定の正規表現です。これは「実用的な落とし所」として有名で、私も最初は本命だと思っていました。

結果は 6/14。FP に a..b.useruser. が並びます。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 は引かず、構文だけ)。

scripts/02_email_validator.py(抜粋)
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_addressipaddress.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)を、保存用・送信用に整えてくれます。

scripts/02_email_validator.py(抜粋)
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'          ← ドメインは小文字化、ローカルは保持

日本語.jpxn--wgv71a119e.jp という Punycode に変換される。ツ@ のようにローカルパートが非 ASCII だと ascii_emailNone になり、smtputf8=True(送信に SMTPUTF8 対応サーバが要る)と教えてくれる。

そして地味に重要なのが USER@Example.COM の正規化結果です。ドメインは小文字化されるのに、ローカルパートは大文字のまま。RFC 上、ローカルパートの大小は区別される(理論上 USER@user@ は別人になりうる)ので、ライブラリは勝手に潰しません。この「正しさ」が、次の重複アカウント問題で牙をむきます。

第3幕:構文が完璧でも、メールは届かない

ここまでは「形」の話。でも user@example.com は形が完璧でも、現実にはメールが届きません。ドメインがメールを受け取れるか(MX レコードがあるか)は、DNS を引かないと分からない。email-validatorcheck_deliverability=True(デフォルト)でこれをやってくれます。

scripts/03_deliverability.py(抜粋)
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 の受信箱に届きます。つまり同一人物です。

scripts/04_normalize_dedup.py(抜粋)
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-validatornormalized に通しても、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幕で見た「ローカルパートを小文字化しない」のと同じ、正しい慎重さ。

なので、重複判定は自分のレイヤーで、ドメインを見てやります。

scripts/04_normalize_dedup.py(抜粋)
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(送信用)と重複キー(検知用)を分ける
  1. HTML Living Standard は <input type=email> 用の正規表現を定義しつつ、それが「a willful violation of RFC 5322」であると明記している。実装容易性のために、連続ドットなど一部の不正形式を許容する代わりに、コメントや一部の合法形式を拒否する割り切りになっている。

  2. RFC 5321 では、ローカルパート 64 オクテット、ドメイン 255 オクテット、forward/reverse-path は山括弧込みで 256 オクテット。山括弧 <> を除いた実効上限がアドレス全体で 254 文字、という関係になっている。

3
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?