ZyVOP Logo
Content That Connects
SeriesAI NewsLeaderboardWrite for Us
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • API Documentation
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeCase StudiesHow We Built a Production Email Verification Pipeline That Saved Our Sender Reputation
Case Studies

How We Built a Production Email Verification Pipeline That Saved Our Sender Reputation

A deep dive into multi-layered email validation — from regex to SMTP probing, catch-all detection, and greylisting retries — so your platform never sends to the void.

Sanju Singh
Sanju SinghSenior Developer
August 15, 2026
9 min read
Series

Building ZyVOP in Public

Part 6 of 7

PrevNext
How We Built a Production Email Verification Pipeline That Saved Our Sender Reputation
#email-verification#smtp#deliverability#Backend Engineering#developer tools
👍2

Every platform that sends emails — onboarding sequences, notifications, password resets, newsletters — lives or dies by one metric: deliverability. Send a few dozen emails to dead mailboxes and your domain ends up on a blacklist. Your carefully crafted messages land in spam. Your sender reputation — built over months — evaporates overnight.

We learned this the hard way. This article walks through the production email verification system we built to protect our email sending infrastructure, and the design decisions behind every layer.


The Problem: Why Naive Validation Kills You

Most tutorials tell you to slap a regex on the email field and call it a day:

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

