メールアドレスのバリデーション

正規表現による簡単なメールアドレスバリデータ

いろいろ話題になったメールアドレスのバリデータですが、勉強がてら、一から作ってみようかと思い立ちました。

資料集め

メールアドレスの書式に関しての最新のドキュメントはIETFの管理するRFC5322です。また、ABNFの書式に関してはRFC5234を参照します。どちらもPlain版をダウンロードしておくと便利です。
RFCはABNFで一通りの仕様が表記されていますので、英語が分からなくても、ABNFの仕様だけを読めば一通りの仕様は理解できます。

ABNFの簡単な説明

ABNFの仕様を簡単に説明します。

  • "@"@(\x40)キャラクターそのもの
  • %x40@(\x40)キャラクターそのもの(16進キャラクターコード表記)
  • %d64@(\x40)キャラクターそのもの(10進キャラクターコード表記)
  • %x30-390(\x30)から9(\x39)までのキャラクターのいずれか
  • A/B はAまたはBのいずれか
  • 1*A はAの1回以上の繰り返し
  • *A はAの0回以上の繰り返し
  • [A] はAが有っても無くてもいい(0または1回の繰り返し)
仕様を考える

なるべく簡単で、広く使えそうなものを考えるということで、フルスペックから以下を制限することにしました。

  • domain-literal([192.168.*.*]のようなもの)は捨て
  • CFWS/FWS(コメントとスペースの類)は捨て
  • quoted-pair(\を使ったエスケープ表現)は捨て
  • コントロールコード(\x00-\x1f,\x7f)は全部捨て

それでは、addr-specから順番に見ていきます。

addr-spec

フルスペックは以下の通りです。

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

domain-literalを捨てます。

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

とりあえずこれで、次は定義されていないdot-atom、quoted-string、obs-local-part、obs-domainを見ていきます。

dot-atom

フルスペックは以下の通りです。

   dot-atom        =   [CFWS] dot-atom-text [CFWS]
   dot-atom-text   =   1*atext *("." 1*atext)

CFWSを捨てて整理した結果、以下の通りになります。

   dot-atom        =   1*atext *("." 1*atext)
obs-local-part

フルスペックは以下の通りです。

   obs-local-part  =   word *("." word)
   word            =   atom / quoted-string
   atom            =   [CFWS] 1*atext [CFWS]

CFWSを捨てて整理した結果、以下の通りになります。

   obs-local-part  =   (1*atext / quoted-string)
                       *("." (1*atext / quoted-string))
obs-domain

フルスペックは以下の通りです。

   obs-domain      =   atom *("." atom)
   atom            =   [CFWS] 1*atext [CFWS]

CFWSを捨てて整理した結果、以下の通りになります。

   obs-domain      =   1*atext *("." 1*atext)

dot-atomと等しくなりました。

addr-spec 再び
   addr-spec       =   local-part "@" domain
   local-part      =   dot-atom / quoted-string / obs-local-part
   domain          =   dot-atom / obs-domain

整理していきます。local-partのdot-atomはobs-local-partに含まれる表現であり、quoted-stringはobs-local-partに含まれる表現なので、全てobs-local-partに統一します。
また、domainのobs-domainとdot-atomが同一なので、obs-domainに統一します。

   addr-spec       =   local-part "@" domain
   local-part      =   obs-local-part
   domain          =   obs-domain

整理すると以下の通りになります。

   addr-spec       =   obs-local-part "@" obs-domain

さっぱりしました。これを起点にして、obs-local-partとobs-domainの定義をしていきます。

obs-local-part/obs-domain 再び
   obs-local-part  =   (1*atext / quoted-string)
                       *("." (1*atext / quoted-string))

   obs-domain      =   1*atext *("." 1*atext)

atextとquoted-stringが定義されていないので、定義していきます。

atext

フルスペックは以下の通りです。

   atext           =   ALPHA / DIGIT /    ; Printable US-ASCII
                       "!" / "#" /        ;  characters not including
                       "$" / "%" /        ;  specials.  Used for atoms.
                       "&" / "'" /
                       "*" / "+" /
                       "-" / "/" /
                       "=" / "?" /
                       "^" / "_" /
                       "`" / "{" /
                       "|" / "}" /
                       "~"
   ALPHA           =  %x41-5A / %x61-7A   ; A-Z / a-z
   DIGIT           =  %x30-39             ; 0-9

整理すると以下の通りになります。

   atext           =   %x21 / %x23-27 /
                       %x2a-2b / %x2d /
                       %x2f-39 / %x3d /
                       %x3f / %x41-5a /
                       %x5e-7e
quoted-string

フルスペックは以下の通りです。

   quoted-string   =   [CFWS]
                       DQUOTE *([FWS] qcontent) [FWS] DQUOTE
                       [CFWS]
   DQUOTE          =   %x22               ; " (Double Quote)
   qcontent        =   qtext / quoted-pair

CFWSとFWSとquoted-pairを捨てて整理すると、以下の通りになります。

   quoted-string   =   %x22 *(qtext) %x22

最後にqtextを定義していきます。

qtext

