en. Combining this with a reputation check creates a defense that adapts to new threats without constant manual updates.
Core Solution
Building a robust email validation pipeline requires moving beyond simple checks. The architecture should separate concerns, handle failures gracefully, and provide actionable signals rather than binary pass/fail results.
Architecture Decisions
- Pipeline Pattern: Validation should be a sequence of checks where each layer adds context. Early exits optimize performance (e.g., reject invalid syntax immediately), while expensive checks (MX lookup) are only performed when necessary.
- Caching Strategy: MX records change infrequently. Querying DNS for every signup is wasteful and introduces latency. Implementing a short-lived cache (e.g., 24 hours) for MX results drastically reduces lookup times and external dependency load.
- Fail-Open Policy: Never block signups solely due to a validation service outage. If the MX resolver or reputation engine fails, the system should log a warning and proceed with a "restricted" account state or accept the signup with elevated risk scoring.
- Risk-Based Response: Instead of hard rejection, consider flagging high-risk emails for additional verification steps, such as phone confirmation or SSO, preserving conversion while mitigating abuse.
Implementation: TypeScript Validation Pipeline
The following example demonstrates a composite validator that integrates syntax parsing, MX resolution with caching, disposable domain detection, and role-based prefix analysis.
import dns from 'dns/promises';
import { isEmail } from 'class-validator';
interface ValidationOutcome {
status: 'ACCEPT' | 'REJECT' | 'REVIEW';
riskScore: number;
flags: string[];
}
interface MxCacheEntry {
exists: boolean;
expiresAt: number;
}
export class EmailDefensePipeline {
private disposableDomains: Set<string>;
private mxCache: Map<string, MxCacheEntry>;
private mxCacheTtlMs: number;
private mxTimeoutMs: number;
constructor(disposableList: string[], options?: { mxCacheTtlMs?: number; mxTimeoutMs?: number }) {
this.disposableDomains = new Set(disposableList.map(d => d.toLowerCase()));
this.mxCache = new Map();
this.mxCacheTtlMs = options?.mxCacheTtlMs ?? 86400000; // 24 hours
this.mxTimeoutMs = options?.mxTimeoutMs ?? 2000;
}
async validate(email: string): Promise<ValidationOutcome> {
const flags: string[] = [];
let riskScore = 0;
// Layer 1: RFC-5322 Syntax Check
if (!isEmail(email)) {
return { status: 'REJECT', riskScore: 100, flags: ['INVALID_SYNTAX'] };
}
const [localPart, domain] = email.split('@');
const normalizedDomain = domain.toLowerCase();
// Layer 2: Role-Based Prefix Detection
const rolePrefixes = ['admin', 'info', 'noreply', 'support', 'postmaster'];
if (rolePrefixes.includes(localPart.toLowerCase())) {
flags.push('ROLE_BASED_ADDRESS');
riskScore += 10;
}
// Layer 3: Disposable Domain Check
if (this.disposableDomains.has(normalizedDomain)) {
flags.push('DISPOSABLE_DOMAIN');
riskScore += 80;
}
// Layer 4: MX Record Lookup with Caching
const mxResult = await this.checkMxRecords(normalizedDomain);
if (!mxResult.exists) {
flags.push('NO_MX_RECORDS');
riskScore += 60;
}
// Determine outcome based on risk score
let status: 'ACCEPT' | 'REJECT' | 'REVIEW' = 'ACCEPT';
if (riskScore >= 80) {
status = 'REJECT';
} else if (riskScore >= 30) {
status = 'REVIEW';
}
return { status, riskScore, flags };
}
private async checkMxRecords(domain: string): Promise<{ exists: boolean }> {
const cached = this.mxCache.get(domain);
if (cached && cached.expiresAt > Date.now()) {
return { exists: cached.exists };
}
try {
const mxRecords = await Promise.race([
dns.resolveMx(domain),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('MX_LOOKUP_TIMEOUT')), this.mxTimeoutMs)
)
]);
const exists = mxRecords.length > 0;
this.mxCache.set(domain, {
exists,
expiresAt: Date.now() + this.mxCacheTtlMs
});
return { exists };
} catch {
// On timeout or error, assume MX exists to fail open
// Log this event for monitoring
return { exists: true };
}
}
}
Rationale:
class-validator for Syntax: Using a library ensures RFC-5322 compliance, catching edge cases that custom regex often misses.
- Risk Scoring: Assigning numeric weights allows flexible policy enforcement. You can adjust thresholds based on business context (e.g., stricter for financial features).
- MX Caching: The
mxCache reduces latency for repeated domains and minimizes DNS query volume.
- Timeout Handling: The
Promise.race ensures the pipeline doesn't hang if DNS resolution is slow. Failures default to accepting MX existence, preventing signup blockage during network issues.
- Role-Based Flagging: Addresses like
admin@ are flagged but not rejected, as they may be legitimate for B2B signups. This reduces false positives.
Pitfall Guide
-
Regex False Security
- Explanation: Developers often rely on simple regex patterns that validate format but ignore domain reality. A regex will happily accept
test@mailinator.com because it is syntactically correct.
- Fix: Treat regex as a baseline filter only. Always follow with domain reputation and MX checks.
-
Blocklist Staleness
- Explanation: Static blocklists degrade over time. Disposable services launch new domains weekly. A list that is three months old may miss hundreds of active disposable domains.
- Fix: Automate blocklist updates or use a composite approach that includes MX analysis, which detects infrastructure patterns regardless of domain age.
-
MX Latency Spikes
- Explanation: DNS lookups can be slow, especially under load or with misconfigured resolvers. Blocking signups during DNS delays hurts conversion.
- Fix: Implement aggressive timeouts and caching. Use a fail-open strategy where MX failures result in a warning rather than a hard block.
-
SMTP Probing Attempts
- Explanation: Some teams attempt to verify mailbox existence by connecting to port 25 and issuing
RCPT TO commands. This is unreliable and dangerous.
- Fix: Avoid SMTP probing. Most cloud providers block outbound port 25 to prevent spam. Additionally, catch-all domains accept any address, making probing ineffective. Rely on MX records as the strongest signal.
-
Information Leakage in Rejections
- Explanation: Error messages like "Mailinator addresses are not allowed" reveal your validation logic to attackers, who can then switch to a different disposable service.
- Fix: Use generic rejection messages such as "Please use a valid email address." For high-risk emails, accept the signup but restrict account features until additional verification is provided.
-
Ignoring Catch-All Domains
- Explanation: Assuming that a domain with MX records guarantees a valid mailbox is incorrect. Many domains use catch-all gateways that accept mail for any address.
- Fix: Treat MX existence as "the domain accepts mail at the gateway." Do not assume the specific inbox exists. Combine MX checks with disposable detection to mitigate catch-all risks.
-
False Positives on Corporate MX
- Explanation: Some legitimate organizations use email providers that share infrastructure with disposable services, or have complex MX setups that trigger false positives.
- Fix: Implement a review queue for borderline cases. Monitor validation metrics to identify and whitelist legitimate domains that are incorrectly flagged.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Early-Stage MVP | Static Blocklist + Syntax | Low latency, easy to implement, covers known threats. | Minimal infrastructure cost. |
| High-Volume SaaS | Composite Pipeline with Caching | Balances coverage, performance, and scalability. Reduces false positives. | Moderate DNS/query costs; offset by reduced abuse. |
| Security-Sensitive App | Full Verification API + Progressive Profiling | Maximum coverage with minimal maintenance. Restricts high-risk accounts. | API subscription costs; higher trust reduces fraud loss. |
| Marketing Newsletter | MX Lookup + Engagement Tracking | Focuses on deliverability and sender reputation. | Low cost; improves inbox placement rates. |
Configuration Template
Use this configuration to tune the validation pipeline for your environment:
const validationConfig = {
mx: {
cacheTtlMs: 86400000, // 24 hours
timeoutMs: 2000, // 2 seconds
failOpen: true // Accept on error
},
riskThresholds: {
reject: 80,
review: 30
},
rolePrefixes: [
'admin', 'info', 'noreply', 'support',
'postmaster', 'webmaster', 'abuse'
],
disposableListUrl: 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf',
updateIntervalMs: 86400000 // Daily update
};
Quick Start Guide
- Install Dependencies: Add
class-validator and configure your DNS resolution library.
- Initialize Pipeline: Create an instance of
EmailDefensePipeline with your disposable domain list and configuration.
- Integrate Middleware: Add the validation step to your signup handler before creating user records.
- Handle Outcomes: Implement logic for ACCEPT (create user), REVIEW (create restricted user), and REJECT (return error).
- Test: Verify behavior with known disposable addresses (e.g.,
test@mailinator.com) and typo domains (e.g., user@gmial.com). Ensure MX caching works and timeouts are respected.