メイルアドレス正規表現ふたたび

(2017.7.1) 新規作成。そんなに新規でもないが。

(2017.7.2) IPv4/IPv6アドレスに対応。

"メールアドレス 正規表現"ぐらいで検索すると、上位に、微妙だったり不適切な内容のページが並ぶ。つらい。

あまつさえ、現実に, いまだに '+' が通らないWeb上の商業サービスもあって、妥当な処理が行なわれていない。

とても古い話題のはずが、まともな解説だけが時代の彼方に去って、訳の分からない記事が跋扈しているのか?

とはいえ、メイルアドレスは仕様が複雑すぎて、意外と難しい。

HTML Living Standard での定め

WHATWG HTML Living Standard には, input[type="email"] 要素で、クライアント側の妥当性検査が盛り込まれている。

これの要求が微妙。結論として、この機能を使ってはならない。

そもそも、クライアント側検査はあくまでも補助的なもの。サーバ側の検査は省略できない(悪意のあるデータなどの可能性も)。また、メイルボックスの実在性は、実際にメイルを投げてみない限り分からない。

厳密に検査できないなら、ありえないものだけを弾いてあとはサーバに任せる、という風にしなければならない。ところが、この規定では、妥当なメイルアドレスも弾いてしまうようになっている。これはいけない。

email
= 1*( atext / "." ) "@" label *( "." label )
label
= let-dig [ [ ldh-str ] let-dig ]
; limited to a length of 63 characters by RFC 1034 section 3.5
atext
= < as defined in RFC 5322 section 3.2.3 >
let-dig
= letter | digit
; defined in RFC 1034 section 3.5
ldh-str
= 1*( let-dig-hyp )
; defined in RFC 1034 section 3.5

さらに、正規表現として次が載っている;

/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/

'@' の後ろ, ドメインについて IPアドレスでの表現を禁止するのは、名前が引けないメイルサーバは公開サーバとしてありえないので、考え方として分かる。HTML (フォーム) の利用場面だし。

一方, '@' の前, local-part のほうは, quoted-string を除外したい気持ちは分からないでもないが、代替的な書き方がないので、やり過ぎではないか。

RFC 5322 ではなく RFC 5321 を参照する

RFC 5322 Internet Message Format (2008年10月) が権威あることになっている。前の RFC 2822 (April 2001) から考えると, すでに15年以上経過している。

が、この page: メールアドレスの正規表現 の解説のように, 厳密に RFC の addr-spec に従うと, コメント, FWS (空白・タブ・改行), 制御文字 (後退など) すら許容することになる。

RFC 5322 は, メイルのヘッダというばらつきが非常に大きいテキストデータのなかから、メイルアドレスらしいものを抽出するためのルールで、何でもかんでもマッチするようになっている。

どこまで RFC 5322 から離れるか、どこで一線を引くかの見極めが大事。世間で RFC に従っていると主張しているものも、本当に準拠しているものは存在しない。(何も考えない勝手定義のは論外だが.)

  • コメントと空白文字を除外
  • 制御文字を除外
  • 旧形式を除外

ここまでは異論は少ないと思う。次は判断のしどころ。適用業務による。HTML LS は両方とも除外を選んでいる。

  • IPアドレス形式によるドメイン
  • local-partquoted-string

後のセクションでは、両方とも含める形で正規表現を作ってみる。

ドメイン (ホスト名)

RFC 5322 での '@' の後ろ, domain は次のようになっている;

addr-spec
= local-part "@" domain
domain
= dot-atom / domain-literal / obs-domain

dot-atom の許容範囲が広すぎて、およそホスト名としてありえないものまでマッチしてしまう。

これまでの話をひっくり返すようだが、RFC 5322 を出発点とするのが, そもそもどうかしている。

RFC 5321 Simple Mail Transfer Protocol のほうを参照しなければならない。

RFC 5321 では, 次のようになっている;