フルスペックは以下の通りです。

   qtext           =   %d33 /             ; Printable US-ASCII
                       %d35-91 /          ;  characters not including
                       %d93-126 /         ;  "\" or the quote character
                       obs-qtext
   obs-qtext       =   obs-NO-WS-CTL
   obs-NO-WS-CTL   =   %d1-8 /            ; US-ASCII control
                       %d11 /             ;  characters that do not
                       %d12 /             ;  include the carriage
                       %d14-31 /          ;  return, line feed, and
                       %d127              ;  white space characters

コントロールコードを捨てて整理すると、以下の通りになります。

   qtext           =   %x21 / %x23-5b / %x5d-7e
仕様のまとめ
   qtext           =   %x21 / %x23-5b / %x5d-7e

   quoted-string   =   %x22 *(qtext) %x22

   atext           =   %x21 / %x23-27 /
                       %x2a-2b / %x2d /
                       %x2f-39 / %x3d /
                       %x3f / %x41-5a /
                       %x5e-7e

   obs-local-part  =   (1*atext / quoted-string)
                       *("." (1*atext / quoted-string))

   obs-domain      =   1*atext *("." 1*atext)

   addr-spec       =   obs-local-part "@" obs-domain

これでABNFでの仕様が完成です。次は、これを正規表現へ変換します。

正規表現に変換

ABNF表記を正規表現にするのは簡単です。ルールは以下の通り。

  • A / B(A|B)に置き換え(AとBがそれぞれ一文字の場合は[AB]に置き換え)
  • *AA* に置き換え
  • 1*AA+ に置き換え

Perl(および互換)の正規表現の場合、()の代わりに(?:)を使うことで、パターンの記憶を制限して、時間やメモリなどを節約できます。

my $qtext = "[\x21\x23-\\\x5b\\\x5d-\x7e]";
my $quoted_string = "\x22$qtext*\x22";
my $atext = "[\x21\x23-\x27\x2a-\x2b\x2d\x2f-\x39\x3d\x3f\x41-\x5a\x5e-\x7e]";
my $obs_local_part = "(?:$atext+|$quoted_string)(?:\\.(?:$atext+|$quoted_string))*";
my $obs_domain = "$atext+(?:\\.$atext+)*";
my $addr_spec = "$obs_local_part\@$obs_domain";

m/^$addr_spec\z/;

テストケースを考える

仕様に沿っているかどうか、テストしてみたいのでテストケースを考えます。幸いにも、それぞれのパートの内容が被ることがない(例えば、local-partのatextに"@"が含まれないのでlocal-partとdomainを個別に考えていい)ので、テストケースもパートごとに分けて単純に考えることができます。
状態遷移図を描いて、全てのパターンをたどるテストケースを作ります。

超適当な状態遷移図

addr_spec のテストケース

obs-local-partをA、obs-domainをBとして、終端に#を置いて、書き直して考えてみます。

A @ B #

テストケースは下の通りになります。

成功するケース
A @ B #

失敗するケース
#
A #
A @ #
obs-local-part のテストケース

atext+をA、quoted-stringをBとして、終端に#を置いて、書き直して考えてみます。

( A | B ) ( . ( A | B ) )* #

テストケースは下の通りになります。

成功するケース
A #
B #
A . A #
A . B #
B . A #
B . B #

失敗するケース
#
A . #
B . #
obs-domain のテストケース

atext+をAとして、終端に#を置いて、書き直して考えてみます。

A ( . A )* #

テストケースは下の通りになります。

成功するケース
A #
A . A #

失敗するケース
#
A . #
quoted-string のテストケース

qtextをAとして、終端に#を置いて、書き直して考えてみます。

" A* " #

テストケースは下の通りになります。

成功するケース
"" #
" A " #

失敗するケース
#
" #
" A #
atextとqtext のテストケース

単純にatextとqtestに含まれる文字が成功するケースで、含まない文字が失敗するケースになります。

テストケース
our @valid_case = (
	'foo@example.com',
	'"foo"@example.com',
	'foo.bar@example.com',
	'foo."bar"@example.com',
	'"foo".bar@example.com',
	'"foo"."bar"@example.com',
	'foo@localhost',
	'""@example.com',
	'09AZaz!#$%&\'*+-/=?^_`{|}@example.com',
	'"09AZaz!#$%&\'()*+,-./:;<=>?@[]^_`{|}"@example.com',

	'{^_^}/~@example.com',
	'~/a.out@example.com',
	'"cellular.."@example.com',
);
our @invalid_case = (
	'',
	'foo',
	'foo@',
	'@example.com',
	'foo.@example.com',
	'"foo".@example.com',
	'foo@',
	'foo@example.',
	'"@example.com',
	'"foo@example.com',
	"\x08".'@example.com',
	':@example.com',
	"\"\x0d\x0a\"".'@example.com',

	'foo@example.com'."\n",
	'cellular..@example.com',
);

最後に

再帰構造が出てくるCFWパートをばっさり切ったことで、かなり単純になり、正規表現でも簡単に表せるようになりました。BNFを読み書きできれば、正規表現もそれほど難しいことはありません。自分好みの正規表現を作ってみてはいかがでしょうか。
お疲れさまでした。