PHP でコンマ区切りのメールアドレスを分割する

4 min read読了の目安(約3800字 1

はじめに

最近でこそユースケースは減ってきていますが, メール送信時における BCC などの用途で,コンマ(,)区切りで列挙された複数のメールアドレスを分割したいという要求はあると思います。ここでは

HelloWorld@example.com,"Hello World"@example.com,"Hello,World"@example.com

を分割して

  • HelloWorld@example.ecom
  • "Hello World"@example.com
  • "Hello,World"@example.com

としたいケースを考えます。思考停止で

「はいはい , で分割するだけでしょ?」

で済ませてしまう方もいらっしゃると思うのですが,世の中には Local Part (@ よりも前の部分)を " " で括ることによって追加の特殊文字の使用を認める quoted-string[1] という形式も存在しています。

これを考慮したコードを書くためには,少し複雑な正規表現を書く必要があります。

実装

実装
function splitEmails(string $emails): array
{
    return preg_split('/(?:"(?:\\\\(?:\\\\|")|[^"])*+"?|[^",])*+(?:\z(*SKIP)(*FAIL)|\K,)/', $emails);
}
結果
// quoted-string が最後でないパターン
var_dump(splitEmails('HelloWorld@example.com,"Hello World"@example.com,"Hello,World"@example.com'));
/*
array(3) {
  [0]=>
  string(25) ""Hello World"@example.com"
  [1]=>
  string(25) ""Hello,World"@example.com"
  [2]=>
  string(22) "HelloWorld@example.com"
}
*/

// quoted-string が最後であるパターン
var_dump(splitEmails('"Hello World"@example.com,"Hello,World"@example.com,HelloWorld@example.com'));
/*
array(3) {
  [0]=>
  string(22) "HelloWorld@example.com"
  [1]=>
  string(25) ""Hello World"@example.com"
  [2]=>
  string(25) ""Hello,World"@example.com"
}
*/

// quoted-pair を含むパターン
var_dump(splitEmails('"Hello\\\\World"@example.com,"Hello\\"World"@example.com,"Hello,World"@example.com'));
/*
array(3) {
  [0]=>
  string(26) ""Hello\\World"@example.com"
  [1]=>
  string(26) ""Hello\"World"@example.com"
  [2]=>
  string(25) ""Hello,World"@example.com"
}
*/

// 入力が壊れているパターン
var_dump(splitEmails('HelloWorld@example.com,He"llo ,W""orld"@example.com,"Hello,World@example.com,Hello,World@example.com'));
/*
array(3) {
  [0]=>
  string(22) "HelloWorld@example.com"
  [1]=>
  string(27) "He"llo,W""orld"@example.com"
  [2]=>
  string(48) ""Hello,World@example.com,Hello,World@example.com"
}
*/

解説

PHP の文字列リテラルとしての \ によるエスケープは取り払った上で,正規表現のみを以下に示します。

/(?:"(?:\\(?:\\|")|[^"])*+"?|[^",])*+(?:\z(*SKIP)(*FAIL)|\K,)/
表現 意味
(?:) 匿名グルーピング。 ?: 無しの ()PREG_SPLIT_DELIM_CAPTURE を使うことによって捕捉対象にできるが,オプションの有無によらず無駄を省くために匿名化している。
*+ ++ 独占的量指定子つきの繰り返し。 *(0回以上の繰り返し)や +(1回以上の繰り返し) はバックトラッキングと呼ばれる再試行を行うが,更に後ろに + を付与することによってそれを抑制している。不必要なバックトラッキングは速度低下を招くので,ここでは明示的にそれが不要であることを示している。
\z $ とほとんど同義だが,厳密に文字列の末尾であることを示す。 $ は最後の改行コード \n の手前でも反応してしまうため, 基本的には \z を使用する方が望ましい。
(*SKIP)(*FAIL) マッチングをここで強制的に失敗させるための言明。 それぞれ別の意味を持つものだが,この 2 つはイディオムのようにセットで使われることがほとんど。
\K マッチング結果で何かをする前に,ここよりも前の部分は無視する。

この正規表現は大きく

  • (?:"(?:\\(?:\\|")|[^"])*+"?|[^",])*+
  • (?:\z(*SKIP)(*FAIL)|\K,)

の 2 つに分かれます。

前半 (?:"(?:\\(?:\\|")|[^"])*+"?|[^",])*+ の説明

  • "(?:\\(?:\\|")|[^"])*+"" で括られた文字列」 を意味します。括られた中では , \\ \" の出現を許可しています。
    • もしエスケープ表現無しの quoted-string だけを考慮する場合は, "[^"]*+" で十分です。
    • quoted-pair と呼ばれるエスケープ表現を考慮する場合, " で括られた中で \\ \" を使うことが認められているので, [^"] の部分に別の選択肢として \\(?:\\|") も与えています。
    • 通常はあり得ないですが, 開いた " に対して閉じる " が見つからないときのために,2つ目の " の後ろに ? を付与しています。これがあることによって,そのような場合にはそこから最後までが括られているとみなされます。
  • " で括られた部分が見つけられないときは, [^",] によって " , 以外の文字」 を探します。

この流れを任意回数繰り返します。

結果的に " で括られた場所を除いて,

  • 文字列の末端
  • 分割すべき , が現れる場所

のいずれかに該当する部分まで確実に消費をすすめることが出来ます。

後半 (?:\z(*SKIP)(*FAIL)|\K,) の説明

  • \z(*SKIP)(*FAIL) により, もしここが末端である場合にはマッチングを終了 します。
    • これを忘れると, 最後のメールアドレスが quoted-string 形式であった場合,再試行で Local Part の中の , を分割すべき対象として誤認識してしまうバグになります。
  • \K, により,ここまでマッチングしてきた内容を無視して, 直後に現れる , 1 文字 だけを preg_split() 関数がデリミタとして扱うべき文字列だと指示しています。

他の言語における実装(?)

この正規表現は (*SKIP)(*FAIL) に大きく依存しているため, Perl や PCRE 使用言語以外への置き換えは難しいかもしれません…

何かいい案があればコメントしてください!

脚注
  1. https://wiki.suikawiki.org/n/quoted-string ↩︎

この記事に贈られたバッジ