DEV Community

Faraz Ahmad
Faraz Ahmad

Posted on AI-assisted

Build a Tri-State Email Risk Gate in Node.js

Most email-validation examples finish with code like this:

if (result.valid) {
  acceptEmail();
}
Enter fullscreen mode Exit fullscreen mode

That is convenient—and usually too lossy.

Syntax, DNS, SMTP and risk checks produce different kinds of evidence. A receiving server may be healthy but refuse to reveal whether a mailbox exists. A catch-all domain may accept any recipient. A role account can be valid but unsuitable for one workflow and perfect for another.

In this tutorial, we will wrap an email validation API in a stable Node.js service that returns three decisions:

  • allow
  • review
  • block

The result is easier to test, safer during outages and more honest about uncertainty.

1. Define an internal contract first

Do not let your application depend directly on every field returned by an external provider. Define the output your product needs.

/**
 * @typedef {Object} EmailAssessment
 * @property {"allow"|"review"|"block"} decision
 * @property {string} reason
 * @property {string} status
 * @property {boolean} checked
 * @property {string} checkedAt
 * @property {object=} evidence
 */
Enter fullscreen mode Exit fullscreen mode

The review state prevents two common bugs:

  1. treating an inconclusive SMTP result as proof that an address is bad;
  2. treating a catch-all domain as proof that a specific mailbox exists.

2. Call the API with a deadline

Node.js provides a global fetch() in current releases. Keep the RapidAPI key in an environment variable and copy the exact host from the marketplace snippet.

const RAPIDAPI_HOST = "email-verify-api1.p.rapidapi.com";

async function requestVerification(email, mode = "power") {
  const response = await fetch(
    `https://${RAPIDAPI_HOST}/api/v1/verify`,
    {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-rapidapi-host": RAPIDAPI_HOST,
        "x-rapidapi-key": process.env.RAPIDAPI_KEY,
      },
      body: JSON.stringify({ email, mode }),
      signal: AbortSignal.timeout(mode === "quick" ? 5_000 : 35_000),
    },
  );

  const body = await response.json();

  if (!response.ok) {
    const error = new Error(body.error ?? "Email verification failed");
    error.status = response.status;
    throw error;
  }

  return body.data;
}
Enter fullscreen mode Exit fullscreen mode

RapidAPI expects both authentication headers. The timeout is intentionally longer for a deep check because remote mail servers control part of its latency.

3. Normalize provider evidence

A Power-mode response can expose syntax, MX, SMTP and risk fields. We only select the fields our policy understands.

function normalizeEvidence(raw = {}) {
  return {
    status: String(raw.status ?? "unknown").toLowerCase(),
    score: Number.isFinite(raw.overall_score)
      ? raw.overall_score
      : null,
    safeToSend: raw.is_safe_to_send === true,
    syntaxValid:
      raw.is_valid_syntax === true
        ? true
        : raw.is_valid_syntax === false
          ? false
          : null,
    mxAcceptsMail: raw.mx_accepts_mail === true,
    smtpConnected: raw.can_connect_smtp === true,
    deliverable: raw.is_deliverable === true,
    disposable: raw.is_disposable === true,
    roleAccount: raw.is_role_account === true,
    catchAll: raw.is_catch_all === true,
    inboxFull: raw.has_inbox_full === true,
    disabled: raw.is_disabled === true,
    spamtrap: raw.is_spamtrap === true,
  };
}
Enter fullscreen mode Exit fullscreen mode

Notice that missing Boolean values do not automatically become positive evidence. An absent is_catch_all is different from a confirmed false in some integrations, so tighten this normalization if your application must preserve null explicitly.

4. Apply a policy—not a score threshold

Scores are helpful summaries, but hard-coding score > 70 hides the reason behind a decision. Start with explicit high-risk signals.

