Article content and detailed guides remain in English. The selected language applies to controls and quick instructions.

Back to articles

Email Verification for Developers: APIs, Libraries and Implementation Patterns

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:

Layer What it checks Speed Cost Accuracy
Syntax validation Format compliance (RFC 5321/5322) Instant Free Catches obvious errors only
DNS / MX lookup Whether the domain can receive email 50-200ms Free Catches non-existent domains
Disposable domain check Whether the domain is a temporary email provider Instant (local list) Free or cheap Depends on list freshness
Role-based detection Whether the local part is a role (info@, admin@) Instant (local list) Free High accuracy
SMTP verification Whether the specific mailbox exists 1-10s Free (DIY) or per-check Variable; catch-all domains return false positives
Third-party API All of the above plus reputation, spam trap, catch-all detection 100-500ms Per-verification pricing Highest overall accuracy

Layer 1: Syntax Validation

RFC-compliant validation

Simple regex patterns miss edge cases. Here is what the RFCs actually allow:

Rule Valid example Notes
Local part max 64 characters aaaa...@example.com RFC 5321
Total max 254 characters a@very-long-domain-name...com RFC 5321
Dots allowed (not leading/trailing/consecutive) first.last@example.com RFC 5322
Plus addressing user+tag@example.com RFC 5233; widely supported
Hyphens in domain user@my-company.com Must not be leading or trailing
Numbers in local part user123@example.com Valid
Case insensitive domain user@EXAMPLE.COM = user@example.com RFC 5321; always lowercase for comparison
Case sensitivity of local part User@example.com may differ from user@example.com 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

Node.js MX lookup

const dns = require('dns').promises;

async function checkMxRecords(domain) {
  try {
    const records = await dns.resolveMx(domain);
    const sorted = records.sort((a, b) => a.priority - b.priority);
    return {
      hasMx: true,
      mxRecords: sorted.map(r => ({
        priority: r.priority,
        host: r.exchange,
      })),
      error: null,
    };
  } catch (err) {
    return {
      hasMx: false,
      mxRecords: [],
      error: err.code === 'ENOTFOUND'
        ? 'Domain does not exist.'
        : `DNS lookup failed: ${err.message}`,
    };
  }
}

Layer 3: Disposable and Role-Based Detection

Disposable domain detection

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)

Sources for disposable domain lists:

Source URL Format
disposable-email-domains (GitHub) github.com/disposable-email-domains/disposable-email-domains Text file; community-maintained
FakeFilter github.com/fakefilter/fake-filter JSON; regularly updated
Burner Email Providers github.com/wesbos/burner-email-providers JSON; community-maintained

Role-based detection

ROLE_BASED_PREFIXES = {
    'abuse', 'admin', 'billing', 'compliance',
    'contact', 'devnull', 'dns', 'ftp',
    'help', 'hostmaster', 'info', 'inoc',
    'ispfeedback', 'ispsupport', 'legal',
    'list', 'list-request', 'mailer-daemon',
    'marketing', 'noc', 'no-reply', 'noreply',
    'null', 'office', 'phish', 'postmaster',
    'privacy', 'registrar', 'remove', 'request',
    'role', 'root', 'sales', 'security',
    'spam', 'subscribe', 'support', 'sysadmin',
    'tech', 'undisclosed-recipients', 'unsubscribe',
    'usenet', 'uucp', 'webmaster', 'www',
}

def is_role_based(email: str) -> bool:
    """Check if the email is a role-based address."""
    local = email.split('@')[0].lower()
    # Remove plus addressing
    local = local.split('+')[0]
    return local in ROLE_BASED_PREFIXES

Layer 4: SMTP Verification

SMTP verification connects to the mail server and checks whether the mailbox exists without sending an email:

How SMTP verification works

Step SMTP command Server response What it means
1. Connect (TCP connection to MX host, port 25) 220 service ready Server accepts connections
2. Greet EHLO verify.example.com 250 OK Server identifies itself
3. Sender MAIL FROM:verify@example.com 250 OK Server accepts the sender
4. Recipient RCPT TO:target@domain.com 250 OK or 550 User unknown This is the verification
5. Quit QUIT 221 Bye Close connection

SMTP verification caveats

Issue Details
Catch-all 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.

Extract emails

Explore tools

Verify emails

Check address validity before using your list.

ZeroBounce

Email Verification

Verifies email lists and provides tools for monitoring deliverability.

Useful when list cleaning and sender health belong in one workflow.

Explore ZeroBounce (opens in a new tab)