# Email Verification

> Verify and clean email lists before campaigns, CRM imports, or email platform uploads. DNS/MX/syntax/disposable/role-based checks. Includes Python implementation.

- Skill: `puretechnyc/email-verification` (Agent Skill)
- Install (CLI): `npx skillmds@latest add puretechnyc/email-verification`
- Raw SKILL.md: https://api.skillmd.com/api/skills/puretechnyc/email-verification/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: puretechnyc (https://skillmd.com/u/puretechnyc)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/puretechnyc/email-verification

---


# Email Verification

## Purpose

Verify and clean email lists using multi-level checks: syntax validation, DNS/MX lookup, disposable domain detection, role-based address flagging, and typo domain correction. Ensures only deliverable addresses reach your campaign tools.

## When to Use

- Before any cold outreach campaign push
- Before CRM or list imports
- Before email platform list uploads
- When processing new lead lists from any source
- When cleaning existing contact databases

## Quick Start

### Single email

```bash
python3 email_verifier.py --email test@example.com
```

### CSV batch

```bash
python3 email_verifier.py \
  --input leads.csv \
  --email-column email \
  --output leads_verified.csv \
  --workers 20
```

### Force DNS-only (skip SMTP probe)

```bash
python3 email_verifier.py \
  --input leads.csv \
  --email-column email \
  --output results.csv \
  --workers 20 \
  --skip-smtp
```

## Parameters

| Parameter | Flag | Description | Default |
|-----------|------|-------------|---------|
| Input file | `--input`, `-i` | CSV file with email column | (required for batch) |
| Email column | `--email-column`, `-c` | Column name containing emails | `email` |
| Output file | `--output`, `-o` | Output CSV path | `{input}_verified.csv` |
| Single email | `--email`, `-e` | Verify one email (no CSV needed) | -- |
| Workers | `--workers`, `-w` | Max concurrent threads | `5` |
| Skip SMTP | `--skip-smtp` | Force DNS-only mode | auto-detected |

## Status Meanings

| Status | Meaning | Action |
|--------|---------|--------|
| **VALID** | Domain has MX records, syntax OK, not disposable | Send to campaign |
| **INVALID** | Dead domain, no MX/A records, disposable provider, bad syntax | Remove from list |
| **RISKY** | Role-based address (info@, admin@) or A-record-only domain | Separate low-priority sequence or exclude |
| **UNKNOWN** | DNS timeout or lookup failure | Re-verify later or exclude |

## Flags (in output CSV `flags` column)

| Flag | Meaning |
|------|---------|
| `ROLE_BASED` | Address is a role mailbox (info@, support@, admin@, etc.) |
| `FREE_PROVIDER` | Gmail, Yahoo, Outlook, etc. (relevant for B2B filtering) |
| `NO_MX_A_ONLY` | Domain has A record but no MX record -- may not accept mail |

## Standard Workflow

1. **Verify**: Run the tool on the lead CSV
2. **Split by status**:
   - `VALID` -- push to primary campaign sequence
   - `RISKY` -- push to separate low-priority sequence, or exclude
   - `INVALID` -- remove entirely
   - `UNKNOWN` -- re-verify in next batch or exclude
3. **Push**: Upload clean list to your email platform or CRM

## Implementation

