Skip to content
無料の100クレジットで検証を始める
Qualisend
すべての記事
エンジニアリング / 2026年5月11日

Expressでメールアドレスを検証する方法

約11分で読めます

Qualisend team
Expressでメールアドレスを検証する方法

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リファレンスをご覧ください。

送信者の信頼を、守る。

最初のリストを数分でクリーニング。100クレジット無料、クレジットカード登録は不要です。

今すぐ始める