# Email Verifier & Validator — Bulk Email List Cleaner, $0.75/1k (`yabanana99/email-list-cleaner-verifier`) Actor

Clean a contact list end to end: syntax check, normalization, deduplication, disposable and role detection, MX lookup, 0-100 scoring and segmented CSV export (clean / risky / role / invalid). Reads addresses buried in free text. Flat $0.75 per 1,000 on every plan — duplicates are never charged.

- **URL**: https://apify.com/yabanana99/email-list-cleaner-verifier.md
- **Developed by:** [Daniele Giovane](https://apify.com/yabanana99) (community)
- **Categories:** Lead generation, Automation, Agents
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.75 / 1,000 email verifieds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

**$0.75 per 1,000 — the same price on every plan, and duplicates are free.**
Most verifiers on the Store price by plan tier (the most popular one bills **$100 per 1,000** on the Free plan, $1.00 Bronze, $0.80 Silver, $0.60 Gold+). This one has a single flat rate for everybody, and because duplicate rows are never charged twice, a list with the typical 18% of duplicates costs about **$0.62 per 1,000 rows** you upload.

Upload the CSV your CRM exported and get back a **deduplicated, normalized, verified and segmented** list — plus `clean.csv`, `risky.csv`, `role.csv` and `invalid.csv` ready to import, and a hygiene report that tells you what was actually wrong with your list.

### Why this Actor

- **Deduplication that understands mailbox providers.** Gmail dots, `+tags` (only where the provider implements sub-addressing), `googlemail.com`, letter case, IDN/punycode, `Jane Doe <jane@…>` wrappers, `mailto:` prefixes. Every duplicate is reported with `duplicate_of`, `occurrences` and whether a plain string comparison would have found it (`exact`) or only normalization did (`normalized`). Search the Store for "email dedup" and you will see why this exists.
- **Files in, files out.** CSV, TSV, XLSX, JSON, JSON Lines and plain-text lists — up to 20 files merged and deduplicated *together*. The email column is auto-detected **and always reported**, wide CRM exports (hundreds of columns) are read to the end, and `include_source_row` gives you back every other column, so the cleaned list is still a contact list.
- **It finds addresses buried in text.** `write to p7@ex.com for info`, `Referred by ana@acme.io — call first`: a notes or comment column full of prose is not a dead end. The address is extracted, verified and deduplicated like any other, the original cell is kept in `email_original`, and the record says so (`note:address_extracted_from_surrounding_text`). When a cell holds several addresses the **first non-role one wins** (`Sent by no-reply@sys.com on behalf of mario@acme.com` → `mario@acme.com`), and punctuation glued to the address is trimmed (`contatto: p1@ex.com.Grazie` → `p1@ex.com`, not a dead `ex.com.grazie`). Verifiers that only accept one clean address per row return "invalid" for every one of those rows — and bill you for them.
- **Segments you can act on.** Every address gets a 0-100 score, an **explicit confidence level** and a `clean` / `risky` / `invalid` segment, exported as ready-to-import CSV files.
- **Honest about what DNS and SMTP can prove.** Nothing is ever upgraded from "we could not check" to "valid", and **we charge the full price only when we actually looked something up** — see [Pricing](#pricing-pay-per-event) and [The truth about SMTP verification](#the-truth-about-smtp-verification).

Everything runs inside the Actor container: no third-party verification API, no LLM, no data leaving the platform except the DNS queries needed to look up MX records. See [Your data (and GDPR)](#your-data-and-gdpr).

### Input

Provide inline addresses, files, or both — they are merged and deduplicated together.

```json
{
    "emails": ["J.Smith+news@Gmail.com", "jsmith@gmail.com", "info@apify.com"],
    "sources": [
        { "url": "https://example.com/exports/contacts.csv" },
        { "key": "hubspot-export.xlsx", "store_id": "abcDEF123" },
        { "base64": "bmFtZSxlbWFpbAo...", "filename": "list.csv" }
    ],
    "email_column": "",
    "dedup": true,
    "normalize": true,
    "check_mx": true,
    "check_smtp": false,
    "detect_disposable": true,
    "detect_role": true,
    "role_segment": "clean",
    "score": true,
    "output_segments": true,
    "include_source_row": false
}
```

| Option | Values | Default | What it does |
| --- | --- | --- | --- |
| `emails` | array of strings | – | Inline addresses |
| `sources` | array of `{url}` / `{base64}` / `{key,store_id}` | – | Up to **20 files**, CSV/TSV/XLSX/JSON/JSONL/TXT |
| `email_column` | string | auto | Header name of the email column; empty = auto-detect |
| `dedup` | boolean | `true` | `true`: one record per unique address. `false`: one record per input row, duplicates flagged |
| `normalize` | boolean | `true` | Provider-aware normalization (see below). Domains are lower-cased either way |
| `plus_tag_handling` | `known_providers` / `all` / `keep` | `known_providers` | Where to strip `+tags` |
| `check_mx` | boolean | `true` | DNS MX lookup, cached per domain |
| `check_smtp` | boolean | `false` | Mailbox probe — read the SMTP section first |
| `detect_disposable` | boolean | `true` | 75,000+ throw-away domains, subdomains included |
| `detect_role` | boolean | `true` | Flags `info@`, `support@`, `sales-eu@`, `noreply@` — as information, not as a defect |
| `role_segment` | `clean` / `risky` | `clean` | Where role mailboxes land |
| `score` | boolean | `true` | 0-100 score, confidence and segment |
| `output_segments` | boolean | `true` | Write `clean.csv`, `risky.csv`, `invalid.csv`, `role.csv` to the run's key-value store |
| `disposable_segment` | `invalid` / `risky` | `invalid` | Where disposable addresses go |
| `extra_disposable_domains` | array of strings | `[]` | Your own additions (max 5000) |
| `disposable_list_url` | http(s) URL | – | Your own disposable list (one domain per line or a JSON array), merged with the bundled one |
| `include_source_row` | boolean | `false` | Copy the original row into `source_row` (max 30 columns, 500 chars per value, 1000 chars per row) |
| `max_emails` | 1–100000 | `100000` | Safety cap on rows processed *and charged* |
| `dns_concurrency` | 1–32 | `5` | Parallel MX lookups |
| `dns_timeout_seconds` | 1–30 | `5` | Per-lookup timeout |
| `max_file_size_mb` | 1–100 | `50` | Bigger files are skipped with a warning |
| `smtp_from_email`, `smtp_helo_host` | string | container defaults | Only used when `check_smtp` is on |
| `smtp_timeout_seconds` | 2–60 | `10` | Per-step SMTP timeout |
| `max_smtp_checks` | 1–20000 | `1000` | SMTP probe budget per run |

#### How the email column is chosen (and how you know)

1. `email_column`, if you set it.
2. Otherwise an unambiguous header name (`email`, `e-mail`, `email_address`, `work email`, `correo`, `courriel`, `indirizzo email`…). Ambiguous ones (`to`, `mail`, `recipient`) are only considered after those.
3. **Whatever the name says, the data has the last word.** The chosen column is sampled: if fewer than 30% of its values look like an address, the column where most values *do* is used instead — a `To,Company,Email` file is read from `Email`, not from `To`. If no column looks like addresses, **nothing is read and nothing is charged**, with an explicit warning.
4. The result is always reported in the log and in the run report: `Email column: 'Email' (index 2), chosen by header name 'Email'; 100% of the 200 sampled value(s) look like an address.`
5. A cell that is not an address but **contains** one has the address extracted (same pattern that selected the column), so a `notes` column of `write to p7@ex.com for info` yields 1,200 clean contacts instead of 1,200 invalid rows.
6. Auto-detection is strict on purpose, but **`email_column` is an instruction**: a column you name is read even when only a few of its values look like addresses (the rest come back as invalid rows), as long as it holds at least one. That is how you harvest a free-text notes column.

Malformed files are recovered where possible: a single unbalanced `"` (the classic way a CSV loses 99% of its rows in silence) is caught by comparing the addresses extracted with the addresses visible in the raw text, then re-parsed ignoring quotes — and the alternative parse is kept **only if it yields more real addresses**, never because it yields more rows. A valid file with multi-line quoted fields (notes, postal addresses — what Excel, Sheets and HubSpot export every day) is therefore never shredded; it just gets an informational note.

#### Normalization rules

All applied only when `normalize` is on, except domain lower-casing which is always applied (domains are case-insensitive by RFC 1035):

| Rule | Example |
| --- | --- |
| Lower-case local part | `John@…` → `john@…` |
| Gmail dots | `j.s.mith@gmail.com` → `jsmith@gmail.com` |
| `googlemail.com` alias | `x@googlemail.com` → `x@gmail.com` |
| `+tag` on providers that support it | `john+news@outlook.com` → `john@outlook.com` |
| `+tag` elsewhere (kept — `+` is a legal character) | `sales+eu@acme.example` stays |
| IDN → punycode | `mario@münchen.de` → `mario@xn--mnchen-3ya.de` |
| Wrappers and artifacts | `Jane Doe <jane@x.com>`, `mailto:jane@x.com`, `"jane@x.com",` → `jane@x.com` |

Yahoo is deliberately **not** on the `+tag` list: it does not implement `+` sub-addressing, so a tag there is part of the mailbox name.

### Output

One dataset record per address (or per row, with `dedup: false`):

```json
{
    "email_original": "J.Smith+news@Gmail.com",
    "email_normalized": "jsmith@gmail.com",
    "domain": "gmail.com",
    "valid_syntax": true,
    "syntax_error": null,
    "is_duplicate": false,
    "duplicate_of": null,
    "duplicate_kind": null,
    "occurrences": 3,
    "is_disposable": false,
    "is_role": false,
    "is_free_provider": true,
    "suggested_domain": null,
    "mx_found": true,
    "mx_hosts": ["gmail-smtp-in.l.google.com", "alt1.gmail-smtp-in.l.google.com"],
    "mx_status": "mx_found",
    "mx_reason": null,
    "smtp_result": "skipped",
    "smtp_reason": null,
    "smtp_code": null,
    "confidence": "high",
    "score": 100,
    "segment": "clean",
    "reasons": [],
    "source": "contacts.csv",
    "row_number": 42,
    "source_row": null,
    "summary_json": null,
    "warnings": []
}
```

`reasons` is the audit trail: scoring rules (`role_account`, `mx_lookup_inconclusive:timeout`, `possible_typo_did_you_mean:gmail.com`) plus every normalization step applied, prefixed with `note:` (`note:gmail_dots_removed`, `note:plus_tag_removed`, `note:display_name_stripped`, `note:address_extracted_from_surrounding_text`, `note:trailing_punctuation_stripped`, `note:text_boundary_repaired:ex.com`, `note:skipped_a_role_address_earlier_in_the_text`). Nothing is changed silently.

`email_original` is always the cell as it was in your file — including the surrounding prose when the address was extracted from text; `email_normalized` is the address itself.

`mx_status` is one of `mx_found`, `implicit_mx` (no MX but an A record — RFC 5321 fallback), `null_mx` (RFC 7505: the domain refuses all mail), `domain_not_found` (NXDOMAIN), `no_dns_records`, `timeout`, `dns_error`, `budget_exhausted`, `skipped`. The last four mean **we could not find out**, and the score and confidence say so.

**Files written to the run's key-value store:**

| Key | Content |
| --- | --- |
| `clean.csv` / `risky.csv` / `invalid.csv` | The three segments, ready to import |
| `role.csv` | Cross-cutting: every role mailbox, whichever segment it is in — so "clean minus role" is one subtraction away |
| `HYGIENE_REPORT` | The full report as JSON |

Segments larger than one record are split into `clean-2.csv`, `clean-3.csv`, … — never silently truncated. Key-value-store files are used rather than named datasets so a daily scheduled run does not slowly fill your account with storages.

**The last dataset record is the hygiene report** (`email_original: "_summary"`), carrying the same columns as every other record plus `summary_json` — a JSON string, so exporting the dataset to CSV gains one column, not one per report field:

```json
{
    "input_rows": 12480, "unique_emails": 10233, "duplicates_found": 2247,
    "duplicate_rate_pct": 18.0, "stopped_reason": null,
    "charged_emails": 10233, "charged_verified": 10101, "charged_screened": 132,
    "estimated_cost_usd_at_list_price": 7.60715,
    "syntax": { "valid": 10101, "invalid": 132, "invalid_reasons": { "missing_at_sign": 88 } },
    "segments": { "clean": 8410, "risky": 1102, "invalid": 721 },
    "confidence": { "high": 9002, "medium": 800, "low": 431 },
    "classification": { "disposable": 61, "role": 480, "free_provider": 5210, "corporate": 4891, "typo_suggestions": 27 },
    "mx": { "mx_found": 9850, "implicit_mx": 40, "domain_not_found": 180, "timeout": 31 },
    "top_domains": [{ "domain": "gmail.com", "count": 3120 }],
    "dns": { "unique_domains": 1204, "lookups": 1204 },
    "segment_files": { "clean.csv": 8410, "role.csv": 480 },
    "options": { "...": "the options this run actually used" },
    "warnings": ["..."], "seconds": 41.2
}
```

### Scoring and confidence

Deterministic and auditable — you can recompute any record by hand from this table.

| Situation | Score | Confidence | Segment |
| --- | --- | --- | --- |
| Invalid syntax | 0 | high | invalid |
| Domain does not exist / null MX / no DNS records | 0 | high | invalid |
| Disposable domain | 5 | high | invalid (configurable) |
| SMTP said 5xx (mailbox rejected) | 0 | high | invalid |
| Valid MX record | 100 | high | clean |
| No MX but an A record (RFC 5321 implicit MX) | 70 | medium | risky |
| MX lookup timed out / failed / budget exhausted | 55 | low | risky |
| `check_mx: false` | 60 | low | risky |
| *modifier* — domain one edit from a big provider (`gmial.com`) | cap 70 | medium | risky |
| *modifier* — non-ASCII local part (needs SMTPUTF8) | −15 | – | – |
| *modifier* — quoted local part | −15 | – | – |
| *modifier* — SMTP mailbox accepted | +5 | high | – |
| *modifier* — catch-all domain | cap 65 | medium | risky |
| *modifier* — SMTP inconclusive | cap 90 | – | – |

Segments follow the score — **clean** ≥ 80, **risky** 40-79, **invalid** < 40 — with two explicit overrides: disposable addresses go to `disposable_segment`, role mailboxes to `role_segment`. Modifiers never push a score below 40: only *hard* evidence (bad syntax, dead domain, disposable, SMTP 5xx) produces an `invalid` verdict.

**Role accounts do not lose points.** `ceo@`, `hello@`, `press@`, `partners@` and `investors@` are the target of most B2B outreach, not junk; they keep their score, stay in `clean` by default and are listed in `role.csv` so you can subtract them if your campaign is a consumer newsletter. Set `role_segment: "risky"` to move them out of `clean.csv`. The detector only treats a suffix as part of the role when it is numeric or a region (`info.uk@`, `sales-eu@`, `support2@`) — real people behind a department prefix (`it.max@`, `pr.ben@`, `hr.zoe@`) are not flagged.

**What confidence means:**

- **high** — the evidence is definitive *for what it measures*: broken syntax, a domain that does not exist, a domain that refuses mail, a disposable domain, or a working MX record. A high-confidence `clean` means **"this domain accepts mail and the address is well formed"** — it does not mean "this mailbox exists". Only `smtp_result: "deliverable"` claims that.
- **medium** — indirect evidence: an A record instead of an MX record, a catch-all domain, a likely typo.
- **low** — we could not obtain the evidence: DNS timed out, the DNS budget ran out, or `check_mx` was off. Nothing is guessed in this state.

### The truth about SMTP verification

Vendors advertising "98% accuracy" are usually measuring one of two things: MX-level checks (what this Actor does by default) or SMTP `RCPT TO` probes from a reputation-managed IP pool. **Reliable SMTP probing is not something a shared datacenter container can do**, and pretending otherwise is how lists get burned:

- **Outbound port 25 is blocked** on most cloud networks. A refused connection says nothing about the mailbox.
- **Mailbox providers score the connecting IP.** A datacenter IP without a matching PTR record gets greylisted, tarpitted or blanket-accepted whatever the address.
- **Gmail, Outlook and Yahoo accept every recipient on purpose**, to defeat address harvesting.
- **Probing at scale looks like a dictionary attack**, which is why this Actor probes sequentially and stops after three consecutive connection failures.

So `check_smtp` is **off by default**, and when it is on:

| `smtp_result` | Meaning |
| --- | --- |
| `deliverable` | The server accepted the recipient **and** rejected a random address at the same domain (so it is not a catch-all) |
| `undeliverable` | The server rejected the recipient with a 5xx |
| `catch_all` | The domain accepts every recipient — the mailbox itself cannot be confirmed |
| `inconclusive` | Port 25 unreachable, greylisting, 4xx, `MAIL FROM` refused, budget exhausted, no MX host… `smtp_reason` says which |
| `skipped` | `check_smtp` was off |

`inconclusive` is a first-class result, not a failure to hide. Note that **catch-all domains are only detected when `check_smtp` is on** — with the default settings a catch-all domain looks like any other domain with a working MX record.

### Pricing (pay per event)

| Event | Price | When it is charged |
| --- | --- | --- |
| `apify-actor-start` | $0.005 | Charged automatically by the Apify platform when a run starts. Apify covers the compute cost of the first 5 seconds of every run. Never charged from the Actor code |
| `email-verified` | $0.00075 | Once per **unique** address for which a DNS or SMTP lookup was performed — **$0.75 per 1,000** |
| `email-screened` | $0.0002 | Once per **unique** address decided with no lookup at all: broken syntax, a disposable domain caught by the local list, or `check_mx: false` — **$0.20 per 1,000** |

**We charge the full price only when we actually looked something up.**

**The $0.005 start fee is charged by the Apify platform on every run**, whatever the input turns out to contain — it is what pays for the first 5 seconds of compute, and the Actor never charges it itself. What a run can make free is the **work**: duplicate rows (charged once, for the first occurrence only), blank cells, rows with no email column, files that could not be downloaded, rows dropped by `max_emails`, and everything after the run's charge limit is reached — the run stops instead of working for free. A run that reads nothing at all costs the $0.005 start fee and not one address.

**And if the file is wrong, you pay $0.005, not for every broken row.** If a column is picked but its values are not addresses, nothing is read and no address is charged. If 60% or more of the first 1,000 unique addresses fail syntax validation **and** the failure has the signature of a parsing accident — values with no `@` anywhere, or prose with no address in it — the run stops with a diagnosis instead of billing 100,000 broken rows: a mis-parsed 50k list costs about **$0.21**, not $37.50.

A list that is simply *old and rotten* — truncated (`john.smith@`), malformed or long-dead addresses — is **not** a parsing accident: that is the job. It is processed to the last row, with a note in the log.

Examples:

- 10,000 rows with 18% duplicates: `$0.005 + 8200 × $0.00075` = **$6.16** (≈ $0.62 per 1,000 uploaded rows)
- 50,000 clean unique rows: `$0.005 + 50000 × $0.00075` = **$37.51**
- A daily 500-row incremental check: `$0.005 + 500 × $0.00075` = **$0.38**
- 20,000 rows of junk (wrong file): stopped after the sample, **$0.21**

Compute is a rounding error — parsing, normalizing, deduplicating and scoring 100,000 rows takes seconds, and the wall-clock time of a run is dominated by DNS latency — so the per-address price is essentially all you pay.

### Honest comparison

| Alternative | Honest trade-off |
| --- | --- |
| **Per-address verifier Actors** (one address per input item) | If your list is *already* deduplicated and you just want a per-address answer, they are simpler, and on a high plan tier some are cheaper per address. They do not take a CSV, do not deduplicate, do not normalize provider aliases, and do not segment — which is most of the work when the input is a real CRM export. |
| **Commercial verification APIs** (ZeroBounce, NeverBounce, …) | They own IP pools with mail reputation, so their SMTP-level verdicts are genuinely better than anything a shared datacenter can do. They are also 5-15× the price. Use them for the final pass on a list you are about to mail; use this Actor to remove the 20-40% of rows that never deserved that pass. |
| **Doing it in a spreadsheet** | Free, and how most people do it. Excel's "Remove duplicates" will not catch `j.smith+news@googlemail.com` = `jsmith@gmail.com`, and it cannot check MX records. |
| **Running it yourself** (`dnspython` + a disposable-domain list) | Same building blocks. This Actor is the glue: file parsing, column detection and validation, provider rules, caching, budgets, scoring, segment exports, and the caps that stop a 200 MB spreadsheet from eating your afternoon. |

We do not claim mailbox-level accuracy we cannot deliver, and we do not price by plan tier.

### Your data (and GDPR)

- **You are the data controller.** A contact list is personal data. You decide why it is processed; this Actor is a tool you run inside Apify, and Apify is your processor under its [Data Processing Addendum](https://apify.com/data-processing-addendum). The Actor author is not a party to that processing and never sees your data.
- **Processing is in memory; the output is stored.** Rows are parsed in the container and never written anywhere else — but the dataset records and the CSV files this run produces **persist in your Apify account** under your account's data-retention settings (unnamed storages are deleted after the retention period of your plan; named ones are kept until you delete them). If your retention policy for contact data is shorter, delete the run's storages when you are done.
- **DNS queries expose the domains in your list.** Verifying MX records means asking a DNS resolver about `example.com` for every distinct domain you upload — domains only, never local parts, and one query per unique domain. If that is a problem for your threat model, run with `check_mx: false`.
- **`check_smtp` connects to third-party mail servers.** It opens unsolicited SMTP conversations with the recipients' mail providers from Apify's IP addresses, on your behalf and under your responsibility. It never sends a message (no `DATA` command) and it is off by default.
- **No enrichment, no third-party lookups, no LLM.** Addresses are never sent to any verification API, enrichment vendor or model. The disposable and role lists are files inside the image; the only outbound traffic is downloading the source URLs you provide, DNS, and — if you enable it — SMTP.
- **No address content in the logs, no telemetry.** Logs contain counts, warnings and column names. There is no hidden persistence of any kind.
- **SSRF-guarded fetching.** Source URLs that resolve to private, loopback, link-local, multicast, reserved, carrier-grade-NAT (`100.64.0.0/10`) or IPv4-mapped-IPv6 addresses are refused, including across redirects.

### Limits and failure behaviour

Nothing here fails a run: every limit produces a warning in the log and in the hygiene report. Even an empty or unreadable input ends with a `SUCCEEDED` run and an explicit status message rather than a failure.

| Limit | Value |
| --- | --- |
| Rows per run | 100,000 (`max_emails`) |
| Files per run | 20 |
| File size | 50 MB default, 100 MB max |
| Columns per row | 1,024 (only the head of a file is held in memory in full) |
| Distinct domains resolved per run | 25,000 |
| Total DNS time budget | 30 minutes, then remaining lookups report `budget_exhausted` |
| SMTP probes | `max_smtp_checks` (default 1,000), sequential |
| Dataset record | 8 MB — `source_row` is dropped (with a warning) before that is reached |
| Segment CSV | split into numbered parts, 100,000 rows per segment |
| Circuit breaker | ≥60% invalid syntax in the first 1,000 unique addresses **and** a parsing-accident signature (no `@` anywhere in <30% of values, or prose with no address) stops the run. Truncated or malformed addresses are a rotten list, not an accident: processed in full |

Malformed CSV, unbalanced quotes, broken ZIP/XLSX, PDFs and images (even renamed to `.csv`), unreachable URLs, UTF-16/CP1252/Latin-1 encodings, spreadsheet formulas with no cached value, and files with no email column all produce a warning and let the rest of the run continue.

### FAQ

**Does it work with a Google Sheets / HubSpot / Mailchimp export?**
Yes — export as CSV or XLSX and pass the URL, or upload it to a key-value store and pass `{ "key": "export.csv" }`. Wide exports with hundreds of columns are supported. Set `include_source_row: true` to get all the other columns back alongside the verdict.

**My addresses are inside a notes/comments column, mixed with text. Is that a problem?**
No. Point `email_column` at it (or let auto-detection find it): each address is extracted from the surrounding text, verified and deduplicated like any other, and `email_original` keeps the full original cell so you can still see the context. If a cell contains more than one address, the first non-role one is taken.

**The run says "Could not identify an email column" but my addresses ARE in there (in a notes column, only in some rows).**
Auto-detection only picks a column when most of its values look like addresses, so a notes column where one row in five carries an address is skipped — nothing is read and nothing is charged. Set `email_column` to that column's name: an explicitly named column is read even when few values look like addresses, and every address inside the text is extracted. Rows with no address in them come back as `invalid` (and are charged at the `email-screened` rate of $0.0002).

**How do I merge several lists?**
Put them all in `sources`: they are read in order, deduplicated *across files*, and `source` + `row_number` in each record tell you where each address came from.

**Why is my whole list `risky` when I turn `check_mx` off?**
By design: without an MX lookup there is no evidence a domain can receive mail, so the best available verdict is syntax-level. Nothing reaches `clean` without at least an MX check. Those addresses are billed at the `email-screened` rate.

**Why is a valid-looking address `risky` with `mx_status: timeout`?**
Because the DNS server did not answer in time. We report that instead of guessing. Re-run with a higher `dns_timeout_seconds`, or lower `dns_concurrency` if your resolver is rate-limiting.

**How complete is the disposable list?**
It bundles **75,000+ domains** merged from two maintained open-source feeds plus our own additions, matched on subdomains too. No list is ever complete: `extra_disposable_domains` adds your own, and `disposable_list_url` lets you plug in a feed that updates faster than this Actor's releases.

**Why is `info@company.com` in `clean.csv`?**
Because it is a real, deliverable mailbox, and for B2B outreach it is often the one you want. It is flagged (`is_role: true`), exported to `role.csv`, and you can move all role addresses to `risky` with `role_segment: "risky"`.

**Can I get only the clean addresses?**
Download `clean.csv` from the run's key-value store, or filter the dataset on `segment` / `score`.

**Does it send any email?**
No. Even with `check_smtp` on, the conversation stops at `RCPT TO` and always ends with `QUIT`; no `DATA`, no message is ever transmitted.

**Attribution.** The bundled disposable-domain list merges [disposable/disposable-email-domains](https://github.com/disposable/disposable-email-domains) and [FGRibreau/mailchecker](https://github.com/FGRibreau/mailchecker), both MIT licensed; the licences travel with the list inside the image.

### You may also need

Same author, same principles: you pay for work that was actually done, nothing is stored outside your own run, and no third-party API ever sees your data.

- **[PDF, Word & Excel to Markdown](https://apify.com/yabanana99/pdf-word-excel-to-markdown)** — turn PDFs, Word, Excel and scans into clean Markdown/JSON with cell-level tables and RAG chunks.
- **[Phone Number Cleaner](https://apify.com/yabanana99/phone-number-cleaner-validator)** — validate and format phone numbers in bulk to E.164, with line type and carrier.

# Actor input Schema

## `emails` (type: `array`):

Addresses to check, one per item. Use this for quick runs; for real lists use <b>Contact files</b> below. Inline addresses and files are merged and deduplicated together.

## `sources` (type: `array`):

Files to read. Each item is an object with exactly one of: <b>url</b> (http/https link to the file), <b>base64</b> (base64-encoded file content, data URIs accepted) or <b>key</b> (record key in a key-value store, with optional <b>store\_id</b>). Add <b>filename</b> (e.g. <code>contacts.csv</code>) to help format detection. Supported: CSV, TSV, XLSX, JSON, JSON Lines and plain-text lists. Up to 20 files per run.

## `email_column` (type: `string`):

Name of the column (CSV/XLSX header or JSON field) holding the address. Leave empty to auto-detect: known header names first (email, e-mail, mail, correo, courriel…), then the column where most values contain '@'. The column actually used is reported in the run log.

## `dedup` (type: `boolean`):

<b>On</b>: one record per unique address (the deduplicated list you can re-import), with <code>occurrences</code> telling how many times it appeared. <b>Off</b>: one record per input row, duplicates flagged with <code>is\_duplicate</code> and <code>duplicate\_of</code>. Either way a duplicate is never charged twice.

## `normalize` (type: `boolean`):

Lowercase the local part, apply provider rules (Gmail dots, googlemail.com → gmail.com, +tags) and convert IDN domains to punycode. Domains are always lowercased. Normalization is what makes <code>J.Smith+news@Gmail.com</code> and <code>jsmith@gmail.com</code> collapse into one contact.

## `plus_tag_handling` (type: `string`):

<b>Known providers</b> strips <code>+tag</code> only where the provider documents sub-addressing (Gmail, Outlook, iCloud, Proton, Fastmail, GMX…). <b>All domains</b> strips it everywhere (aggressive: '+' is a legal character on other domains). <b>Keep</b> never strips it.

## `check_mx` (type: `boolean`):

Look up the domain's MX records. This is the evidence that a domain can receive mail at all; with it off, no address can reach the <b>clean</b> segment (see the README scoring table). One DNS lookup per unique domain, cached for the whole run.

## `check_smtp` (type: `boolean`):

Opens an SMTP conversation (RCPT TO) with the mail server. <b>Off by default on purpose:</b> port 25 is blocked on most datacenter networks and big providers accept every recipient, so results are frequently <code>inconclusive</code> — which is exactly what this Actor reports instead of guessing. Slow (sequential, one conversation at a time).

## `detect_disposable` (type: `boolean`):

Flag throw-away providers (mailinator, guerrillamail, temp-mail, mail.tm, 1secmail…) using a bundled offline list of 75,000+ domains, subdomains included.

## `detect_role` (type: `boolean`):

Flag shared mailboxes such as <code>info@</code>, <code>support@</code>, <code>sales-eu@</code>, <code>noreply@</code>. This is <b>information, not a defect</b>: role addresses keep their score and are exported to <code>role.csv</code> as well, so you can keep them (B2B outreach) or subtract them (newsletters).

## `role_segment` (type: `string`):

Where role mailboxes land. <b>clean</b> (default) keeps <code>ceo@</code>, <code>hello@</code>, <code>press@</code> in the clean segment — they are the target of most B2B outreach. Choose <b>risky</b> to move them out of <code>clean.csv</code>.

## `score` (type: `boolean`):

Compute the 0-100 score, the confidence level and the clean / risky / invalid segment. Turn it off to get raw fields only.

## `output_segments` (type: `boolean`):

Also write <code>clean.csv</code>, <code>risky.csv</code> and <code>invalid.csv</code> to the run's key-value store, ready to download and import. Requires <b>Score and segment</b>.

## `disposable_segment` (type: `string`):

Disposable addresses are usually deliverable but worthless. By default they land in <b>invalid</b>; choose <b>risky</b> if you prefer to review them yourself.

## `extra_disposable_domains` (type: `array`):

Your own additions to the disposable list (domain only, e.g. <code>badmail.example</code>). Subdomains match too. Up to 5000 entries.

## `disposable_list_url` (type: `string`):

http(s) URL of your own disposable-domain list — one domain per line or a JSON array of strings. It is merged with the bundled list, so you can plug in a feed that updates faster than this Actor's releases.

## `include_source_row` (type: `boolean`):

Copy the source row (all its columns) into <code>source\_row</code>, so the cleaned output keeps names, companies and every other field. Up to 30 columns, 500 characters per value and 1000 characters per row.

## `max_emails` (type: `integer`):

Safety cap on how many rows are processed (and therefore charged) in one run. Hard limit: 100000.

## `dns_concurrency` (type: `integer`):

How many MX lookups run in parallel. DNS is I/O-bound so parallelism genuinely helps here; lookups are cached per domain, so a 50k list of Gmail addresses is still one lookup. Range 1-32.

## `dns_timeout_seconds` (type: `integer`):

Per-lookup timeout. A domain that times out is reported as <code>mx\_status: timeout</code> with low confidence — never as valid or invalid. Range 1-30.

## `max_file_size_mb` (type: `integer`):

Files larger than this are skipped with a warning instead of failing the run.

## `smtp_from_email` (type: `string`):

Only used when SMTP probing is on. Use an address on a domain you control; a mismatched or unknown sender is a common reason for inconclusive results.

## `smtp_helo_host` (type: `string`):

Only used when SMTP probing is on. Should be a hostname you control whose PTR matches the outgoing IP — which datacenter IPs usually cannot offer. Defaults to the container hostname.

## `smtp_timeout_seconds` (type: `integer`):

Per-step timeout of the SMTP conversation. Range 2-60.

## `max_smtp_checks` (type: `integer`):

Budget of SMTP conversations per run; beyond it, addresses are reported as <code>inconclusive</code> with the reason. Protects the run from spending hours on unresponsive servers.

## Actor input object example

```json
{
  "emails": [
    "John.Doe+news@Gmail.com",
    "john.doe@gmail.com",
    "info@apify.com",
    "user@mailinator.com",
    "jane@gmial.com",
    "not-an-email"
  ],
  "sources": [],
  "dedup": true,
  "normalize": true,
  "plus_tag_handling": "known_providers",
  "check_mx": true,
  "check_smtp": false,
  "detect_disposable": true,
  "detect_role": true,
  "role_segment": "clean",
  "score": true,
  "output_segments": true,
  "disposable_segment": "invalid",
  "include_source_row": false,
  "max_emails": 100000,
  "dns_concurrency": 5,
  "dns_timeout_seconds": 5,
  "max_file_size_mb": 50,
  "smtp_timeout_seconds": 10,
  "max_smtp_checks": 1000
}
```

# Actor output Schema

## `contacts` (type: `string`):

One record per address: normalized form, duplicate flag, disposable/role/free detection, MX result, score, segment and reasons. The final \_summary record holds the hygiene report.

## `contactsCsv` (type: `string`):

The same records as CSV, ready to import back into your CRM or mailing tool.

## `segments` (type: `string`):

clean.csv, risky.csv, role.csv and invalid.csv, ready to download separately.

## `overview` (type: `string`):

Browse the results in the Apify Console.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "emails": [
        "John.Doe+news@Gmail.com",
        "john.doe@gmail.com",
        "info@apify.com",
        "user@mailinator.com",
        "jane@gmial.com",
        "not-an-email"
    ],
    "sources": []
};

// Run the Actor and wait for it to finish
const run = await client.actor("yabanana99/email-list-cleaner-verifier").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "emails": [
        "John.Doe+news@Gmail.com",
        "john.doe@gmail.com",
        "info@apify.com",
        "user@mailinator.com",
        "jane@gmial.com",
        "not-an-email",
    ],
    "sources": [],
}

# Run the Actor and wait for it to finish
run = client.actor("yabanana99/email-list-cleaner-verifier").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "emails": [
    "John.Doe+news@Gmail.com",
    "john.doe@gmail.com",
    "info@apify.com",
    "user@mailinator.com",
    "jane@gmial.com",
    "not-an-email"
  ],
  "sources": []
}' |
apify call yabanana99/email-list-cleaner-verifier --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,yabanana99/email-list-cleaner-verifier"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/FBfrrmWQdvwi3AVi5/builds/RFX18bCHfj2v0Tg7L/openapi.json