```python
import dns.resolver
import re
import csv
import concurrent.futures
from datetime import datetime, timezone

# Disposable domain blocklist (subset -- expand as needed)
DISPOSABLE_DOMAINS = {
    'mailinator.com', 'guerrillamail.com', 'tempmail.com', 'throwaway.email',
    'yopmail.com', 'sharklasers.com', 'grr.la', 'guerrillamailblock.com',
    'pokemail.net', 'spam4.me', 'trashmail.com', 'dispostable.com',
    # Add 70+ more as needed
}

# Role-based prefixes
ROLE_PREFIXES = {
    'info', 'admin', 'support', 'sales', 'contact', 'help', 'billing',
    'accounts', 'webmaster', 'postmaster', 'abuse', 'noreply', 'no-reply',
    'team', 'office', 'hello', 'careers', 'jobs', 'press', 'media',
    'marketing', 'security', 'privacy', 'compliance', 'legal', 'hr',
    'feedback', 'inquiries', 'enquiries', 'service', 'orders',
}

# Free email providers
FREE_PROVIDERS = {
    'gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com', 'aol.com',
    'icloud.com', 'protonmail.com', 'mail.com', 'zoho.com', 'yandex.com',
}

# Common typo corrections
TYPO_DOMAINS = {
    'gmial.com': 'gmail.com', 'gmai.com': 'gmail.com', 'gamil.com': 'gmail.com',
    'gmali.com': 'gmail.com', 'gmal.com': 'gmail.com', 'gnail.com': 'gmail.com',
    'yahooo.com': 'yahoo.com', 'yaho.com': 'yahoo.com',
    'outlok.com': 'outlook.com', 'outloo.com': 'outlook.com',
    'hotmal.com': 'hotmail.com', 'hotmial.com': 'hotmail.com',
}

def verify_email(email: str) -> dict:
    """Verify a single email address."""
    email = email.strip().lower()
    result = {
        'email': email,
        'status': 'UNKNOWN',
        'reason': '',
        'mx_host': '',
        'flags': [],
        'checked_at': datetime.now(timezone.utc).isoformat(),
    }

    # Syntax check
    if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
        result['status'] = 'INVALID'
        result['reason'] = 'Bad syntax'
        return result

    local, domain = email.rsplit('@', 1)

    # Typo domain check
    if domain in TYPO_DOMAINS:
        result['status'] = 'INVALID'
        result['reason'] = f'Typo domain -- did you mean {TYPO_DOMAINS[domain]}?'
        return result

    # Disposable domain check
    if domain in DISPOSABLE_DOMAINS:
        result['status'] = 'INVALID'
        result['reason'] = 'Disposable email provider'
        return result

    # Role-based check
    if local in ROLE_PREFIXES:
        result['flags'].append('ROLE_BASED')

    # Free provider check
    if domain in FREE_PROVIDERS:
        result['flags'].append('FREE_PROVIDER')

    # DNS/MX lookup
    try:
        mx_records = dns.resolver.resolve(domain, 'MX')
        result['mx_host'] = str(mx_records[0].exchange)
        result['status'] = 'VALID'
        result['reason'] = 'MX record found'
        if result['flags'] and 'ROLE_BASED' in result['flags']:
            result['status'] = 'RISKY'
            result['reason'] = 'Role-based address'
    except dns.resolver.NoAnswer:
        # Try A record fallback
        try:
            dns.resolver.resolve(domain, 'A')
            result['status'] = 'RISKY'
            result['reason'] = 'A record only, no MX'
            result['flags'].append('NO_MX_A_ONLY')
        except:
            result['status'] = 'INVALID'
            result['reason'] = 'No DNS records'
    except dns.resolver.NXDOMAIN:
        result['status'] = 'INVALID'
        result['reason'] = 'Domain does not exist'
    except Exception as e:
        result['reason'] = f'DNS lookup failed: {str(e)}'

    result['flags'] = '|'.join(result['flags'])
    return result
```

## Performance

- ~160 emails/sec in DNS-only mode
- 44K leads in ~5 minutes with `--workers 20`
- Per-domain concurrency capped at 2 with 0.5s delay (built-in rate limiting)

## Limitation

If port 25 is blocked on your server, the tool falls back to DNS-only mode. This means:
- No SMTP mailbox existence check (RCPT TO probe)
- No catch-all domain detection
- Results are domain-level, not mailbox-level

For full SMTP verification, use an external service (e.g., ZeroBounce, NeverBounce) or run from a server with port 25 open.

## Output CSV Columns

| Column | Description |
|--------|-------------|
| `email` | Lowercased, trimmed email |
| `status` | VALID / INVALID / RISKY / UNKNOWN |
| `reason` | Human-readable explanation |
| `mx_host` | MX server hostname (if found) |
| `flags` | Pipe-separated flags (ROLE_BASED, FREE_PROVIDER, NO_MX_A_ONLY) |
| `checked_at` | ISO 8601 UTC timestamp |

---

Built by [PureBrain](https://purebrain.ai) -- AI-powered marketing operations.

Want the full suite of 183+ production-tested skills? [Start your trial ->](https://purebrain.ai/pricing)

