You know the drill: a user signs up with asdfgh@notreal.xyz, and your onboarding flow sends a welcome email into the void. Bounce rates climb, your sender reputation tanks, and deliverability drops for everyone.
Most email verification guides tell you to send a confirmation email. But you can catch 80%+ of bad addresses before sending anything — using DNS lookups.
In this tutorial, you'll build an email verification function that checks:
- Syntax — is it a valid email format?
- MX records — can the domain actually receive email?
- SPF records — does the domain have proper email authentication?
- DMARC policy — is the domain protecting against spoofing?
- Disposable domains — is it a throwaway address?
All without sending a single email.
The API
We'll use a free DNS lookup API that resolves any record type — MX, TXT, A, AAAA, NS — with a single HTTP call:
curl "https://api.frostbyte.world/v1/agent-dns/api/resolve/gmail.com/MX"
{
"domain": "gmail.com",
"type": "MX",
"records": [
{ "exchange": "gmail-smtp-in.l.google.com", "priority": 5 },
{ "exchange": "alt1.gmail-smtp-in.l.google.com", "priority": 10 },
{ "exchange": "alt2.gmail-smtp-in.l.google.com", "priority": 20 }
],
"queryTime": 4
}
No API key needed for basic usage, or create a free key for 200 requests.
Step 1: Syntax Validation
Start with the obvious — reject malformed addresses before making any network calls:
function validateSyntax(email) {
// RFC 5322 simplified — covers 99% of real addresses
const re = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
if (!re.test(email)) return { valid: false, reason: 'Invalid email format' };
const [local, domain] = email.split('@');
if (local.length > 64) return { valid: false, reason: 'Local part too long (max 64 chars)' };
if (domain.length > 253) return { valid: false, reason: 'Domain too long (max 253 chars)' };
const parts = domain.split('.');
if (parts.length < 2) return { valid: false, reason: 'Domain must have at least two parts' };
if (parts[parts.length - 1].length < 2) return { valid: false, reason: 'Invalid TLD' };
return { valid: true, local, domain };
}
Step 2: Check MX Records
This is the most important check. If a domain has no MX records, it cannot receive email — period.
const API_BASE = 'https://api.frostbyte.world/v1/agent-dns/api';
async function checkMX(domain) {
const res = await fetch(`${API_BASE}/resolve/${domain}/MX`);
const data = await res.json();
if (!data.records || data.records.length === 0) {
return { hasMX: false, reason: 'No MX records — domain cannot receive email' };
}
// Sort by priority (lower = preferred)
const sorted = data.records.sort((a, b) => a.priority - b.priority);
return {
hasMX: true,
primaryServer: sorted[0].exchange,
totalServers: sorted.length,
records: sorted,
};
}
Example output for gmail.com:
{
"hasMX": true,
"primaryServer": "gmail-smtp-in.l.google.com",
"totalServers": 5
}
Example output for notarealdomain12345.xyz:
{
"hasMX": false,
"reason": "No MX records — domain cannot receive email"
}
Step 3: Check SPF Records
SPF (Sender Policy Framework) tells receiving servers which IPs are authorized to send email for a domain. A domain without SPF is suspicious — legitimate organizations almost always set it up.
async function checkSPF(domain) {
const res = await fetch(`${API_BASE}/resolve/${domain}/TXT`);
const data = await res.json();
if (!data.records) return { hasSPF: false };
const spfRecord = data.records.find(r =>
(typeof r === 'string' ? r : '').startsWith('v=spf1')
);
if (!spfRecord) return { hasSPF: false, reason: 'No SPF record found' };
return {
hasSPF: true,
record: spfRecord,
strict: spfRecord.includes('-all'), // hard fail = strict
softFail: spfRecord.includes('~all'), // soft fail = moderate
neutral: spfRecord.includes('?all'), // neutral = permissive
};
}
For gmail.com, SPF resolves to:
v=spf1 redirect=_spf.google.com
Step 4: Check DMARC Policy
DMARC builds on SPF and DKIM to tell receivers what to do with unauthenticated email. A proper DMARC policy is a strong signal that the domain is professionally managed.
async function checkDMARC(domain) {
const res = await fetch(`${API_BASE}/resolve/_dmarc.${domain}/TXT`);
const data = await res.json();
if (!data.records || data.records.length === 0) {
return { hasDMARC: false, reason: 'No DMARC policy — domain may not be protecting against spoofing' };
}
const dmarcRecord = data.records.find(r =>
(typeof r === 'string' ? r : '').startsWith('v=DMARC1')
);
if (!dmarcRecord) return { hasDMARC: false };
// Parse the policy
const policyMatch = dmarcRecord.match(/p=(\w+)/);
const policy = policyMatch ? policyMatch[1] : 'unknown';
return {
hasDMARC: true,
policy, // none, quarantine, or reject
record: dmarcRecord,
strict: policy === 'reject',
};
}
For gmail.com:
{
"hasDMARC": true,
"policy": "none",
"record": "v=DMARC1; p=none; sp=quarantine; rua=mailto:mailauth-reports@google.com"
}
Step 5: Disposable Email Detection
Disposable email services (Mailinator, Guerrilla Mail, temp-mail, etc.) are used by bots and users trying to bypass verification. Keep a blocklist:
const DISPOSABLE_DOMAINS = new Set([
'mailinator.com', 'guerrillamail.com', 'tempmail.com',
'throwaway.email', 'yopmail.com', 'sharklasers.com',
'guerrillamailblock.com', 'grr.la', 'dispostable.com',
'mailnesia.com', 'temp-mail.org', '10minutemail.com',
'trashmail.com', 'fakeinbox.com', 'mailcatch.com',
// Add more as needed — there are 3000+ disposable domains
]);
function isDisposable(domain) {
return DISPOSABLE_DOMAINS.has(domain.toLowerCase());
}
Tip: Maintain your own list or use a community-maintained list with 3000+ entries.
Putting It All Together
Now combine all checks into a single verification function with a confidence score:
async function verifyEmail(email) {
// Step 1: Syntax
const syntax = validateSyntax(email);
if (!syntax.valid) {
return { email, valid: false, score: 0, reason: syntax.reason };
}
const { domain } = syntax;
let score = 20; // Base score for valid syntax
const checks = {};
// Step 2: Disposable check
if (isDisposable(domain)) {
return { email, valid: false, score: 5, reason: 'Disposable email domain' };
}
// Step 3: MX records
const mx = await checkMX(domain);
checks.mx = mx;
if (!mx.hasMX) {
return { email, valid: false, score: 10, reason: mx.reason, checks };
}
score += 30; // Has MX records
// Step 4: SPF
const spf = await checkSPF(domain);
checks.spf = spf;
if (spf.hasSPF) {
score += 25;
if (spf.strict) score += 5; // Bonus for strict SPF
}
// Step 5: DMARC
const dmarc = await checkDMARC(domain);
checks.dmarc = dmarc;
if (dmarc.hasDMARC) {
score += 20;
if (dmarc.strict) score += 5; // Bonus for reject policy
}
return {
email,
valid: true,
score: Math.min(score, 100),
confidence: score >= 75 ? 'high' : score >= 50 ? 'medium' : 'low',
domain,
checks,
};
}
Test It
// Test with common providers
const emails = [
'user@gmail.com',
'dev@outlook.com',
'fake@notarealdomain12345.xyz',
'spam@mailinator.com',
'bad-format@@.com',
];
for (const email of emails) {
const result = await verifyEmail(email);
console.log(`${email}: score=${result.score} (${result.confidence || 'invalid'})`);
}
Output:
user@gmail.com: score=95 (high)
dev@outlook.com: score=100 (high)
fake@notarealdomain12345.xyz: score=10 (invalid)
spam@mailinator.com: score=5 (invalid)
bad-format@@.com: score=0 (invalid)
Express Middleware
Drop this into your signup flow to reject bad emails before they hit your database:
async function emailVerificationMiddleware(req, res, next) {
const { email } = req.body;
if (!email) return res.status(400).json({ error: 'Email is required' });
const result = await verifyEmail(email);
if (!result.valid) {
return res.status(422).json({
error: 'Invalid email address',
reason: result.reason,
score: result.score,
});
}
if (result.score < 50) {
return res.status(422).json({
error: 'Email domain appears suspicious',
score: result.score,
confidence: result.confidence,
});
}
req.emailVerification = result;
next();
}
// Usage
app.post('/api/signup', emailVerificationMiddleware, (req, res) => {
// Email is verified — proceed with registration
res.json({ message: 'Account created', emailScore: req.emailVerification.score });
});
What This Catches vs. What It Doesn't
This approach catches:
- Typos in domain names (gmal.com, gmial.com)
- Completely fake domains
- Disposable/throwaway emails
- Domains that can't receive email
- Domains without proper email authentication
This approach doesn't catch:
- Valid domains with non-existent mailboxes (e.g.,
nonexistent@gmail.com) - Catch-all servers that accept everything
- Recently deactivated accounts
For those edge cases, you'd need SMTP verification (connecting to the mail server and checking if the mailbox exists) — which is more aggressive and can get your IP blacklisted. The DNS approach above covers 80-90% of bad addresses safely.
Cost
Each verification makes 3 DNS API calls (MX, TXT for SPF, TXT for DMARC). With the free tier, you get:
- 50 requests/day without a key (enough for ~16 verifications/day)
- 200 credits with a free API key (enough for ~66 verifications)
- $1 USDC = 500 credits if you need more (enough for ~166 verifications)
That's roughly $0.006 per email verified — far cheaper than dedicated email verification services that charge $0.01-0.05 per address.
Full Source Code
The complete script (~80 lines) is ready to copy-paste. All dependencies are just fetch — no npm packages needed.
Get a free API key: api-catalog-three.vercel.app
What's your email verification strategy? Do you use DNS checks in production? Let me know in the comments.
Top comments (0)