function decide(evidence) {
  const blockingStatuses = new Set([
    "invalid",
    "disabled",
    "disposable",
    "spamtrap",
  ]);

  if (
    evidence.syntaxValid === false ||
    evidence.disposable ||
    evidence.disabled ||
    evidence.spamtrap ||
    blockingStatuses.has(evidence.status)
  ) {
    return {
      decision: "block",
      reason: `high-risk result: ${evidence.status}`,
    };
  }

  if (
    evidence.status === "safe" &&
    evidence.safeToSend &&
    !evidence.catchAll
  ) {
    return {
      decision: "allow",
      reason: "positive mailbox and risk signals",
    };
  }

  return {
    decision: "review",
    reason: `inconclusive or contextual result: ${evidence.status}`,
  };
}
Enter fullscreen mode Exit fullscreen mode

Role accounts intentionally fall into context-dependent handling. A SaaS signup may allow them. A consumer rewards program may review them. A support-ticket system should probably expect them.

5. Fail into review during an outage

If the verification service times out, the address does not suddenly become invalid.

export async function assessEmail(email, { mode = "quick" } = {}) {
  const checkedAt = new Date().toISOString();

  try {
    const raw = await requestVerification(email, mode);
    const evidence = normalizeEvidence(raw);

    return {
      checked: true,
      checkedAt,
      status: evidence.status,
      evidence,
      ...decide(evidence),
    };
  } catch (error) {
    return {
      checked: false,
      checkedAt,
      status: "unavailable",
      decision: "review",
      reason: "verification service unavailable",
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Whether review means “allow signup but require confirmation” or “hold the CRM record” belongs to the calling application.

6. Test policy branches without calling the network

The most valuable tests exercise your decisions, not a provider’s live score.

import test from "node:test";
import assert from "node:assert/strict";

test("safe non-catch-all address is allowed", () => {
  const evidence = normalizeEvidence({
    status: "safe",
    is_safe_to_send: true,
    is_valid_syntax: true,
    is_catch_all: false,
  });

  assert.equal(decide(evidence).decision, "allow");
});

test("unknown address is reviewed, not blocked", () => {
  const evidence = normalizeEvidence({ status: "unknown" });
  assert.equal(decide(evidence).decision, "review");
});

test("disposable address is blocked by this policy", () => {
  const evidence = normalizeEvidence({
    status: "disposable",
    is_disposable: true,
  });

  assert.equal(decide(evidence).decision, "block");
});
Enter fullscreen mode Exit fullscreen mode

Add cases for catch_all, role_account, inbox_full, provider timeouts and malformed input.

Quick versus power mode

Use quick mode for latency-sensitive checks. It can evaluate syntax, disposable status, domain/MX readiness and other fast signals, but it does not prove that an individual inbox exists.

Use power mode when deeper SMTP/mailbox evidence justifies a slower response. Even then, preserve unknown: providers may restrict address probing, defer connections or return insufficient evidence.

For signup, a good pattern is quick verification plus a confirmation email. For CRM cleanup, use deeper verification asynchronously.

Production guardrails

Before shipping, add:

  • rate limits to prevent address enumeration;
  • redacted or hashed email logs;
  • explicit retry rules for 429 and transient 5xx responses;
  • latency and status-distribution metrics;
  • short, policy-appropriate cache windows;
  • suppression-list checks that verification cannot override;
  • a clear retention policy for personal data.

Remember: reachability is not consent, and verification is not an inbox-placement guarantee.

Try it with controlled addresses

The Email Verification API on RapidAPI exposes quick and power checks plus an asynchronous bulk workflow. The Basic plan includes 50 requests per month.

I also published a longer implementation guide on StadiaSoft covering bulk polling, failure handling and deliverability boundaries.

Test with addresses you control, inspect every field, and change the policy to match your use case before it touches production traffic.

Disclosure: I publish the RapidAPI product referenced in this tutorial. The code and decision model are provider-aware but intentionally preserve the limitations of SMTP verification.

References

Top comments (0)