PHPでメールアドレスを検証する処理は、たいてい1行で始まり、1行で終わります。filter_var($email, FILTER_VALIDATE_EMAIL)です。組み込み関数で高速、しかも実用的ですが、本格的な検証で問われる3つの質問のうち、答えられるのは最初の1つだけです。アドレスの形は正しいか。そのドメインはメールを受信できるか。メールボックスは実際に存在するか。PHPの標準ライブラリは、最初の2つには追加なしで答えられますが、3つ目はネットワークの問題であり、外部に任せる価値があります。このガイドでは、PHPのメール検証を層に分けて構築し、各層の動作するコードと、filter_varの限界がどこにあるのかを具体的に示します。
結論#
構文チェックにはFILTER_VALIDATE_EMAILを指定したfilter_var()、MX検索にはcheckdnsrr()またはgetmxrr()、SMTPによるメールボックス確認には検証APIを使います。コストの低い順に実行し、結果が確定した時点で処理を打ち切ります。PHPからSMTPセッションを開いてメールボックスを自前で確認するのはおすすめしません。ほとんどのホスティング環境ではアウトバウンドのポート25がブロックされています。また、結果は送信元IPのレピュテーションやグレーリスティングに左右され、これを自前で再現するのは割に合いません。後の層ほど、アドレスを除外するコストは高くなりますが、アドレスを有効と断定できるのは最後の層だけです。
1層目:filter_varによる構文チェック#
PHPでは、1層目にfilter_varが適しています。正規表現を自作する必要がなく、Stack Overflowから貼り付けるどのコードよりもよくテストされています。FILTER_VALIDATE_EMAILは、成功時にアドレスを返し、失敗時にfalseを返すので、厳密に比較してください。
function is_valid_syntax(string $email): bool
{
return strlen($email) <= 320
&& filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
is_valid_syntax('jane@example.com'); // true
is_valid_syntax('not-an-email'); // false
is_valid_syntax('a@@b.com'); // false
厳密な!== falseには意味があります。filter_varは成功時にtrueではなくフィルター後の文字列を返します。ゆるいif (filter_var(...))が動くのは、空でない文字列が真と評価されるからにすぎません。falseと比較すれば意図が明確になり、リファクタリングにも耐えます。長さのガードは念のための保険です。320文字を超えるアドレスは実在しえないため、巨大な文字列を後続の処理に渡すより、早めに拒否するほうが安上がりです。
このフィルターにできることと、できないことを理解しておきましょう。デフォルトではRFCに基づく文法で検証し、国際化された(Unicodeの)ローカル部を拒否します。そのため用户@example.comは、PHP 7.1以降でFILTER_FLAG_EMAIL_UNICODEフラグを追加しない限り失敗します。これはあくまで構文のフィルターです。DNSには触れず、ソケットも開かず、example.comが存在するかどうかも分かりません。なお、FILTER_SANITIZE_EMAILはまったく別物です。許可されていない文字を取り除いて加工後の文字列を返すだけで、有効かどうかは答えません。検証したいときには使わないでください。
filter_varに成功しても安心できない理由#
filter_varに成功したということは、文字列がメールアドレスの形をしているということです。メールが届くかどうかについては何も示しません。definitely-fake@gmail.comは通ります。info@company-that-folded.comも通ります。typo@gmial.comも通ります。3つとも配信できないアドレスですが、どれほどパターンマッチングを重ねても、filter_varでも、見つけられる限り精巧な正規表現でも、それは判別できません。構文と到達可能性は別の問いだからです。前者は文字列についての事実であり、後者はインターネットについての事実です。これは正規表現によるメール検証が失敗するのと同じ落とし穴で、filter_varも正規表現とまったく同じく、構文の側にあります。
この差を自分の目で確かめたい場合は、構文として完璧なアドレスを無料メールチェッカーに入力してみてください。filter_varが承認した文字列がundeliverableと判定される様子が分かります。
2層目:ドメインはメールを受信できるか#
ここは、PHPが依存関係なしで力を発揮する部分です。メール経路のないドメインは、誰宛てのメールも受け付けられません。そのため、この検索1回で、機能していないドメイン、社名のスペルミス、架空のTLDを除外できます。最も手早いのはcheckdnsrr()で、指定したタイプのレコードが存在するかどうかを真偽値で返します。
function has_mail_route(string $domain): bool
{
// checkdnsrr() returns true if the domain publishes at least one MX record.
// Fall back to A for domains that accept mail on an implicit MX.
return checkdnsrr($domain, 'MX') || checkdnsrr($domain, 'A');
}
has_mail_route('gmail.com'); // true
has_mail_route('company-that-folded.com'); // false
アドレスからドメインを切り出すにはstrrposを使い、必ず最後の@で分割します。
$domain = substr($email, strrpos($email, '@') + 1);
有無の判定ではなく実際のメールホストが必要な場合(たとえばログに残したり優先度を確認したりするとき)は、getmxrr()を使います。参照渡しでホストの配列と対応する重みの配列を埋め、MXレコードがまったくない場合はfalseを返します。
function mail_hosts(string $domain): array
{
$hosts = [];
$weights = [];
if (!getmxrr($domain, $hosts, $weights)) {
return [];
}
array_multisort($weights, $hosts); // lowest weight = highest priority
return $hosts; // e.g. ['gmail-smtp-in.l.google.com', 'alt1.gmail-smtp-in.l.google.com', ...]
}
3層目:メールボックスは実際に存在するか#
1層目と2層目でできるのは、アドレスの除外だけです。ドメインに正しいMXレコードがあっても、手元のアドレスにメールボックスがあるとは限りません。noreply-9f2x@gmail.comは、構文として正しく、メール経路も稼働しているドメイン上にありますが、作成されたことのないメールボックスです。特定のメールボックスが存在することを確認するには、SMTPの配信のやり取りが必要です。メールホストに接続してRCPT TOを発行し、応答を読み、メールを送る前に切断します。実際にはもう少し複雑で、パイプライン全体はメールアドレス検証の仕組みで解説しています。すべてのアドレスを受け付けて単純な確認を無効にするキャッチオールドメインについても、そこで触れています。
原理的には、PHPでもfsockopen()と生のSMTPコマンドでこれを実行するスクリプトを書けます。ただし実際には、アプリケーションサーバーから実行すべきではありません。ほとんどのクラウドプロバイダーはアウトバウンドのポート25をブロックしています。また、結果は接続元のIPのレピュテーションに左右され、受信側サーバーは見慣れない送信元をグレーリスティングしたりレート制限したりします。ローカルのテストでは動作する確認が、本番では気づかないうちに失敗したり、接続元のIPがブラックリストに載る原因になったりするのです。この層こそ、外部に任せる価値があります。
APIで完全なチェックを行う#
QualisendのPOST /verifyは、構文、DNS、SMTPによるメールボックスの確認というパイプライン全体を、そのために構築されたレピュテーション管理済みのインフラから実行し、判定を返します。フレームワークを使わない場合は、小さなcURLのヘルパーで、必要な呼び出しをすべて賄えます。
const QUALISEND_BASE = 'https://app.qualisend.com/api/v1';
function qualisend(string $method, string $path, ?array $body = null): array
{
$ch = curl_init(QUALISEND_BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('QUALISEND_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null,
CURLOPT_TIMEOUT => 10,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw === false || $status >= 400) {
throw new RuntimeException("Qualisend responded {$status}");
}
return json_decode($raw, true);
}
function verify(string $email): array
{
return qualisend('POST', '/verify', ['email' => $email]);
}
すでにGuzzleを使っているなら、同じリクエストを数行短く書けて、JSONとエラーのステータスコードも処理してくれます。
use GuzzleHttp\Client;
$http = new Client(['base_uri' => 'https://app.qualisend.com/api/v1/']);
$response = $http->post('verify', [
'headers' => ['Authorization' => 'Bearer ' . getenv('QUALISEND_API_KEY')],
'json' => ['email' => 'jane@example.com'],
'timeout' => 10,
]);
$result = json_decode((string) $response->getBody(), true);
ローカルのチェックの結果はすぐに返り、SMTPの確認はキューに入ります。
{
"job_id": "6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f",
"probe_queued": true,
"result": {
"email": "jane@example.com",
"status": "deliverable",
"score": 95,
"sub_flags": { "role": false, "disposable": false, "free_mail": false, "catch_all": false },
"did_you_mean": null,
"smtp": "pending",
"reason": null
}
}
リアルタイムのサインアップ検証では、多くの場合、すぐに返るresultだけで対応を決められます。undeliverableは拒否し、did_you_meanの修正候補を提示し、disposableにはフラグを立てます。SMTPで確定した判定が必要な場合は、確認が終わるまでジョブをポーリングします。これが必要なのはresult.smtpがpendingの間だけで、cachedまたはskippedなら最初のresultがすでに確定しています。ループはバックグラウンドワーカーまたはキューから実行し、サインアップのリクエスト内では実行しないでください。アドレスの結果を更新するために/verifyを再度呼び出すのも避けてください。
function verify_and_wait(string $email, int $tries = 10, float $delay = 1.5): ?array
{
$response = verify($email);
if ($response['result']['smtp'] !== 'pending') {
return $response['result']; // cached or skipped: already final
}
$jobId = $response['job_id'];
for ($i = 0; $i < $tries; $i++) {
$job = qualisend('GET', "/jobs/{$jobId}?include=results");
if ($job['status'] === 'completed') {
return $job['results'][0]; // ['status' => ..., 'score' => ..., 'reason' => ...]
}
if ($job['status'] === 'canceled' || $job['status'] === 'failed') {
return null; // the job ended without a verdict
}
usleep((int) ($delay * 1_000_000));
}
return null; // still processing — treat as unknown, retry later
}
層を組み合わせる#
コストの低い順に実行し、答えが出た時点で打ち切ります。
function validate_email(string $email): array
{
if (!is_valid_syntax($email)) {
return ['status' => 'undeliverable', 'reason' => 'invalid_email'];
}
$domain = substr($email, strrpos($email, '@') + 1);
if (!has_mail_route($domain)) {
return ['status' => 'undeliverable', 'reason' => 'invalid_domain'];
}
try {
return verify($email)['result']; // deliverable | risky | undeliverable | unknown
} catch (RuntimeException $e) {
// Timeout or API error (e.g. 402, 429, 5xx): fail open, re-verify later.
return ['status' => 'unknown', 'reason' => 'unknown'];
}
}
ローカルの2つの層はコストがかからず、不要なアドレスの大半をすぐに除外できます。APIの層が実行されるのは、ネットワークへの問い合わせに見合うアドレスだけです。この順序こそが要点です。このガイドのNode.js版とPython版も同じ構造です。検証を成り立たせているのは、言語ではなく層構造だからです。
よくある質問
01PHPでメールアドレスを検証するには、filter_varだけで十分ですか?
構文に関しては十分です。filter_var($email, FILTER_VALIDATE_EMAIL)は1層目のチェックとして適切で、自作の正規表現より確実です。ただし検証するのは形式であり、到達可能性ではありません。DNSの解決もメールサーバーへの接続も行わないため、成功しても意味するのは「メールアドレスの形をしている」ことで、「配信できる」ことではありません。アドレスを信頼する前に、MX検索とSMTPによるメールボックスの確認を組み合わせてください。
02checkdnsrr()とgetmxrr()の違いは何ですか?
checkdnsrr()は、ドメインが指定したタイプのレコードを公開しているかという、有無の問いに答えます。真偽値を返すので、メール経路の存在を確認するだけならこれで十分です。getmxrr()はさらに進んで、実際のMXホスト名とその重みを参照渡しで配列に格納します。メールサーバーの存在を確認するだけでなく、中身を調べたり並べ替えたりしたいときに使ってください。どちらも、一致するレコードがない場合はfalseを返します。
03外部サービスなしでPHPからメールボックスを検証できますか?
一部は可能です。checkdnsrr()とgetmxrr()を使えば、ドメインがメールを受け付けることを確認でき、機能していないドメインを無料で除外できます。標準ライブラリだけで足ります。メールボックスの確認にはSMTPのやり取りが必要です。fsockopen()で試すことはできますが、アプリケーションサーバーから実行すべきではありません。ポート25は広範にブロックされており、結果はIPのレピュテーションに左右されるためです。この層を担うために存在するのが、検証サービスです。
04これらのチェックはサインアップ時とリストのクリーニング時のどちらで行うべきですか?
どちらも行いますが、深さが異なります。構文とMX検索はサインアップ時に同期で実行します。リクエストを止めても支障がない速さで、すぐにフィードバックを返せるためです。APIから即時に返る判定に基づく対応もその場で行います。SMTPで確定した結果は、リストのクリーニングや、時間のかかるバックオフィスの作業用に取っておきます。一連の流れはサーバーレスのサインアップ検証ガイドで解説しています。
SMTPの層を追加する準備はできましたか。無料プランには、パイプライン全体を実行できる100クレジットが含まれています。/verifyと/jobsの全エンドポイントと、7つの言語でそのままコピーして使えるサンプルは、APIリファレンスにあります。