That regex catches obvious typos — user@, @domain.com, spaces — but it happily accepts:

  • [email protected] (throwaway address)

  • [email protected] (shared inbox — nobody reads platform emails there)

  • [email protected] (domain has no mail servers)

  • [email protected] (server accepts everything, but the mailbox doesn't exist)

Every one of these will either bounce, trigger a spam complaint, or waste your send quota. At scale, this compounds into a deliverability crisis.

We needed a pipeline that handles all of these cases before the email ever leaves our server.


The Architecture: 8 Verification Steps

Our SmtpVerifierService runs every candidate email through an ordered sequence of increasingly expensive checks. The philosophy is simple: fail fast, fail cheap.

flowchart TD
    A["Input Email"] --> B["Normalize"]
    B --> C{"Cache Hit?"}
    C -- Yes --> D["Return Cached Result"]
    C -- No --> E["Regex Check"]
    E --> F["Role-Based Check"]
    F --> G["MX Record Lookup"]
    G --> H["Disposable Domain Check"]
    H --> I{"Major Provider?"}
    I -- Yes --> J["Skip SMTP, Mark VALID"]
    I -- No --> K["Catch-All Detection"]
    K --> L["SMTP RCPT TO Probe"]
    L --> M["Cache + Return"]

Each step can short-circuit with a verdict. We defined five possible outcomes:

Verdict

Meaning

Action

VALID

Mailbox confirmed to exist

✅ Safe to send

INVALID

Definitively bad address

❌ Block globally, never send

CATCH_ALL

Server accepts everything

⚠️ Send with caution

RISKY

Role-based or shared inbox

⚠️ Treat carefully based on context

UNKNOWN

Verification inconclusive

⏸️ Skip for now, retry later

export enum EmailVerdict {
  VALID = 'VALID',
  INVALID = 'INVALID',
  CATCH_ALL = 'CATCH_ALL',
  UNKNOWN = 'UNKNOWN',
  RISKY = 'RISKY',
}

The key insight: UNKNOWN is not INVALID. Greylisting, timeouts, and connection resets are temporary. Marking those as invalid would permanently discard good emails. Instead, we skip them and let the next verification cycle retry with a fresh connection.


Step 1: Normalization and Cache Check

Before anything else, we normalize the email and check an in-memory cache:

const normalised = email.trim().toLowerCase();

const cached = this.fromCache(normalised);
if (cached) return cached;

The cache is a simple Map<string, CacheEntry> with a 24-hour TTL. We chose in-memory over Redis for three reasons:

  1. Latency — zero network hop for cache hits

  2. Simplicity — no external dependency for a single-process worker

  3. Volume — we verify hundreds per day, not millions. The map stays small.

A background timer purges expired entries every 6 hours to prevent unbounded memory growth:

const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const CACHE_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;

this.cacheCleanupTimer = setInterval(
  () => this.purgeExpiredCache(),
  CACHE_CLEANUP_INTERVAL_MS,
);
// Allow process to exit even if the timer is still running
if (this.cacheCleanupTimer.unref) {
  this.cacheCleanupTimer.unref();
}

The .unref() call is critical — without it, the timer would keep the Node.js process alive indefinitely, preventing graceful shutdown.


Step 2: Regex Format Check (The Cheapest Gate)

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

if (!EMAIL_REGEX.test(normalised)) {
  return this.cacheAndReturn(
    normalised,
    EmailVerdict.INVALID,
    'Invalid email format',
  );
}

This is intentionally loose. We're not trying to implement RFC 5322 — we're filtering obvious garbage before spending DNS and TCP resources. If it doesn't have a local part, an @, and a dotted domain, it's out.


Step 3: Role-Based Address Filtering

Emails to info@, support@, admin@, or hr@ are shared inboxes, not personal addresses. Sending transactional emails to these addresses often leads to higher bounce and complaint rates, especially when the recipient didn't explicitly sign up.

const ROLE_BASED_PREFIXES: ReadonlySet<string> = new Set([
  'info', 'admin', 'support', 'contact', 'sales',
  'help', 'billing', 'abuse', 'postmaster', 'webmaster',
  'hostmaster', 'security', 'noreply', 'no-reply',
  'mailer-daemon', 'office', 'team', 'hr', 'careers',
  'jobs', 'feedback', 'marketing', 'press', 'media',
]);

We mark these as RISKY instead of INVALID because they are deliverable — they're just not ideal targets for most email flows:

if (ROLE_BASED_PREFIXES.has(localPart)) {
  return this.cacheAndReturn(
    normalised,
    EmailVerdict.RISKY,
    `Role-based address (${localPart}@) — not a personal inbox`,
  );
}

The consuming service can then decide what to do based on context — skip entirely, flag for review, or send with caution.


Step 4: MX Record Validation

If a domain has no MX records, it cannot receive email. Period.

private async checkMx(domain: string): Promise<boolean> {
  try {
    const records = await dns.resolveMx(domain);
    return records.length > 0;
  } catch (err: unknown) {
    const code = (err as NodeJS.ErrnoException).code;
    if (code === 'ENOTFOUND' || code === 'ENODATA') {
      return false; // Domain definitively has no MX records
    }
    // DNS timeout or transient failure — don't penalise the email
    return true;
  }
}

Notice the error handling: ENOTFOUND and ENODATA are definitive — the domain doesn't exist or has no mail records. But a DNS timeout is transient. We give the benefit of the doubt and continue the pipeline rather than falsely rejecting a valid email.


Step 5: Disposable Domain Blocking

deep-email-validator has its own disposable domain list, but it misses some. We maintain a supplementary set of known throwaway domains:

const EXTRA_DISPOSABLE_DOMAINS: ReadonlySet<string> = new Set([
  'mailinator.com', 'guerrillamail.com', 'tempmail.com',
  'throwaway.email', 'yopmail.com', 'sharklasers.com',
  'maildrop.cc', 'discard.email',
  // ... 16 domains total
]);

Anyone using a disposable email to sign up is either testing or has no intention of engaging. We mark these as INVALID and block the address.


Step 6: Major Provider Short-Circuit

Here's a counterintuitive design decision: we skip SMTP verification entirely for Gmail, Outlook, Yahoo, iCloud, and ProtonMail.

const SMTP_PROBE_SKIP_DOMAINS: ReadonlySet<string> = new Set([
  'gmail.com', 'googlemail.com',
  'yahoo.com', 'outlook.com', 'hotmail.com',
  'icloud.com', 'protonmail.com', 'proton.me',
  // ... 22 domains total
]);

Why? These providers actively block SMTP RCPT TO probes from cloud IPs. If you try to verify [email protected] from an AWS or DigitalOcean server, Google will either:

  • Reject the connection outright (false INVALID)

  • Rate-limit you and return 450 codes (false UNKNOWN)

  • Silently accept the probe but flag your IP for suspicious activity

Since MX, regex, and disposable checks already passed, we mark these as VALID and move on. The risk of a false positive on gmail.com is far lower than the risk of false negatives from unreliable SMTP probing.

flowchart TD
    A{"Is domain a major provider?"}
    A -- "Gmail, Outlook, Yahoo..." --> B["Skip SMTP probe"]
    B --> C["Mark VALID"]
    A -- "Custom domain" --> D["Proceed to catch-all detection"]
    D --> E["Then SMTP RCPT TO probe"]

Step 7: Catch-All Domain Detection

Before probing the real address, we probe a randomly generated address at the same domain:

private async detectCatchAll(domain: string): Promise<boolean> {
  const probeAddress =
    `${randomUUID().replace(/-/g, '').slice(0, 16)}@${domain}`;

  const res = await this.runWithTimeout(
    validate({
      email: probeAddress,
      sender: '[email protected]',
      validateSMTP: true,
      validateMx: false, // already verified
    }),
  );

  // If a random gibberish address was accepted, it's catch-all
  return res.valid || res.validators.smtp?.valid === true;
}

If [email protected] is accepted, the domain is a catch-all — it accepts everything. This means verifying the real address is meaningless; the server would say "yes" regardless.

We mark catch-all domains with CATCH_ALL verdict. Consuming services can decide whether to proceed — the address might still be valid, but we can't confirm it.


Step 8: Full SMTP Verification with Greylisting Retry

For custom domains that aren't catch-all, we perform the actual SMTP RCPT TO probe using deep-email-validator:

sequenceDiagram
    participant V as Verifier
    participant S as Mail Server

    V->>S: HELO / EHLO
    S-->>V: 250 OK
    V->>S: MAIL FROM: [email protected]
    S-->>V: 250 OK
    V->>S: RCPT TO: [email protected]
    alt Mailbox exists
        S-->>V: 250 OK → VALID
    else Mailbox not found
        S-->>V: 550 User unknown → INVALID
    else Greylisting
        S-->>V: 450/451 Try again → UNKNOWN
        Note over V: Wait 30 seconds
        V->>S: RCPT TO: [email protected]
        S-->>V: 250 OK → VALID
    end

The most interesting part is greylisting handling. Many mail servers temporarily reject the first delivery attempt from an unknown sender (response codes 450, 451, or messages containing "greylist" or "try again"). This is an anti-spam technique — legitimate senders retry, spammers don't.

Our verifier detects greylisting by inspecting the SMTP response:

const isGreylisting =
  smtpReason.includes('greylist') ||
  smtpReason.includes('try again') ||
  smtpReason.includes('temporarily') ||
  smtpReason.includes('4.7.1') ||
  smtpReason.includes('450') ||
  smtpReason.includes('451');

On the first attempt, we return UNKNOWN with the reason. The outer method then waits 30 seconds and retries exactly once:

const GREYLIST_RETRY_DELAY_MS = 30_000;

const firstAttempt = await attempt(false);

if (firstAttempt.verdict === EmailVerdict.UNKNOWN) {
  await new Promise((r) => setTimeout(r, GREYLIST_RETRY_DELAY_MS));
  return attempt(true);
}

If the retry also fails, we cache the result as UNKNOWN so we stop hammering the server. The next verification cycle will retry organically.


The Timeout Safety Net

Every external call — DNS, SMTP probe, catch-all detection — is wrapped in a configurable timeout:

private runWithTimeout<T>(promise: Promise<T>): Promise<T> {
  let timer: ReturnType<typeof setTimeout> | undefined;

  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(
      () => reject(new Error('SMTP_VERIFY_TIMEOUT')),
      this.smtpTimeoutMs, // Default: 10 seconds
    );
  });

  return Promise.race([promise, timeout]).finally(() => {
    if (timer !== undefined) clearTimeout(timer);
  });
}

