Node.jsでメールアドレスを検証する作業は、実は3つの仕事を1つの名前で呼んでいるものです。多くのチュートリアルは正規表現を示して終わりますが、それはつづりを確認しているだけで、検証とは言えません。本来の検証は層になっています。低コストの構文チェック、DNSルックアップ、SMTPによるメールボックスプローブです。このうち最初の2つは、Node.jsだけで実行できます。このガイドでは、各レイヤーを動くコードで組み立て、どこから検証APIに任せるべきかを示します。
結論#
構文には緩めの正規表現、MXルックアップにはnode:dns/promises、SMTPによるメールボックスの確認には検証APIを使います。この順にコストの低いものから実行し、結果が確定した時点で処理を打ち切ります。アプリサーバーからSMTP接続を開こうとしないでください。多くのホストではポート25がブロックされており、ブロックされていない環境でも、確認の結果は送信IPのレピュテーションに左右されます。グレーリスティングへの対処も必要で、これは自前で作りたくない部分です。
レイヤー1:構文#
正規表現を使ってよいのはここだけです。緩めに書いてください。目的は入力ミスをその場で拾うことで、RFC 5322を再実装することではありません(再実装しても役には立ちません)。
const SYNTAX = /^[^\s@."]+(?:\.[^\s@."]+)*@[^\s@.]+(?:\.[^\s@.]+)+$/;
export function isValidSyntax(email) {
return typeof email === "string" && email.length <= 320 && SYNTAX.test(email);
}
ここに通ったということは「確認する価値がある」という意味で、「有効」という意味ではありません。以降のレイヤーはすべて、構文が正常であることを前提にしています。
レイヤー2:ドメインはメールを受信できるか#
ここでNodeの強みが生きます。組み込みのnode:dns/promisesモジュールは、依存パッケージなしでMXレコードを解決できます。メールの経路がないドメインは誰宛てのメールも受け付けられないため、このルックアップ1回で、存在しないドメインや会社名のつづり間違い、架空のTLDを除外できます。
import { resolveMx } from "node:dns/promises";
export async function hasMailRoute(domain) {
try {
const mx = await resolveMx(domain);
return mx.length > 0;
} catch {
// ENOTFOUND / ENODATA → domain doesn't exist or publishes no MX
return false;
}
}
await hasMailRoute("gmail.com"); // true
await hasMailRoute("company-that-folded.com"); // false
ドメインによっては、MXがなくAレコードだけでメールを受け付けます(暗黙のMX)。この例外にも対応したい場合は、resolveMxの結果が空のときにdns.resolve4へフォールバックしてください。ただし実在するアドレスの大半では、MXチェックが適切なフィルターです。
レイヤー3:メールボックスは実際に存在するか#
レイヤー1と2でできるのは、アドレスを除外することだけです。メールボックスを確認するには、SMTPの配信時のやり取りが必要です。RCPT TOを発行して応答を読み取り、何も送らずに切断します。原理的にはnetモジュールで実行できますが、実際にはおすすめしません。ほとんどのクラウドプロバイダーはアウトバウンドのポート25をブロックしています。結果は接続元のIPのレピュテーションに左右され、サーバーは見慣れない送信元をグレーリスティングしたりレート制限したりします。このレイヤーこそ、外部に任せる価値があります。
QualisendのPOST /verifyは、パイプライン全体を実行して判定を返します。ローカルのチェック結果はすぐに返り、SMTPプローブはキューに入ります。
const BASE = "https://app.qualisend.com/api/v1";
export async function verify(email) {
const res = await fetch(`${BASE}/verify`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
if (!res.ok) throw new Error(`Qualisend responded ${res.status}`);
return res.json();
}
{
"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にはフラグを立てます。result.smtpが"pending"で、SMTPで確認された判定が必要な場合は、プローブが完了するまでバックグラウンドタスクからジョブをポーリングしてください。リクエストハンドラーの中でポーリングしてはいけません。
export async function verifyAndWait(email, { tries = 10, delayMs = 1500 } = {}) {
const { job_id, result } = await verify(email);
if (result.smtp !== "pending") return result; // cached or skipped: already final
for (let i = 0; i < tries; i++) {
const res = await fetch(`${BASE}/jobs/${job_id}?include=results`, {
headers: { Authorization: `Bearer ${process.env.QUALISEND_API_KEY}` },
});
const job = await res.json();
if (job.status === "completed") return job.results[0]; // { status, score, reason, ... }
if (job.status === "failed" || job.status === "canceled") return null;
await new Promise((r) => setTimeout(r, delayMs));
}
return { ...result, job_id }; // probe still running: poll job_id again later, don't call /verify again
}
上の単純なfetchは、実際のトラフィックに載せると2つの点でつまずきます。1つ目は、上流の応答が遅いときにNodeプロセスを止めてはいけないことです。リクエストをAbortControllerのタイムアウトで包み、イベントループをブロックせずに早く失敗させます。2つ目は、429(レート制限)や5xxは一時的なエラーなのでリトライする価値がある一方、400のような4xxはペイロードが誤っているのでリトライしても無駄だということです。そこで両者を分けます。
export async function verify(email, { timeoutMs = 4000 } = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`${BASE}/verify`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: ctrl.signal,
});
if (res.status === 429 || res.status >= 500) {
throw Object.assign(new Error(`retryable ${res.status}`), { retryable: true });
}
if (!res.ok) throw new Error(`Qualisend responded ${res.status}`);
return res.json();
} finally {
clearTimeout(timer);
}
}
サインアップフォームでは、フェイルオープンにします。呼び出しがタイムアウトしたり、402、429、5xxが返ったりした場合は、ユーザーを通過させ、アドレスの検証は後で行います。
APIキーはprocess.envに置き、バンドルには含めないでください。この呼び出しはNodeアプリのサーバー側で行うもので、ブラウザーに配布するコードに入れるものではありません。
リストのクリーニングでは、1件ずつではなく数千件のアドレスを検証します。awaitでループすると直列になって遅く、全件を一度に発行するとレート制限に達します。Promise.allSettledを使って同時リクエスト数を制限して送信すれば、1件のアドレスが拒否されてもバッチ全体が失敗することはありません。
export async function verifyMany(emails, { concurrency = 5 } = {}) {
const out = [];
for (let i = 0; i < emails.length; i += concurrency) {
const slice = emails.slice(i, i + concurrency);
const settled = await Promise.allSettled(slice.map((e) => verify(e)));
out.push(...settled); // each: { status: "fulfilled", value } | { status: "rejected", reason }
}
return out;
}
rejectedのエントリーを集めてバックオフし、それだけをリトライします。fulfilledの判定はすでに完了しています。レート制限はワークスペースごと、1分あたりで、そのキーすべてで共有されます(Freeは60リクエスト、Proは120、Enterpriseは300)。そのため、この方法では長いリストの処理中に429に当たることがあります。リスト全体を処理する場合は、代わりに{ text, expected_count }を指定したPOST /verify/bulkのジョブを1つ送り、GET /jobs/{id}?include=resultsで判定をページごとに取得してください。
3つのレイヤーを組み合わせる#
コストの低い順に実行し、答えが得られた時点で止めます。
export async function validateEmail(email) {
if (!isValidSyntax(email)) return { status: "undeliverable", reason: "invalid_email" };
const domain = email.slice(email.lastIndexOf("@") + 1);
if (!(await hasMailRoute(domain))) return { status: "undeliverable", reason: "invalid_domain" };
const { result } = await verify(email);
return result; // deliverable | risky | undeliverable | unknown, with reason + sub_flags
}
2つのローカルレイヤーはコストがかからず、不正なアドレスの大半を即座に除外できます。APIレイヤーは、ネットワークを往復する価値のあるアドレスにだけ実行されます。この順序がすべてです。各段階がその位置にある理由は、メールアドレス検証の仕組みを参照してください。
よくある質問
01validator.jsやemail-validatorのようなライブラリで十分ですか?
これらのライブラリはレイヤー1の構文チェックをうまく処理するので、自作の正規表現の代わりとして申し分ありません。ただし構文チェッカーなので、MXレコードの解決もメールボックスの確認も行いません。validator.isEmail()に通っても、意味するのは「メールアドレスの形をしている」ことで、「配信できる」ことではありません。上で紹介したDNSとSMTPのレイヤーと組み合わせてください。
02APIなしで、Node.jsからメールアドレスの存在を確認できますか?
一部は可能です。node:dns/promisesを使えば、ドメインがメールを受け付けることを確認でき、存在しないドメインを無料で除外できます。メールボックスの確認にはSMTPプローブが必要です。netモジュールで試すことはできますが、アプリサーバーから実行すべきではありません。ポート25は広くブロックされており、結果はIPのレピュテーションに左右されるからです。そこを引き受けるのが検証サービスです。
03サインアップ時にメールアドレスを同期的に検証すべきですか?
即時に終わるレイヤーは同期的に実行します。構文とMXは十分に高速なので、リクエストをブロックしても、ユーザーにすぐフィードバックを返せます。SMTPの結果は遅れて届く答えとして扱ってください。サインアップ時には即時のローカル判定に基づいて処理し、SMTPで確認された完全な結果はリストのクリーニングに使います。サーバーレスのサインアップガイドで、このパターンを最初から最後まで確認できます。
04同じアドレスを再検証しないよう、検証結果をキャッシュすべきですか?
はい。検証を呼び出すたびにネットワークの往復とクレジットの消費が発生するため、直前に確認したアドレスに対して繰り返さないでください。正規化したメールアドレス(先に小文字化してトリムしたもの)をキーに短時間のキャッシュを作り、判定を数分間再利用します。1点だけ注意があります。unknownやpendingのままの結果はキャッシュしないでください。pendingの判定はまだ最終ではありません。verifyを再度呼び出すのではなく、SMTPプローブの完了後にGET /jobs/{job_id}から最終的な判定を読み取り、unknownは後でリトライします。単一のNodeプロセスなら通常のMapで十分です。複数のインスタンスを動かしてキャッシュを共有する必要があるときだけ、Redisを検討してください。
SMTPレイヤーを追加してみませんか。無料プランには100クレジットが含まれており、パイプライン全体を実行できます。APIリファレンスには、/verifyと/jobsの全エンドポイントについて、7言語のコピー&ペースト用サンプルがあります。



