# Email Verifier - SMTP, Catch-All & Disposable Detection (`chloematt/email-verifier`) Actor

Bulk email verification that only bills decisive answers. Returns valid / invalid / disposable / catch-all / unknown with a confidence score; catch-all and unknown are always free, and duplicates are folded before billing.

- **URL**: https://apify.com/chloematt/email-verifier.md
- **Developed by:** [Chloe & Matt](https://apify.com/chloematt) (community)
- **Categories:** Lead generation, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$2.00 / 1,000 decisive verifications

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

## Email Verifier — decisive verdicts only, never billed for a maybe

Give it a list of email addresses. It removes the ones that provably cannot
receive mail, flags the throwaways, folds the duplicates — and **charges you
nothing for the addresses it could not decide.**

Three verdicts cost money: `valid`, `invalid`, `disposable`.
Two are always free: `catch_all` and `unknown`.

That is the whole product. Everything below is how it earns the claim.

***

### Read this before you run it

**Apify Actors run on AWS EC2, which blocks outbound TCP on ports 25, 465 and
587.** Mailbox-level SMTP verification is impossible from the platform's default
network — not slow, not unreliable: impossible. Apify Proxy does not help
either; `CONNECT host:25` hangs while `CONNECT host:443` completes in 45 ms.
Measured on 2026-08-02 from a live Actor run.

So this Actor runs in one of two modes, and it tells you which one you are in:

| | Without a relay (default on Apify) | With a port-25 relay |
|---|---|---|
| Syntax, RFC 5322 | yes | yes |
| Domain / MX / null-MX / unreachable-MX | yes | yes |
| Disposable (11,582 domains) | yes | yes |
| Role, free provider, typo suggestion, de-dupe | yes | yes |
| **Mailbox exists?** | **no** — every such address returns `unknown`, **free** | **yes** — `valid` / `invalid` at score 95 |

Set `proxyUrl` to an HTTP proxy that permits `CONNECT host:25`, or to a SOCKS5
proxy, and mailbox checks switch on. A purpose-built relay for this - password
authentication, port-25-only allowlist, and a `DATA` filter so a leaked
credential cannot send mail - ships alongside this Actor as `smtp-probe-relay`,
deployable from a Dockerfile on any host whose provider permits port 25. The Actor probes the route once at startup;
if port 25 is unreachable it says so in the log and skips straight to DNS-level
verification instead of stalling on every domain in your list.

Every other verifier on this Store is silent about this. Several are resellers
of an off-platform HTTP API, which is how they get around it. This one tells you
what it did and does not bill you for what it could not do.

### Audited accuracy — 2026-08-02

Run `node benchmark/run.mjs` to reproduce. It builds a 169-row benchmark whose
ground truth is known by construction, runs it on the platform, and re-checks
every billable verdict against an **independent resolver** (Cloudflare
DNS-over-HTTPS), so the audit cannot simply agree with itself.

Composition: 60 live mail domains × 2 addresses, 15 non-existent domains,
15 malformed addresses, 10 disposable providers, 4 typo'd consumer domains,
5 duplicates. DNS-level mode, no relay.

| measure | result |
|---|---|
| **false `invalid` verdicts** | **0 of 31** (0.00%) |
| **false `disposable` verdicts** | **0 of 12** |
| non-existent domains caught | 15 of 15 |
| malformed addresses caught | 15 of 15 |
| typos suggested | 4 of 4 |
| typo suggestions on healthy domains (false positives) | 0 of 120 |
| duplicates folded before billing | 4 of 4 |
| decisive (billed) share of this list | 26.06% |
| free of charge | 73.94% |

Read the last two rows honestly: this benchmark is deliberately hostile —
71% of its rows sit on healthy corporate domains where **only** a mailbox check
could decide anything, and it was run without a relay. On a scraped list, the
decisive share is whatever fraction of it is genuinely broken. You pay for that
fraction and nothing else.

The mailbox-check path is covered by protocol-level tests that run a real SMTP
conversation (`npm test`, 42 assertions), including through HTTP CONNECT and
SOCKS5 tunnels. It is not covered by the figures above, because the audit ran on
Apify where port 25 is blocked.

### Why the free verdicts matter

Every SMTP verifier hits three walls. What separates them is what they do next.

| Situation | What most verifiers return | What this Actor returns |
|---|---|---|
| Domain accepts every recipient | `valid`, or a silent `unknown` | `catch_all`, **not billed** |
| Our IP is blocked by the receiver | `invalid` | `unknown`, **not billed** |
| Greylisted / rate limited | `invalid` or `unknown` | `unknown`, **not billed** |
| Port 25 unreachable | `invalid`, or a crashed run | `unknown`, **not billed** |
| Mailbox genuinely rejected | `invalid` | `invalid`, billed |

The mechanism is a **calibration probe**. Before checking any of your addresses,
the Actor asks the server about one random address on the same domain:

- The server **rejects** it → the server really does check recipients, so its
  answers about your addresses are evidence. Results carry
  `checks.calibrated: true` and score 95.
- The server **accepts** it → the domain is catch-all. Your addresses come back
  `catch_all`, cost nothing, and are not probed further.
- The server **blocks or defers** us → nothing here is knowable right now.
  Everything comes back `unknown` and costs nothing.

The probe itself is never billed. Without it (`detectCatchAll: false`) an accept
is worth only 72, because you cannot tell a real mailbox from a wide-open door.

The classifier is built around the same distinction. Microsoft 365 answers an
unknown recipient with `550 5.4.1 Recipient address rejected: Access denied` —
that string contains "access denied", which naive verifiers read as a policy
block. A reputation refusal such as `550 5.7.1 ... blocked using Spamhaus` looks
similar and means the opposite. The first is a billable `invalid`; the second is
a free `unknown`.

### Duplicates are free

Gmail dots and `+tags` are folded before any work happens, so
`j.doe+leads@gmail.com` and `jdoe@gmail.com` are one person: verified once,
billed once. `canonical_email` shows the key used. Set `skipDuplicates: false`
if you need one output row per input row.

### Output

```json
{
  "email": "Jane.Roe+leads@Example.com",
  "normalized_email": "jane.roe+leads@example.com",
  "canonical_email": "jane.roe+leads@example.com",
  "domain": "example.com",
  "status": "valid",
  "state": "Deliverable",
  "score": 95,
  "reason": "mailbox_exists",
  "deliverable": true,
  "billed": true,
  "free": false,
  "role": false,
  "disposable": false,
  "catch_all": false,
  "has_tag": true,
  "did_you_mean": null,
  "syntax_valid": true,
  "mx_found": true,
  "smtp_valid": true,
  "mx_host": "mx1.example.com",
  "mx_records": [{ "host": "mx1.example.com", "priority": 10 }],
  "mail_provider": "google",
  "verified_at": "2026-08-02T20:12:52.008Z",
  "checks": {
    "calibrated": true,
    "smtp_secure": true,
    "smtp_code": 250,
    "smtp_enhanced_code": "2.1.5",
    "smtp_response": "250 2.1.5 Recipient OK",
    "duration_ms": 812
  }
}
```

| `status` | Meaning | Billed |
|---|---|---|
| `valid` | The mail server confirmed this mailbox exists. | Yes |
| `invalid` | Bad syntax, dead domain, null MX, unreachable MX, or the server rejected this recipient by name. | Yes |
| `disposable` | Throwaway mailbox provider. | Yes |
| `catch_all` | The domain accepts every address. Deliverability is unknowable from outside. | **No** |
| `unknown` | Blocked, greylisted, timed out, port 25 unreachable, or ambiguous. | **No** |

`reason` carries the detail: `mailbox_exists`, `mailbox_not_found`,
`domain_not_found`, `no_mx_record`, `null_mx`, `mx_host_unresolvable`,
`disposable_domain`, `accepts_all_recipients`, `smtp_blocked`, `greylisted`,
`ambiguous_smtp_reply`, `smtp_egress_blocked`, `smtp_proxy_unusable`,
`smtp_unreachable_*`, plus the RFC 5322 syntax reasons.

#### Two outputs, three views

The Actor declares both of its outputs, so the Console Output tab, the
`GET Run` API and any MCP/AI agent can find them without guessing:

| output | where | what |
|---|---|---|
| `results` | default dataset | one item per unique address, all 24 fields |
| `summary` | key-value store record `SUMMARY` | counts by status, decisive rate, duplicates folded, SMTP route used and whether port 25 was reachable |

They arrive resolved in the run's `output` property:

```json
"output": {
  "results": "https://api.apify.com/v2/datasets/<id>/items",
  "summary": "https://api.apify.com/v2/key-value-stores/<id>/records/SUMMARY"
}
```

The dataset carries three views, selectable in the Output tab or by appending
`?view=<name>` to the items URL:

- **Overview** — the verdict and the flags that decide whether to send.
- **What you paid for** — `billed` beside `status` and `reason`. Sum the
  `billed` column and it must equal your invoice. Nothing else in this Store
  lets you check that.
- **Full diagnostics** — every field, with the nested SMTP evidence flattened
  into `checks.*` columns.

Views change the Console table and the `?view=` API response only. The plain
`/items` URL in `output.results` always returns complete, unflattened rows, so
programmatic consumers never silently lose a field.

### Input

Three sources, merged, so you can combine them.

| Field | What it does |
|---|---|
| `emails` | A list pasted into the form. |
| `emailListUrl` | A CSV or TXT file, uploaded or by public URL. Every address anywhere in the file is picked up, so the column layout does not matter. |
| `inputDatasetId` + `emailField` | Reads addresses straight out of another Actor's run. This is how you clean a scraper's output. |

| Tuning | Default | Notes |
|---|---|---|
| `proxyUrl` | none | `socks5://user:pass@host:1080` or `http://user:pass@host:8080`. Unlocks mailbox checks. Stored as a secret. |
| `verifySmtp` | `true` | Off skips the mailbox check and its startup probe entirely. |
| `detectCatchAll` | `true` | Turning it off drops every SMTP score from 95 to 72. Not recommended. |
| `skipDuplicates` | `true` | Fold Gmail dots and `+tags`. |
| `maxConcurrency` | `10` | Domains in parallel. Addresses on one domain always share a single sequential connection. |
| `smtpTimeoutSecs` | `15` | Per-command deadline. |
| `perHostDelayMs` | `300` | Pause between recipients on one server. Lower it and large providers rate-limit you into `unknown`. |
| `heloDomain`, `mailFrom` | auto | See below. |

#### Getting better answers out of strict receivers

By default the Actor announces the reverse DNS of its own egress IP, which is
what an honest mail server does. Some receivers only answer probes from a host
whose forward and reverse DNS agree and that publishes SPF.

If you own a sending domain, set `heloDomain` to it and `mailFrom` to a real
address on it. Nothing is ever sent — the session ends before `DATA`, so no
message is queued anywhere — but the identity you present decides how many
domains answer instead of blocking.

### Chaining it after a scraper

```jsonc
{
  "inputDatasetId": "<dataset id of the run you want to clean>",
  "emailField": "email"
}
```

Then filter on `status = valid`, on `deliverable = true`, or on `score >= 90`.

### Limits, stated plainly

- **No port 25 on Apify.** Without a relay you get DNS-level verification, and
  you are not billed for the mailbox checks that could not run.
- **Yahoo-hosted domains always return `catch_all`.** Yahoo accepts every
  recipient at RCPT time and bounces later. Not billed.
- **`valid` means the mailbox existed at probe time**, not that your mail will
  reach the inbox. Reputation, content and authentication decide that.
- **`unknown` is an outcome, not a failure.** Some networks answer no probe from
  any cloud IP. A different relay changes the answer for some of them.
- **Nothing is stored.** Addresses live in your own run's dataset.

### Runtime

512 MB. The work is network-bound, not memory-bound. Elapsed time follows the
number of distinct domains far more than the number of addresses, because
same-domain addresses share one connection.

### Build & deploy

Prereqs: Node.js 22+, npm, Apify CLI (`npm install -g apify-cli`), and an
Apify account.

```bash
## Install deps and compile TypeScript to dist/
npm install
npm run build

## Run tests
npm test

## Push current source to Apify and build the Actor
apify push
```

After `apify push`, the CLI prints the build URL. Follow it to watch the build;
when it finishes, the Actor is live at `https://apify.com/chloematt/email-verifier`.

#### GitHub integration / auto-deploy

In the Apify console, open this Actor → **Deploy** → **Connect GitHub repo**.
Select `chloe-matt/chloe-matt-email-verifier`, branch `master`, and enable
auto-deploy. After that, every push to `master` triggers a new build without
leaving the terminal.

Verified entrypoints:

- local dev: `npm run start:dev`
- production: `node dist/main.js`

Apify deploy settings are already in `.actor/actor.json` and `.actor/Dockerfile`;
`apify push` reads them from the repo root.

# Actor input Schema

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

The addresses to verify. Paste them here, or leave empty and use a file / dataset below.

## `emailListUrl` (type: `string`):

Upload a CSV or plain-text file, or paste a public URL. Every address found anywhere in the file is verified, so any column layout works.

## `inputDatasetId` (type: `string`):

Chain this Actor after a scraper: paste that run's dataset ID and the addresses are read straight out of it.

## `emailField` (type: `string`):

Which field of the input dataset holds the address.

## `skipDuplicates` (type: `boolean`):

Fold Gmail dots and +tags, then verify each unique person once. You are never billed twice for the same mailbox.

## `detectCatchAll` (type: `boolean`):

Probes one random address per domain before yours. This is what tells a real mailbox confirmation apart from a server that accepts everything - and it is never billed. Only applies when the mailbox check is running.

## `verifySmtp` (type: `boolean`):

On by default. The Actor tests whether port 25 is reachable and, if it is not, skips straight to DNS-level verification instead of stalling on every domain.

## `proxyUrl` (type: `string`):

Apify Actors run on AWS, which blocks outbound SMTP, so mailbox-level checks need a relay you control. Give an HTTP proxy that permits CONNECT to port 25, or a SOCKS5 proxy: socks5://user:pass@host:1080 or http://user:pass@host:8080. Leave empty for DNS-level verification only.

## `heloDomain` (type: `string`):

Announce this hostname instead of the reverse DNS of the egress IP. Set it to a domain you own with matching forward and reverse DNS to get answers out of the strictest receivers.

## `mailFrom` (type: `string`):

Envelope sender used during the probe. No mail is ever sent - the session ends before DATA. Defaults to postmaster@<HELO domain>.

## `maxConcurrency` (type: `integer`):

How many domains to work on at once. Addresses on the same domain always share one polite, sequential connection.

## `smtpTimeoutSecs` (type: `integer`):

Per-command deadline. Slow greylisting servers need more; a big list of dead domains finishes faster with less.

## `perHostDelayMs` (type: `integer`):

Pause between consecutive RCPT commands on one server. Too low and large providers rate-limit you into `unknown` results.

## `maxRcptPerConnection` (type: `integer`):

How many addresses to check before resetting the SMTP transaction.

## Actor input object example

```json
{
  "emails": [
    "support@apify.com",
    "no-such-person-9d3f@apify.com",
    "test@mailinator.com"
  ],
  "emailField": "email",
  "skipDuplicates": true,
  "detectCatchAll": true,
  "verifySmtp": true,
  "maxConcurrency": 10,
  "smtpTimeoutSecs": 15,
  "perHostDelayMs": 300,
  "maxRcptPerConnection": 20
}
```

# Actor output Schema

## `results` (type: `string`):

One item per unique address. `status` is one of valid, invalid, disposable, catch\_all or unknown; `billed` is true only for the first three. Filter on `status = valid` or `deliverable = true` to get a sending list, and on `billed = false` to see everything that was returned free of charge.

## `summary` (type: `string`):

Counts by status, the decisive (billable) rate, how many duplicate addresses were folded before any work was done, and which SMTP route was used - including whether outbound port 25 was reachable at all.

# 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": [
        "support@apify.com",
        "no-such-person-9d3f@apify.com",
        "test@mailinator.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("chloematt/email-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": [
        "support@apify.com",
        "no-such-person-9d3f@apify.com",
        "test@mailinator.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("chloematt/email-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": [
    "support@apify.com",
    "no-such-person-9d3f@apify.com",
    "test@mailinator.com"
  ]
}' |
apify call chloematt/email-verifier --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,chloematt/email-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/KGIBsp6GXFXaa5i5P/builds/Ih7OEBv6T9ZAXJBoe/openapi.json