The timeout is configurable via environment variable (defaults to 10 seconds). This prevents a single unresponsive mail server from blocking the entire verification queue.


How Consuming Services Use Verdicts

The verification service is a pure function — it takes an email and returns a verdict. Any consuming service then maps each verdict to a domain-specific action:

flowchart TD
    A["Email Address"] --> B["Verify"]
    B --> C{"Verdict?"}
    C -- VALID --> D["Safe to Send"]
    C -- INVALID --> E["Block Globally"]
    E --> F["Never Send Again"]
    C -- UNKNOWN --> G["Skip, Retry Later"]
    C -- CATCH_ALL --> H["Send with Caution"]
    C -- RISKY --> I["Flag for Review"]

The critical detail: INVALID emails aren't just skipped — they're globally blocked. This prevents any part of the application (notifications, newsletters, transactional emails) from ever attempting delivery to that address again.

if (verification.verdict === EmailVerdict.INVALID) {
  await this.mailService.blockEmail(email);
  // Update user record to reflect invalid email status
}

Lessons Learned

1. Never trust SMTP results from major providers

Gmail, Outlook, and Yahoo will lie to your cloud IP. Accept it and skip the probe. Your regex + MX + disposable checks are sufficient for these domains.

2. UNKNOWN ≠ INVALID

