Expressでメールアドレスを検証するには、単一のチェックではなくミドルウェアを組み立てます。しかもそのミドルウェアは、3つの異なる質問に順番に答えなければなりません。アドレスの形式は正しいか。ドメインはメールを受信できるか。メールボックスは本当に存在するか。Express自体はこれらをいずれも備えていませんが、周辺のエコシステムを使えば、それぞれ数行で実現できます。構文にはexpress-validator、ドメインにはNode標準のdns/promises、メールボックスには検証APIを使います。このガイドでは、1つのPOSTルートに3つすべてをバリデーターとして組み立て、フレームワークの守備範囲がどこで終わり、本格的なチェックがどこから始まるのかを示します。Node.jsのメールアドレス検証ガイドのExpress版にあたるため、そちらで説明したDNSとSMTPの考え方はそのまま当てはまります。
結論#
emailフィールドに3つのバリデーターをコストの低い順に連結し、express-validatorの.bail()で、いずれかで結論が出た時点で打ち切ります。構文にはbody('email').isEmail()、ドメインにはdns/promisesのresolveMxをラップした非同期カスタムバリデーター、メールボックスには検証APIを呼び出す2つ目の非同期カスタムバリデーターを使い、判定がundeliverableのときは422を返します。ExpressのプロセスからSMTP接続を開いてメールボックスを調べるのは避けてください。ほとんどのホスティング環境でアウトバウンドのポート25がブロックされており、結果は送信元IPのレピュテーションやグレーリスティングに左右されるため、自前で再現するのは割に合いません。各レイヤーは、後のレイヤーより低いコストでアドレスを除外できます。アドレスを有効と判断できるのはAPIだけです。
レイヤー1:express-validatorによる構文チェック#
express-validatorは、Expressの事実上標準のバリデーションミドルウェアで、validator.jsライブラリをExpress向けに薄くラップしたものです。isEmail()チェックの実体はvalidator.jsのisEmailそのものなので、あらゆるNodeプロジェクトが頼りにしている実績ある構文パーサーを、連結可能なミドルウェアとして使えます。フィールドにルールを宣言すれば、ハンドラーの前に実行されます。
import { body } from "express-validator";
const validateEmail = body("email")
.trim()
.isEmail()
.withMessage("Enter a valid email address.")
.bail();
.trim()は前後の空白を除去するサニタイズ、.isEmail()は形式の検証、.withMessage()はエラーメッセージの設定です。.bail()は構文チェックに失敗した時点でこのフィールドのチェーンを止めるため、後続のコストの高いレイヤーが不正な文字列に対して実行されることはありません。この.bail()は、最もコストの低いチェックで打ち切るためのExpress流の方法です。
これで何が得られるのかを正確に把握しておきましょう。isEmail()が読むのは文字列だけで、DNSを解決せず、ソケットも開きません。definitely-fake@gmail.comは通過します。info@company-that-folded.comも通過します。typo@gmial.comも通過します。3つとも配信できないアドレスですが、文字列を調べるだけのバリデーターには判別できません。これは正規表現によるメールアドレス検証が失敗するのと同じ理由です。構文と到達性は別の問いであり、前者は文字列についての事実、後者はインターネットについての事実です。
レイヤー2:ドメインはメールを受信できるか#
Expressが用意していない最初のレイヤーですが、追加するのは簡単です。MXレコードのないドメインは、誰宛てのメールも受け付けられません。そのため、DNSルックアップを1回行うだけで、ネットワークに本格的なコストをかける前に、存在しないドメイン、会社名のつづり間違い、架空のTLDを除外できます。Node標準のnode:dns/promisesなら依存パッケージなしでMXレコードを解決でき、express-validatorの.custom()は非同期関数を受け付けます。フィールドを失敗させるには関数内でスローし、通過させるにはtrueを返します。
import { resolveMx } from "node:dns/promises";
async function hasMailRoute(email) {
const domain = email.slice(email.lastIndexOf("@") + 1);
let records = [];
try {
records = await resolveMx(domain);
} catch {
// ENOTFOUND / ENODATA — the domain doesn't exist or publishes no MX
throw new Error("This domain cannot receive email.");
}
if (records.length === 0) {
throw new Error("This domain cannot receive email.");
}
return true;
}
これは単純な非同期関数なので、そのままカスタムバリデーターとしてチェーンに組み込めます。ここにも.bail()を置き、実際にメール経路が解決できたドメインに対してのみ、メールボックスのレイヤーが実行されるようにします。
body("email")
.isEmail()
.bail()
.custom(hasMailRoute)
.bail();
await hasMailRoute("jane@gmail.com"); // true
await hasMailRoute("jane@company-that-folded.com"); // throws — no MX
ドメインの中には、MXがなくAレコードでメールを受け付けるもの(暗黙のMX)があります。この例外にも対応したい場合は、resolveMxの結果が空のときにresolve4へフォールバックしてください。ただし、実在するアドレスの圧倒的多数には、MXチェックが適切なフィルターです。
レイヤー3:メールボックスは本当に存在するか#
レイヤー1と2でできるのは、アドレスの除外だけです。ドメインに正しいMXレコードが公開されていても、手元のアドレスにメールボックスがあるとは限りません。noreply-9f2x@gmail.comは、メール経路のあるドメイン上の有効な構文ですが、作成されたことのないメールボックスです。特定のメールボックスを確認するには、SMTPの配信のやり取りが必要です。メールホストに接続してRCPT TOを発行し、応答を読み取り、何も送信せずに切断します。
原則として、netモジュールを使えばExpressからもスクリプトで実装できます。しかし実際には、アプリケーションサーバーから実行すべきではありません。ほとんどのクラウドホストはアウトバウンドのポート25をブロックしており、結果は接続元のIPのレピュテーションに左右され、受信サーバーは見慣れない送信者をグレーリスティングやレート制限の対象にします。そのため、ローカルのテストで動いたプローブが、本番では黙って失敗するか、ブラックリスト入りを招きます。メールアドレス検証の仕組みでは、キャッチオールドメインを含む一連の流れを詳しく解説しています。このレイヤーは、外部に任せる価値があります。
非同期カスタムバリデーターからQualisendを呼び出す#
外部への委任も、もう1つのカスタムバリデーターとして実装できます。Qualisendの検証エンドポイントは、構文チェック、DNS、SMTPによるメールボックスのプローブという一連の処理を、レピュテーションを管理したインフラから実行します。応答は即座に返ります。多くの場合はメールボックスのプローブが終わる前に返りますが、プローブの判定は後から取得できるジョブに届きます。Node 18以降に組み込まれているグローバルのfetchで呼び出し、キーは環境変数から読み取り、待ち時間にはタイムアウトを設けて、判定がundeliverableのときだけスローします。
async function isDeliverable(email) {
let res;
try {
res = await fetch("https://app.qualisend.com/api/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(5000),
});
} catch {
// A timeout or a network error: don't block a real signup.
return true;
}
// Out of credits, rate limited or our own outage: don't block a real signup.
if (!res.ok) return true;
const { job_id, result } = await res.json();
if (result.status === "undeliverable") {
throw new Error("We couldn't confirm a mailbox at this address.");
}
// The mailbox probe is still running: collect its verdict off the request path.
if (result.smtp === "pending") {
recheckLater(email, job_id).catch(() => {});
}
return true;
}
リクエストとレスポンスの全体はAPIリファレンスに記載されています。判定結果は、job_idと並んで{ "result": { ... } }というエンベロープに入って返されます。
{
"job_id": "8c2f4e1a-5b7d-4c3e-9a6f-2d1b0e7c4a91",
"result": {
"status": "unknown",
"score": 60,
"smtp": "pending",
"reason": null,
"sub_flags": { "role": false, "disposable": false, "free_mail": false, "catch_all": false }
}
}
statusはdeliverable、risky、undeliverable、unknownのいずれかです。上のバリデーターが検証エラーにするのはundeliverableだけで、riskyとunknownは通過させます。境界線上のアドレスをフォームエラーにするのではなく、scoreで判断する、disposableなどのsub_flagsの項目で分岐するといった扱いを、後続の処理で決められるようにするためです。上のJSONはあくまで構造の例であり、仕様そのものではありません。フィールドの全一覧は開発者向けドキュメントにあります。
上のサンプルのようにsmtpが"pending"の場合、メールボックスのプローブはまだ実行中です。reasonはnullのままで、最終的な判定はジョブに届きます。recheckLaterの呼び出しは、リクエスト処理とは別にそれを取得し、ジョブが完了するまで数秒おきにGET /jobs/{job_id}をポーリングします。本番環境では、プロセス内でポーリングするのではなく、ジョブIDをキューまたはバックグラウンドワーカーに渡してください。
async function recheckLater(email, jobId) {
const url = `https://app.qualisend.com/api/v1/jobs/${jobId}?include=results`;
const headers = { Authorization: `Bearer ${process.env.QUALISEND_API_KEY}` };
for (let attempt = 0; attempt < 180; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 5000));
let job;
try {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
if (!res.ok) continue;
job = await res.json();
} catch {
continue; // a network blip: try again on the next tick
}
if (job.status === "canceled" || job.status === "failed") return;
if (job.status === "completed") {
const [final] = job.results ?? [];
if (final?.status === "undeliverable") {
await flagEmail(email); // your own follow-up, e.g. ask the user to fix it
}
return;
}
}
}
3つのレイヤーを組み合わせてExpressでメールアドレスを検証する#
3つすべてを1つのbody("email")チェーンにコストの低い順に組み合わせ、各レイヤーの間に.bail()を置きます。失敗した時点で、次のレイヤーが実行される前に打ち切られます。APIバリデーターが実行されるのは、構文チェックとMXチェックの両方を通過したアドレスだけです。
import { body } from "express-validator";
const validateEmail = [
body("email")
.trim()
.isEmail()
.withMessage("Enter a valid email address.")
.bail()
.custom(hasMailRoute)
.bail()
.custom(isDeliverable),
];
この配列をルートハンドラーの前に配置し、収集されたエラーをvalidationResultで読み取ります。チェーンが失敗した場合はエラーの配列とともに422を返します。そうでなければ、すべてのレイヤーを通過しており、アドレスを安全に保存できます。
import express from "express";
import { validationResult } from "express-validator";
const app = express();
app.use(express.json());
app.post("/signup", validateEmail, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
// Every layer passed — safe to create the account.
const { email } = req.body;
return res.status(201).json({ email });
});
この順序こそが肝です。isEmail()が、コードの実行前に不正な入力を打ち切ります。ローカルのMXチェックは、存在しないドメインを無料で除外します。APIが呼ばれるのは、その両方を通過したアドレスだけです。順序を誤ったり、コストの低いレイヤーを飛ばしたりすると、入力ミスのたびにAPIクレジットを1つ消費することになります。検証を確かなものにするのはフレームワークではなく、レイヤーの重ね方です。構成はNode.js版と同じで、パイプラインの役割をExpressのミドルウェアチェーンが担っています。
最初に決めておくべきことが1つあります。API呼び出し自体が失敗したときの扱いです。isDeliverableの中でfetchのネットワークタイムアウトや例外が起きたとき、本物の顧客に、自分では直せない422を返してはいけません。上のバリデーターはすでにこのケースを緩和しています。タイムアウト、ネットワークエラー、402、429、5xxといったOKでないレスポンスのときはtrueを返します。到達できないサービスはundeliverableではなくunknownとして扱い、サインアップは通して、再検証は後に回します。構文とMXのレイヤーはすでにローカルで実行済みなので、緩和するのはネットワークに依存するレイヤーだけです。
この高速な同期パイプラインはデータを収集する時点で実行し、より負荷の高いバッチ検証はリストのクリーニング用に取っておきます。リアルタイムの手法を最初から最後まで示したサーバーレスのサインアップガイドもあります。レイヤー3を支える検証サービスを選ぶ際は、APIの比較で選択肢を並べて確認できます。
よくある質問
01express-validatorのisEmailだけでメールアドレスを検証できますか?
構文チェックであれば十分です。body("email").isEmail()はvalidator.jsのisEmailをラップしたもので、レイヤー1の適切なチェックであり、自前の正規表現よりはるかに優れています。ただし検証できるのは形式であり、到達性ではありません。DNSを解決せず、メールサーバーにも接続しないため、通過しても「メールアドレスの形をしている」という意味で、「配信できる」とは限りません。アドレスを信頼する前に、MXルックアップとメールボックスの確認を組み合わせてください。
02Expressで非同期のカスタムバリデーターを書くにはどうすればよいですか?
express-validatorの.custom()に非同期関数を渡します。関数はフィールドの値を受け取り、通過させるにはtrueを返し(またはresolveし)、失敗させるにはErrorをthrowします(またはrejectします)。スローしたメッセージがそのまま検証エラーになります。前述のhasMailRouteとisDeliverableも、この方法で同じbody("email")チェーンに組み込んでいます。それぞれの前に.bail()を置くと、失敗したレイヤーでチェーンが止まり、次のレイヤーは実行されません。
03APIを使わずにExpressでメールボックスの存在を確認できますか?
部分的にしかできません。node:dns/promisesを使えばドメインがメールを受け付けるかを確認でき、依存パッケージなしで、存在しないドメインを無料で除外できます。メールボックスを確認するにはSMTPのやり取りが必要で、netモジュールで試すことはできますが、アプリサーバーから実行すべきではありません。ポート25は広くブロックされており、結果はIPのレピュテーションにも左右されるからです。この部分を引き受けるのが検証サービスです。用語についてはメールアドレス検証とはをご覧ください。
04unknownの結果には422を返すべきですか、それとも通過させるべきですか?
undeliverableは422で拒否してください。ユーザーが修正できる、確認済みの不正なアドレスだからです。ただし、検証サービス自体の障害を理由に失敗させてはいけません。OKでないレスポンス、タイムアウト、unknownのステータスは、通過させて後で再確認するものとして扱います。そうすれば、一時的なネットワークの問題で本物の登録が止まることはありません。isDeliverableバリデーターは、レスポンスがOKでないときにtrueを返すことで、まさにこれを実現しています。
メールボックスのレイヤーを追加する準備はできましたか?構文が完璧なアドレスを無料メールチェッカーに入力すれば、isEmailが通した文字列がundeliverableで返ってくる様子を確認できます。同じ判定をExpressのミドルウェアに組み込む方法は、コピーして使えるサンプル付きのAPIリファレンスをご覧ください。



