Email Verification for Developers: APIs, Libraries and Implementation Patterns
By Email ExtractorPublished 10 min read
On this page
Email Verification Stack
Email verification involves multiple checks at different layers. Each layer catches different problems and has different cost and speed characteristics:
RFC says case-sensitive; in practice, providers ignore case
Python implementation
import re
def validate_email_syntax(email: str) -> dict:
"""Validate email syntax per RFC 5321/5322 (simplified)."""
result = {
'valid': False,
'normalised': None,
'errors': []
}
if not email or not isinstance(email, str):
result['errors'].append('Email is required.')
return result
email = email.strip()
if len(email) > 254:
result['errors'].append('Email exceeds 254 characters.')
return result
if email.count('@') != 1:
result['errors'].append('Email must contain exactly one @.')
return result
local, domain = email.rsplit('@', 1)
# Local part checks
if not local:
result['errors'].append('Local part (before @) is empty.')
elif len(local) > 64:
result['errors'].append('Local part exceeds 64 characters.')
elif local.startswith('.') or local.endswith('.'):
result['errors'].append('Local part cannot start or end with a dot.')
elif '..' in local:
result['errors'].append('Local part cannot have consecutive dots.')
# Domain checks
if not domain:
result['errors'].append('Domain (after @) is empty.')
elif not '.' in domain:
result['errors'].append('Domain must contain at least one dot.')
elif domain.startswith('-') or domain.endswith('-'):
result['errors'].append('Domain labels cannot start or end with a hyphen.')
elif domain.startswith('.') or domain.endswith('.'):
result['errors'].append('Domain cannot start or end with a dot.')
else:
# Check TLD length
tld = domain.rsplit('.', 1)[-1]
if len(tld) < 2:
result['errors'].append('TLD must be at least 2 characters.')
# Check for valid domain characters
domain_pattern = re.compile(
r'^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?'
r'(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$'
)
if not domain_pattern.match(domain):
result['errors'].append('Domain contains invalid characters.')
if not result['errors']:
result['valid'] = True
result['normalised'] = f"{local}@{domain.lower()}"
return result
Node.js implementation
function validateEmailSyntax(email) {
const result = { valid: false, normalised: null, errors: [] };
if (!email || typeof email !== 'string') {
result.errors.push('Email is required.');
return result;
}
const trimmed = email.trim();
if (trimmed.length > 254) {
result.errors.push('Email exceeds 254 characters.');
return result;
}
const atIndex = trimmed.indexOf('@');
const lastAtIndex = trimmed.lastIndexOf('@');
if (atIndex === -1 || atIndex !== lastAtIndex) {
result.errors.push('Email must contain exactly one @.');
return result;
}
const local = trimmed.substring(0, atIndex);
const domain = trimmed.substring(atIndex + 1);
if (!local || local.length > 64) {
result.errors.push('Local part is empty or exceeds 64 characters.');
}
if (local.startsWith('.') || local.endsWith('.') || local.includes('..')) {
result.errors.push('Local part has invalid dot placement.');
}
if (!domain || !domain.includes('.')) {
result.errors.push('Domain must contain at least one dot.');
}
const tld = domain.split('.').pop();
if (tld && tld.length < 2) {
result.errors.push('TLD must be at least 2 characters.');
}
if (result.errors.length === 0) {
result.valid = true;
result.normalised = `${local}@${domain.toLowerCase()}`;
}
return result;
}
Layer 2: DNS / MX Lookup
Python MX lookup
import dns.resolver
def check_mx_records(domain: str) -> dict:
"""Check if the domain has MX records."""
result = {
'has_mx': False,
'mx_records': [],
'error': None
}
try:
answers = dns.resolver.resolve(domain, 'MX')
result['has_mx'] = True
result['mx_records'] = [
{
'priority': rdata.preference,
'host': str(rdata.exchange).rstrip('.')
}
for rdata in sorted(answers, key=lambda x: x.preference)
]
except dns.resolver.NXDOMAIN:
result['error'] = 'Domain does not exist.'
except dns.resolver.NoAnswer:
result['error'] = 'Domain exists but has no MX records.'
except dns.resolver.NoNameservers:
result['error'] = 'No name servers found for domain.'
except dns.exception.Timeout:
result['error'] = 'DNS lookup timed out.'
except Exception as e:
result['error'] = f'DNS lookup failed: {str(e)}'
return result
import os
class DisposableDomainChecker:
"""Check if an email domain is a known disposable email provider."""
def __init__(self, list_path: str = 'disposable_domains.txt'):
"""Load the disposable domain list from a file."""
self.domains = set()
if os.path.exists(list_path):
with open(list_path, 'r') as f:
for line in f:
domain = line.strip().lower()
if domain and not domain.startswith('#'):
self.domains.add(domain)
def is_disposable(self, domain: str) -> bool:
"""Check if the domain is disposable."""
return domain.lower() in self.domains
def update_list(self, new_domains: list):
"""Add new domains to the list."""
self.domains.update(d.lower() for d in new_domains)
Server accepts all RCPT TO commands; cannot verify individual mailboxes
Greylisting
Server temporarily rejects the first attempt; retry required
Rate limiting
Servers limit verification attempts per IP per time period
IP reputation
High-volume verification from a single IP can get it blocked
SMTP connection blocking
Many providers block port 25 outbound
Anti-verification measures
Some servers always return 250 to prevent verification
Privacy concerns
SMTP verification reveals that you are checking an address
Python SMTP verification (basic)
import smtplib
import dns.resolver
def verify_email_smtp(email: str, timeout: int = 10) -> dict:
"""
Verify an email address via SMTP.
WARNING: Use sparingly. High-volume SMTP verification
can get your IP blocked.
"""
result = {
'email': email,
'smtp_valid': None,
'catch_all': None,
'error': None,
}
domain = email.split('@')[1]
# Get MX records
try:
mx_records = dns.resolver.resolve(domain, 'MX')
mx_host = str(
sorted(mx_records, key=lambda x: x.preference)[0].exchange
).rstrip('.')
except Exception as e:
result['error'] = f'MX lookup failed: {e}'
return result
try:
server = smtplib.SMTP(mx_host, 25, timeout=timeout)
server.ehlo('verify.example.com')
# Check for catch-all
code, _ = server.mail('verify@example.com')
if code != 250:
result['error'] = 'Server rejected MAIL FROM.'
server.quit()
return result
# Test with a random address first (catch-all detection)
code, _ = server.rcpt('randomnonexistent12345@' + domain)
if code == 250:
result['catch_all'] = True
# Test the actual address
code, message = server.rcpt(email)
result['smtp_valid'] = code == 250
server.quit()
except smtplib.SMTPServerDisconnected:
result['error'] = 'Server disconnected.'
except smtplib.SMTPConnectError:
result['error'] = 'Could not connect to SMTP server.'
except Exception as e:
result['error'] = f'SMTP error: {e}'
return result
Layer 5: Third-Party API Integration
API provider comparison
Provider
Syntax
MX
SMTP
Disposable
Catch-all
Spam trap
Free tier
Pricing
ZeroBounce
Yes
Yes
Yes
Yes
Yes
Yes
100/month
From $0.008
NeverBounce
Yes
Yes
Yes
Yes
Yes
No
None
From $0.008
Bouncer
Yes
Yes
Yes
Yes
Yes
No
100 free
From $0.008
Kickbox
Yes
Yes
Yes
Yes
Yes
No
100 free
From $0.01
Abstract API
Yes
Yes
Yes
Yes
Yes
No
100/month
From $0.01
Hunter.io
Yes
Yes
Yes
Yes
No
No
25/month
From $0.01
Emailable
Yes
Yes
Yes
Yes
Yes
No
250 free
From $0.005
Generic API integration pattern
import requests
from functools import lru_cache
import time
class EmailVerificationClient:
"""Generic email verification API client."""
def __init__(self, api_key: str, base_url: str,
rate_limit: int = 10):
self.api_key = api_key
self.base_url = base_url
self.rate_limit = rate_limit # requests per second
self.last_request_time = 0
def _rate_limit_wait(self):
"""Enforce rate limiting."""
now = time.time()
elapsed = now - self.last_request_time
min_interval = 1.0 / self.rate_limit
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
self.last_request_time = time.time()
def verify_single(self, email: str) -> dict:
"""Verify a single email address."""
self._rate_limit_wait()
try:
response = requests.get(
f'{self.base_url}/verify',
params={'email': email},
headers={'Authorization': f'Bearer {self.api_key}'},
timeout=30
)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
return {'error': str(e), 'email': email}
def verify_batch(self, emails: list,
batch_size: int = 50) -> list:
"""Verify emails in batches."""
results = []
for i in range(0, len(emails), batch_size):
batch = emails[i:i + batch_size]
self._rate_limit_wait()
try:
response = requests.post(
f'{self.base_url}/verify/batch',
json={'emails': batch},
headers={
'Authorization': f'Bearer {self.api_key}'
},
timeout=120
)
response.raise_for_status()
results.extend(response.json().get('results', []))
except requests.RequestException as e:
results.extend(
[{'error': str(e), 'email': em} for em in batch]
)
return results
Production Architecture
Caching strategy
Cache layer
What to cache
TTL
Why
Syntax validation results
Local; no cache needed
N/A
Deterministic; instant
MX record lookups
DNS resolver cache
1-24 hours
MX records rarely change
Disposable domain list
In-memory set
Refresh daily
List updates periodically
SMTP verification results
Application cache (Redis)
24-72 hours
Mailbox status can change
Third-party API results
Application cache (Redis)
24-72 hours
Saves API credits
Queue-based architecture for batch verification
Component
Role
API endpoint
Accepts verification requests; returns job ID
Job queue (Redis, RabbitMQ, SQS)
Holds verification jobs
Worker pool
Processes verification jobs in parallel
Cache (Redis)
Stores recent results to avoid re-verification
Results store (database)
Stores verification results long-term
Webhook / polling endpoint
Notifies client when batch is complete
Error handling
Error type
How to handle
DNS timeout
Retry once after 2 seconds; mark as "unknown" if still failing
SMTP connection refused
Skip SMTP layer; rely on other checks
API rate limit (429)
Exponential backoff; retry after the period specified in Retry-After header
API server error (500)
Retry up to 3 times with exponential backoff
Network error
Retry once; mark as "unknown" if still failing
Invalid API key (401/403)
Do not retry; alert and fail
Timeout
Increase timeout; retry once; mark as "unknown"
Pre-Processing Email Lists
Before running verification, extract and deduplicate email addresses from raw data sources. Upload files to Email Extractor to pull email addresses from CSV, XLSX, PDF, HTML, JSON and other formats. Deduplication before verification reduces API costs because you avoid paying to verify the same address twice.