This was our most expensive lesson. Early versions marked timeouts and connection errors as INVALID, permanently discarding thousands of valid emails. Treat inconclusive results as inconclusive — let the next cycle retry.

3. Catch-all detection must precede SMTP probing

Without catch-all detection, you'll mark catch-all domain addresses as VALID when they might not be. Probe a random address first. If the server says "yes" to garbage, it'll say "yes" to anything.

4. In-memory cache is fine at moderate scale

We process hundreds of verifications per day, not millions. A Map with TTL and periodic cleanup is simpler, faster, and more reliable than adding a Redis dependency for this workload.

5. Greylisting is more common than you think

About 15–20% of custom domains use greylisting. Without the 30-second retry, you'd mark all of these as UNKNOWN forever and never be able to send to them.

6. Role-based emails need their own verdict

[email protected] is technically deliverable, but sending to shared inboxes often leads to higher complaint rates. A separate RISKY verdict lets consuming services make the right decision without the verification layer overstepping.


The Numbers

After deploying this pipeline, our email metrics changed dramatically:

Metric

Before

After

Bounce rate

~12%

< 1%

Spam complaints per 1000

~8

< 1

Valid emails wrongly blocked

~15%

< 2%

Sender reputation score

Moderate

Excellent


Key Takeaways

  1. Layer your defences — each check catches what the previous one missed.

  2. Order by cost — regex is free, DNS is cheap, SMTP is expensive. Run them in that order.

  3. Distinguish temporary from permanent failures — your verdict enum should have more than just valid and invalid.

  4. Skip what you can't verify — major providers block SMTP probes from cloud IPs. Don't fight it.

  5. Detect catch-all domains — they'll accept any address, making SMTP verification useless.

  6. Retry greylisting — one retry after 30 seconds resolves most temporary rejections.

  7. Block invalid emails globally — not just in the sending flow, but across your entire platform.

The full implementation is ~500 lines of TypeScript in a single NestJS injectable service. No external SaaS dependency, no monthly verification API bill — just DNS, SMTP, and careful engineering.


Building email verification from scratch isn't glamorous, but it's the difference between an email infrastructure that scales and one that gets your domain blacklisted. Invest in the plumbing.


ZyVOP is a developer publishing platform where every post you write natively cross-posts to Dev.to, Hashnode, Medium, and Bluesky — with the canonical URL pointing back to your ZyVOP post. Publish your first post here.

Sanju Singh

Sanju Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Series

Building ZyVOP in Public

Part 6 of 7

PrevNext

Comments (0)

Login to post a comment.

How We Built a Production Email Verification Pipeline That Saved Our Sender Reputation

Related Posts

BESUR: The VS Code Theme That Refuses to Be Boring

BESUR is a family of nine vibrant editor themes with carefully tunned support for JavaScript, Python, SQL, Rust, Go, PHP, XML, YAML, Markdown, and much more.

Read article

FormatSizer: Stop Doing CSS Unit Math in Your Head

FormatSizer displays CSS unit conversions directly beside your code as visual annotations, helping you compare px, rem, em, pt, viewport units, and physical units without interrupting your workflow.

Read article

Why same-Wi-Fi AI agent discovery fails on larger home networks

Your phone and Mac can show the same Wi-Fi icon and still fail automatic discovery. I ran into that exact problem while testing Hermes Mobile, the Android contr...

Read article

Why Cosine Similarity Fails to Catch Confusable MCP Tools

Cosine similarity fails to catch confusable MCP tools. Here's the schema-substitutability approach that actually worked, packaged as an open-source lint tool called mcplock.

Read article

Developer‑Centric AI Hits a Tipping Point: Performance, Open Models, and Scaling Pain | The AI Daily Roundup

Today's headlines show a convergence: AI tools for software development are accelerating performance (Claude Code on Rust‑Bun), opening up (Ollama), but also straining compute (Moonshot) and exposing human‑overreliance (AI advice study). The shift reshapes who wins in the developer market and forces

Read article