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:
ceo@totallylegit-disposable.com(throwaway address)info@company.com(shared inbox — nobody reads platform emails there)user@nonexistent-domain.xyz(domain has no mail servers)john@catch-all-domain.com(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:
Latency — zero network hop for cache hits
Simplicity — no external dependency for a single-process worker
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 john@gmail.com from an AWS or DigitalOcean server, Google will either:
Reject the connection outright (false
INVALID)Rate-limit you and return
450codes (falseUNKNOWN)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: 'hello@yourdomain.com',
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 a3f8b2e91c4d7a06@domain.com 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: hello@yourdomain.com
S-->>V: 250 OK
V->>S: RCPT TO: target@domain.com
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: target@domain.com
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
info@company.com 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
Layer your defences — each check catches what the previous one missed.
Order by cost — regex is free, DNS is cheap, SMTP is expensive. Run them in that order.
Distinguish temporary from permanent failures — your verdict enum should have more than just
validandinvalid.Skip what you can't verify — major providers block SMTP probes from cloud IPs. Don't fight it.
Detect catch-all domains — they'll accept any address, making SMTP verification useless.
Retry greylisting — one retry after 30 seconds resolves most temporary rejections.
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.
Originally published on ZyVOP
💡 For more articles like this, subscribe to the ZyVOP newsletter!
Top comments (0)