Domain
= sub-domain *("." sub-domain)
sub-domain
= Let-dig [Ldh-str]
Let-dig
= ALPHA / DIGIT
Ldh-str
= *( ALPHA / DIGIT / "-" ) Let-dig
address-literal
= "[" ( IPv4-address-literal / IPv6-address-literal / General-address-literal ) "]"
; See Section 4.1.3
Mailbox
= Local-part "@" ( Domain / address-literal )

メイルアドレスと言われてイメージするような、まっとうな定義になっている。(用途が違うから。)

ホスト名は, 数字で始まってよい (RFC 1123 section 2.1; October 1989). しかし、すべてが数字だけだと IPアドレスと区別できない。上記に加えて, Javaで参照されている RFC 2609 の制限を使う。

See Is it valid for a hostname to start with a digit? - Server Fault

メイルアドレスの正規表現

という訳で, 上記の方針に沿って正規表現を作ってみる。Perlメモのものを参照しつつ, Ruby に書き直した。RFC 5321 を出発点として, ホスト名の部分を変更した。

Ruby
[RAW]
  1. ##################################
  2. # local-part
  3. # タブ文字は除外. 空白, ',', '"', '\' も書ける.
  4. quoted_pair = %Q<\\\\[\\x20-~]> # ("\" (VCHAR / WSP))
  5. atext = %Q<[A-Za-z0-9!#\$%&'*+\\-/=?^_`{|}~]>
  6. dot_string = %Q<(?:#{atext}{1,64}(?:\\.#{atext}{1,62}){0,31})>
  7. qtext = %Q<[\\x21\\x23-\\x5B\\x5D-\\x7E]> # '"' と '\' 以外.
  8. qcontent = %Q<(?:#{qtext}|#{quoted_pair})>
  9. quoted_string = %Q<"#{qcontent}{0,62}">
  10. # <var>obs-local-part</var> は除外.
  11. # local-part は 64オクテットまで. (RFC 5321 section 4.5.3)
  12. local_part = %Q<(?:#{dot_string}|#{quoted_string})>
  13. ##################################
  14. # address-literal
  15. # <var>domain-literal</var> を有効にする場合. <var>obs-domain</var> は除外.
  16. dtext = %Q<[\\x21-\\x5A\\x5E-\\x7E]> # 次を除く: '[' '\' ']'
  17. # 値の範囲まで見ていない
  18. ipv4_address = %Q<(?:[0-9]{1,3}(?:\\.[0-9]{1,3}){3})>
  19. ipv6_hex = %Q<(?:[0-9A-Fa-f]{1,4})>
  20. # 簡略化している
  21. # ::ffff:192.0.2.1
  22. # ::1
  23. ipv6_address = %Q<(?:#{ipv6_hex}?(?:(?:::|:)#{ipv6_hex}){1,7}(?:(?:::|:)#{ipv4_address})?)>
  24. # RFC 5321 の定義.
  25. ldh_str = %Q<(?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])>
  26. # https://www.iana.org/assignments/address-literal-tags/
  27. # 2017.7.25 これはやりすぎ。外す.
  28. #general_address = %Q<(?:[a-zA-Z]#{ldh_str}?:#{dtext}{1,61})>
  29. #address_literal = %Q<(?:\\[(?:#{ipv4_address}|#{ipv6_address}|#{general_address})\\])>
  30. address_literal = %Q<(?:\\[(?:#{ipv4_address}|#{ipv6_address})\\])>
  31. ##################################
  32. # domain
  33. # 一つのサブドメインは63バイト以内 (RFC 1034 section 3.5)
  34. sub_domain = %Q<[A-Za-z0-9]#{ldh_str}?>
  35. toplabel = %Q<[A-Za-z]#{ldh_str}?>
  36. # RFC 2609 の制限 (トップレベルは英字必須)
  37. # domain は 255オクテットまで. (RFC 5321 section 4.5.3)
  38. domain = %Q<(?:(?:#{sub_domain}\\.){0,127}#{toplabel})>
  39. # RFC 5321 による.
  40. mailbox = %Q<(#{local_part})@(#{domain}|#{address_literal})> # $1, $2
  41. $mail_regex = Regexp.new(mailbox)

できた正規表現。先頭の ^ と末尾の $ は付けていない。

(?-mix:((?:(?:[A-Za-z0-9!#$%&'*+\-\/=?^_`{|}~]{1,64}(?:\.[A-Za-z0-9!#$%&'*+\-\/=?^_`{|}~]{1,62}){0,31})|"(?:[\x21\x23-\x5B\x5D-\x7E]|\\[\x20-~]){0,62}"))@((?:(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.){0,127}[A-Za-z](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)|(?:\[(?:(?:[0-9]{1,3}(?:\.[0-9]{1,3}){3})|(?:(?:[0-9A-Fa-f]{1,4})?(?:(?:::|:)(?:[0-9A-Fa-f]{1,4})){1,7}(?:(?:::|:)(?:[0-9]{1,3}(?:\.[0-9]{1,3}){3}))?))\])))

(2020.3.28) IPv4射影アドレス (IPv4-Mapped IPv6 address) は, ::ffff 固定なので, ipv6_address のなかで通常のIPv6アドレスと並列に書いたほうがよい。See RFC 5952.

Test cases

妥当なメイルアドレス、不正なのを、いろいろ集めてみた。

email-address-test-cases.txt

結果。SHOULDが 'Y' のものが通すべきもの。部分一致したものは、どこで一致したかを表示している。

弾くべきだが通ってしまったものがいくつかある。しかし、まぁ、上出来だと思う。

テストパタン SHOULD 結果 コメント
email@domain.com Y MATCHED
firstname.lastname@domain.com Y MATCHED
email@subdomain.domain.com Y MATCHED
firstname+lastname@domain.com Y MATCHED '+'を弾くアホがある
1234567890@domain.com Y MATCHED
_______@domain.com Y MATCHED
firstname-lastname@domain.com Y MATCHED
-email@domain Y MATCHED 先頭が'-'も可.
abcABC123.defDEF456@ghiGHI789.comCOM012 Y MATCHED
abc.#%&'/=~`*+?{}^$-|@ghi.com Y MATCHED
Abc@example.com Y MATCHED
Abc.123@example.com Y MATCHED
user+mailbox/department=shipping@example.com Y MATCHED
customer/department=shipping@example.com Y MATCHED
!#$%&'*+-/=?^_`.{|}~@example.com Y MATCHED
!def!xyz%abc@example.com Y MATCHED
Abc.@example.com N , $1 = , $2 =
Abc..123@example.com N 123@example.com, $1 = 123, $2 = example.com
.dot_kara_hazimaru@example.com N dot_kara_hazimaru@example.com, $1 = dot_kara_hazimaru, $2 = example.com
I.like.you.@example.com N , $1 = , $2 =
I..love...you@example.com N you@example.com, $1 = you, $2 = example.com
abc.def@#%&'/=~`*+?{}^$-|.com N , $1 = , $2 =
ab<c.def@ghi.com N c.def@ghi.com, $1 = c.def, $2 = ghi.com
abc.de<f@ghi.com N f@ghi.com, $1 = f, $2 = ghi.com
.email@domain.com N email@domain.com, $1 = email, $2 = domain.com
email.@domain.com N , $1 = , $2 =
email..email@domain.com N email@domain.com, $1 = email, $2 = domain.com
あいうえお@domain.com N , $1 = , $2 =
"e#$$%&@>mail"@domain.com Y MATCHED '@'や'>'を含められる
"em,ail"@localhost Y MATCHED カンマも可
"Abc@def"@example.com Y MATCHED
"Fred\ Bloggs"@example.com Y MATCHED '\'に続ければ空白すら可
"Joe.\\Blow"@example.com Y MATCHED '\'自身
"Joe.\"Blow"@example.com Y MATCHED '"'も含められる.
".dot_kara_hazimaru"@example.com Y MATCHED
"I.likeyou."@example.com Y MATCHED
"I..love...you"@example.com Y MATCHED
email@domain-one.com Y MATCHED
email@domain.name N MATCHED (制限) トップレベルの実在性までは確認できない
email@domain.co.jp Y MATCHED
email@localhost Y MATCHED ドメインにピリオドがなくても可
a@a Y MATCHED
a@0.a Y MATCHED
a@a-a.com Y MATCHED
a@0-a.com Y MATCHED
a@a-0.com Y MATCHED
a@a-a.a-a Y MATCHED
email@-domain.com N , $1 = , $2 = RFC 5322 だと通ってしまう. RFC 5321 で制約.
email@-.-.-.- N , $1 = , $2 = RFC 5322 だと通ってしまう. RFC 5321 で制約.
email@123.123.123.123 N , $1 = , $2 = RFC 2609 で制約.
abc.def@ghi.#%&'/=~`*+?{}^$-| N abc.def@ghi, $1 = abc.def, $2 = ghi
abc.def@gh<i.com N abc.def@gh, $1 = abc.def, $2 = gh
abc.def@ghi.co<m N abc.def@ghi.co, $1 = abc.def, $2 = ghi.co
a@0 N , $1 = , $2 =
a@0.0 N , $1 = , $2 =
a@a.0 N a@a, $1 = a, $2 = a
a@.a N , $1 = , $2 =
a@a-.a N a@a, $1 = a, $2 = a
a@-a.a N , $1 = , $2 =
email@domain..com N email@domain, $1 = email, $2 = domain
email@[123.123.123.123] Y MATCHED
a@[255.255.255.255] Y MATCHED
a@[001.002.003.004] Y MATCHED
a@[2001:0db8:bd05:01d2:288a:1fc0:0001:10ee] Y MATCHED
a@[2001:db8:20:3:1000:100:20:3] Y MATCHED
a@[2001:db8::1234:0:0:9abc] Y MATCHED
a@[2001:db8::9abc] Y MATCHED
a@[::1] Y MATCHED localhost
ea@[::ffff:255.255.255.255] Y MATCHED IPv4射影アドレス
a@[::ffff:0:255.255.255.255] N MATCHED IPv4射影アドレスとして正しくない.
email@[111.222.333.44444] N , $1 = , $2 = 不正なIPv4アドレス
a@[0.0.0.0] N MATCHED (制限) 正しくないが, 構文では弾けない
a@[255.255.255.256] N MATCHED (制限) 正しくないが, 構文では弾けない
a@[example.com] N , $1 = , $2 =
a@[example.com:hoge] N , $1 = , $2 =
a@[fuga:xxxxxxx] N , $1 = , $2 = (制限) 正しくないが、構文にはマッチ
a@[2001:0db8:bd05:01d2:288a::1fc0:0001:10ee] N MATCHED 1ヶ多い
a@[2001:0db8:bd05:01d2:288a:1fc0:0001:10ee:11fe] N , $1 = , $2 = 1ヶ多い
a@[::] N , $1 = , $2 =
a@[0::0] N MATCHED
a@[1::] N , $1 = , $2 =
a@[1:2:3:4:5:6:7::] N , $1 = , $2 = 数が合わない
a@[::255.255.255.255] N , $1 = , $2 = IPv4互換アドレス。廃止
a@[2001:db8:3:4::192.0.2.33] N MATCHED IPv4射影アドレスとして不正。構文では弾けない
a@[64:ff9b::192.0.2.33] N MATCHED IPv4射影アドレスとして不正。構文では弾けない
a@abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.com Y MATCHED 63バイトOK
a@abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345678901.com N a@abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890, $1 = a, $2 = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890 64バイトNG
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/@example.com Y MATCHED 64バイトOK
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/a@example.com N bcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/a@example.com, $1 = bcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/a, $2 = example.com 65バイトNG
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567+/"@example.com Y MATCHED 制限は local-part に掛かっている
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567+/a"@example.com N , $1 = , $2 = 65バイトNG
abcdefhghijklmnopqrstuvwxyzABC@aaaaaaaa01.aaaaaaaa02.aaaaaaaa03.aaaaaaaa04.aaaaaaaa05.aaaaaaaa06.aaaaaaaa07.aaaaaaaa08.aaaaaaaa09.aaaaaaaa10.aaaaaaaa11.aaaaaaaa12.aaaaaaaa13.aaaaaaaa14.aaaaaaaa15.aaaaaaaa16.aaaaaaaa17.aaaaaaaa18.aaaaaaaa19.aaaaaaaa20.aaaaaaaa21.aaaaaaaa22.aaaaaaaa23 Y MATCHED domain = 252オクテット
abcdefhghijklmnopqrstuvwxyzABCD@aaaaaaaa01.aaaaaaaa02.aaaaaaaa03.aaaaaaaa04.aaaaaaaa05.aaaaaaaa06.aaaaaaaa07.aaaaaaaa08.aaaaaaaa09.aaaaaaaa10.aaaaaaaa11.aaaaaaaa12.aaaaaaaa13.aaaaaaaa14.aaaaaaaa15.aaaaaaaa16.aaaaaaaa17.aaaaaaaa18.aaaaaaaa19.aaaaaaaa20.aaaaaaaa21.aaaaaaaa22.aaaaaaaa23.zz Y MATCHED domain = 255
abcdefhghijklmnopqrstuvwxyzABCD@aaaaaaaa01.aaaaaaaa02.aaaaaaaa03.aaaaaaaa04.aaaaaaaa05.aaaaaaaa06.aaaaaaaa07.aaaaaaaa08.aaaaaaaa09.aaaaaaaa10.aaaaaaaa11.aaaaaaaa12.aaaaaaaa13.aaaaaaaa14.aaaaaaaa15.aaaaaaaa16.aaaaaaaa17.aaaaaaaa18.aaaaaaaa19.aaaaaaaa20.aaaaaaaa21.aaaaaaaa22.aaaaaaaa23.zzz N MATCHED domain = 256
"abcdefhghijklmnopqrstuvwxyzABC"@aaaaaaaa01.aaaaaaaa02.aaaaaaaa03.aaaaaaaa04.aaaaaaaa05.aaaaaaaa06.aaaaaaaa07.aaaaaaaa08.aaaaaaaa09.aaaaaaaa10.aaaaaaaa11.aaaaaaaa12.aaaaaaaa13.aaaaaaaa14.aaaaaaaa15.aaaaaaaa16.aaaaaaaa17.aaaaaaaa18.aaaaaaaa19.aaaaaaaa20.com Y MATCHED
"abcdefhghijklmnopqrstuvwxyzABCD"@aaaaaaaa01.aaaaaaaa02.aaaaaaaa03.aaaaaaaa04.aaaaaaaa05.aaaaaaaa06.aaaaaaaa07.aaaaaaaa08.aaaaaaaa09.aaaaaaaa10.aaaaaaaa11.aaaaaaaa12.aaaaaaaa13.aaaaaaaa14.aaaaaaaa15.aaaaaaaa16.aaaaaaaa17.aaaaaaaa18.aaaaaaaa19.aaaaaaaa20.com Y MATCHED
plainaddress N , $1 = , $2 =
@domain.com N , $1 = , $2 =
Joe Smith <email@domain.com> N email@domain.com, $1 = email, $2 = domain.com
email.domain.com N , $1 = , $2 =
email@domain@domain.com N email@domain, $1 = email, $2 = domain
email@domain.com (Joe Smith) N email@domain.com, $1 = email, $2 = domain.com
email@ example N , $1 = , $2 = 空白を含む. CFWSはエラーにすべき.
"foo"."bar"@example.com N "bar"@example.com, $1 = "bar", $2 = example.com 旧形式

外部リンク

まともそうなページだけ。