# Email Verification API — Bulk SMTP, MX & Catch-All Check (`sumitr_mardy/email-verifier`) Actor

Bulk email verification with a flat, schema-stable output: syntax, MX/A, disposable/role/free lists, SMTP handshake and honest catch-all detection.

- **URL**: https://apify.com/sumitr\_mardy/email-verifier.md
- **Developed by:** [Sumitr Mardy](https://apify.com/sumitr_mardy) (community)
- **Categories:** Lead generation, Developer tools, Open source
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$2.00 / 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

## Email Verification API — Bulk SMTP, MX & Catch-All Check

Verify email addresses in bulk: **RFC 5322 syntax → DNS/MX → disposable, role and free-provider lists → live SMTP handshake with a mandatory catch-all probe.** One flat JSON record per address, built to be consumed by a machine.

No message is ever sent. The actor reads SMTP reply codes only — it never issues `DATA`.

***

### Why this one

Most verifiers are easy to use until you put them in a pipeline. Then you find out that `status` is sometimes `null`, that `checks.catch_all` disappears when the check didn't run, and that "valid" quietly means "the domain accepted a random string".

This actor makes three promises and enforces them in tests:

1. **`status` is always one of four values.** `deliverable`, `undeliverable`, `risky`, `unknown`. Never null, never empty, never a fifth value.
2. **Every field in `checks` is always present.** All 11 booleans, every time. A check that was never reached is `false` — the *reason* lives in `sub_status`. No defensive null-checking in your code.
3. **Catch-all and free providers are reported honestly.** A catch-all domain returns `risky/catch_all`, not a fake "valid". Gmail returns `risky/free_provider_unverifiable`, not a fabricated mailbox verdict. See [Known limits](#known-limits).

***

### Built to be called by a machine

Most verifiers are written for a human reading a dashboard. This one assumes the caller is code — a pipeline step, a workflow node, or an AI agent holding it as a tool.

That distinction is not marketing. It changes what the output has to guarantee.

A human sees `catch_all` missing from a response and shrugs. An agent reasoning over the same response concludes the domain is not catch-all, marks the address safe, and sends. **An agent cannot write `result.checks?.catch_all ?? false`** — it acts on what it reads. So a field that sometimes disappears is not a minor inconvenience; it is a wrong answer with no warning attached.

What that means here:

- **The response shape never varies.** Same 11 `checks` keys on every record, whether the SMTP layer ran or not. Nothing to branch on, nothing to guard.
- **`status` has exactly four values, forever.** A caller can enumerate them once and be correct permanently. New information arrives in `sub_status`, which is also always populated.
- **Verdicts are deterministic.** Scores come from a lookup table keyed on the verdict — no model, no randomness. The same address with the same verdict scores the same today and in a year, which is what makes a result cacheable and a test repeatable.
- **Uncertainty is a value, not a gap.** When the mailbox genuinely cannot be determined, you get `unknown` or `risky` with a reason — never a confident guess and never a null. An agent that can distinguish "this is dead" from "I could not find out" makes better decisions than one handed a boolean.

Being a standard Apify Actor, it is callable from anything that speaks the Apify API — including agent toolchains and MCP integrations — and the input schema doubles as the tool's parameter description.

***

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `emails` | array of strings | `[]` | Addresses to verify. |
| `emailFileUrl` | string | — | URL of a `.txt` (one per line) or `.csv`. Every email-shaped token in the file is picked up. Used *in addition to* `emails`. |
| `concurrency` | integer 1–50 | `15` | Parallel SMTP handshakes. Addresses are grouped by domain first, so this is really "domains in flight". |
| `smtpTimeoutMs` | integer 2000–20000 | `8000` | Per-reply SMTP timeout. |
| `verbose` | boolean | `false` | Adds a `raw` block per record with per-layer detail (MX list, probe codes, raw SMTP text). Off by default to keep records small. |
| `heloName` | string | `angelnumbercodex.com` | Hostname announced in `EHLO`. |
| `mailFrom` | string | `verify@angelnumbercodex.com` | Envelope sender used in `MAIL FROM`. |

> **The defaults already point at a domain with clean SPF, DKIM and DMARC, and the published accuracy numbers were measured with them.** Override them only if you want the handshake to identify itself as your own domain. It is the single biggest accuracy lever on business domains: many MX servers downgrade or reject a session whose HELO name has no DNS, or whose envelope sender domain doesn't resolve. The defaults work, but a real domain works better.

At least one of `emails` / `emailFileUrl` must yield an address, otherwise the run fails immediately with a clear message.

```json
{
  "emails": [
    "john@example.com",
    "info@apify.com",
    "someone@gmail.com",
    "test@mailinator.com"
  ],
  "concurrency": 15,
  "smtpTimeoutMs": 8000,
  "verbose": false
}
```

***

### Output

One record per input address, pushed to the dataset:

```json
{
  "email": "john@example.com",
  "normalized_email": "john@example.com",
  "domain": "example.com",
  "status": "deliverable",
  "sub_status": "valid_mailbox",
  "score": 100,
  "checks": {
    "syntax": true,
    "domain_exists": true,
    "mx_found": true,
    "a_record_fallback": false,
    "smtp_connectable": true,
    "mailbox_exists": true,
    "catch_all": false,
    "disposable": false,
    "role_based": false,
    "free_provider": false,
    "has_tag": false
  },
  "mx_record": "aspmx.l.google.com",
  "verified_at": "2026-08-14T10:00:00.000Z",
  "duration_ms": 340
}
```

#### Field guarantees

| Field | Guarantee |
|---|---|
| `email` | Your input, echoed verbatim — even when it fails syntax. |
| `normalized_email` | Lower-cased; `+tag` stripped; dots folded for Gmail. Empty string if unparseable, never `null`. |
| `domain` | Lower-cased domain, or empty string. Never `null`. |
| `status` | Always one of 4 values. |
| `sub_status` | Always one of the 12 codes below. |
| `score` | Always an integer `0`–`100`. |
| `checks` | Always all 11 boolean keys. Never `null` inside. |
| `mx_record` | Highest-priority MX host, or empty string. Never `null`. |
| `error` | Present **only** on `unknown/internal_error`. |
| `raw` | Present **only** when `verbose: true`. |

An unexpected failure on one address produces `unknown / internal_error` with an `error` string. It never aborts the run or drops a record.

***

### Status × sub\_status — the full table

| `status` | `sub_status` | Meaning | Score |
|---|---|---|---|
| `deliverable` | `valid_mailbox` | SMTP confirmed the mailbox exists and the domain is **not** catch-all. | 100 (90 on A-record fallback) |
| `undeliverable` | `invalid_syntax` | Fails RFC 5322. | 0 |
| `undeliverable` | `no_mx_record` | No MX and no A record (or RFC 7505 null MX). | 0 |
| `undeliverable` | `mailbox_not_found` | SMTP `550` on the real address, on a non-catch-all domain. | 5 |
| `risky` | `catch_all` | Domain accepts every address; no individual mailbox can be confirmed. | 50 |
| `risky` | `disposable` | Domain is on the disposable/temp-mail blocklist. | 20 |
| `risky` | `role_based` | Shared inbox (`info@`, `admin@`, `support@`, …) that SMTP confirmed exists. | 60 |
| `risky` | `full_mailbox` | SMTP `452`/`552` — mailbox over quota. | 45 |
| `risky` | `free_provider_unverifiable` | Gmail/Outlook/Yahoo/iCloud etc. Their RCPT answers carry no information, so no mailbox verdict is claimed. Decided by the domain, not by whether the SMTP session succeeded. | 55 |
| `unknown` | `greylisted` | `421`/`450`/`451` — retry later. **Not** undeliverable. | 40 |
| `unknown` | `smtp_timeout` | Host unreachable, timed out, rejected the session on policy/IP-reputation grounds, or the DNS resolver failed (SERVFAIL) instead of answering. | 30 |
| `unknown` | `internal_error` | Unexpected processing error; `error` field explains. | 0 |

Scores are a **pure function of the verdict** — no randomness, no model. The same address with the same verdict scores the same forever.

#### Precedence, so nothing surprises you

- `disposable` is terminal — no SMTP is spent on a mailbox designed to expire.
- `catch_all` beats `role_based`: on a catch-all domain nothing about the individual mailbox is knowable.
- `role_based` beats `deliverable`: a confirmed `info@` is a real mailbox but not a person.
- A `5xx` whose text mentions blocklists, reputation, or policy is treated as `unknown/smtp_timeout`, **not** `mailbox_not_found`. Rejections aimed at the sender are never turned into claims about the recipient.
- The same rule applies one layer down: an authoritative NXDOMAIN is `undeliverable/no_mx_record`, but a resolver **SERVFAIL or timeout** is `unknown/smtp_timeout`. "We could not find out" never becomes "it does not exist".

***

### How verification works

**Layer 1 — Syntax.** RFC 5322 via validator.js. Invalid → terminal.

**Layer 2 — DNS.** MX lookup, sorted by preference. No MX but an A record → RFC 5321 implicit MX, flagged as `a_record_fallback`. Neither → `no_mx_record`, unless the resolver failed rather than answered, which is `unknown`.

**Layer 3 — Lists.** Disposable blocklist (refreshed daily from the public disposable-email-domains list, ~8k domains, with a bundled fallback), role-account local parts, free-provider domains, `+tag` detection.

**Layer 4 — SMTP.** Connect to the MX on port 25, `EHLO` → `MAIL FROM` → `RCPT TO`. Reply codes only.

> **The catch-all probe is mandatory and runs first.** Before asking about your address, the actor sends `RCPT TO` for a random address that cannot exist (`zzq-nonexist-<random>@domain`). A `250` there means the domain accepts everything, and every address on it is reported `risky/catch_all` — the real address is never even probed, because the answer would be meaningless.

***

### Known limits

Stated openly, because you would find them anyway:

- **Free providers cannot be mailbox-verified.** This verdict comes from the domain, so it holds even when the provider refuses the session outright — GMX and web.de answer `554 Nemesis ESMTP Service not available` to unknown senders from any IP we have tested, and reporting that as `unknown` would tell you to retry something refused permanently. `checks.smtp_connectable` records it instead.
- **The mechanics.** Gmail, Outlook, Yahoo and iCloud answer `250` to `RCPT TO` for addresses that do not exist, then bounce at delivery time. Any vendor claiming a per-mailbox Gmail verdict from SMTP is guessing. This actor returns `risky/free_provider_unverifiable` and sets `checks.mailbox_exists: false`. Syntax, domain and disposable checks still apply.
- **Catch-all domains cannot be mailbox-verified.** By definition. You get `risky/catch_all` and the honest `catch_all: true` flag.
- **Outbound port 25 must be open, and on Apify it is not.** Measured, not assumed: every direct connection to port 25 from an Actor run is silently dropped, which turns every SMTP-dependent address into `unknown/smtp_timeout`. Set `relayUrl` and `relayToken` to point at a relay running somewhere port 25 works — see [relay/README.md](relay/README.md). Without one you get the syntax, DNS and list layers only.
- **Egress IP reputation decides the unknown rate.** Some MX servers throttle or refuse unknown senders — those become `unknown/smtp_timeout`, never a false `undeliverable`. Watch the `UNKNOWN RATE` in the run log; a high value means the SMTP path is being blocked, not that your list is bad.
- **Greylisting is not a verdict.** `421`/`450` returns `unknown/greylisted`. Re-run those addresses later; the MX and catch-all caches make the retry cheap.

***

### Performance & cost

- **MX cache**, keyed by domain, 24h TTL, persisted in the key-value store — so it survives across runs. A 5,000-address list is usually a few hundred domains.
- **Catch-all cache**, same key and TTL. Each domain is probed once, not once per address.
- **Domain grouping + session reuse.** Addresses are grouped by domain; one SMTP session serves many `RCPT TO`s, reconnecting on a budget when a server caps the session.
- **Results stream** to the dataset in batches while the run is still going.

Every run logs its own KPIs: throughput, the full status/sub\_status distribution, cache hit rates, and the unknown rate.

***

### Pricing

**Pay per event — `email_verified`, $0.002 per address.**

One charge per address that produces a result, including `undeliverable` and `unknown`: the compute was spent either way, and a confirmed bad address is exactly the result you were paying to get. No per-run charge.

***

### Usage

Via the Apify API (Node):

```bash
npm install apify-client
```

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

const client = new ApifyClient({ token: 'YOUR_TOKEN' });

const run = await client.actor('YOUR_USERNAME/email-verifier').call({
    emails: ['john@example.com', 'info@apify.com'],
    concurrency: 15,
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const r of items) {
    console.log(r.email, r.status, r.sub_status, r.score);
}
```

Filtering, without a single null check:

```js
const mailable = items.filter((r) => r.status === 'deliverable');
const worthRetrying = items.filter((r) => r.status === 'unknown');
const neverSend = items.filter((r) => r.status === 'undeliverable' || r.checks.disposable);
```

***

### Development

```bash
npm install
npm run build
npm test
```

`npm test` runs the contract tests — no network required. They assert the field guarantees above, the SMTP code mapping, and score determinism.

The accuracy gate makes real DNS and SMTP calls against a labelled set:

```bash
npm run accuracy -- test/fixtures/labeled.csv
```

It prints per-group accuracy and the unknown rate, and exits non-zero below the 95% target. The `own-domain-valid`, `own-domain-invalid` and `catch-all` groups need a throwaway side-project domain you control — those are the branches that carry the product. **Do not bulk-probe your main company domain.**

Latest measured run: **100% sub\_status match over 166 labelled rows, 1.8%
unknown** — scored identically running direct and running through the relay,
which is how the transport was shown to be equivalent.

To score a run that happened elsewhere — an Apify run, whose IP is the one that
actually matters — export its dataset as JSON and pass it in. Nothing is
verified locally; the exported records are scored against the same labels:

```bash
npm run accuracy -- --dataset path/to/dataset.json
```

# Actor input Schema

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

List of email addresses to verify.

## `emailFileUrl` (type: `string`):

URL to a .txt (one email per line) or .csv file. Used in addition to `emails`.

## `concurrency` (type: `integer`):

Parallel SMTP handshakes. 10–20 recommended. Higher = faster but more rate-limiting.

## `smtpTimeoutMs` (type: `integer`):

How long to wait for each SMTP reply before giving up on a host.

## `verbose` (type: `boolean`):

If true, include an extra raw-details block per record. Off by default for lean output.

## `heloName` (type: `string`):

Hostname announced in EHLO/HELO. Point this at a domain you own with valid DNS — many MX servers reject sessions from a HELO name that does not resolve. Biggest accuracy lever on business domains.

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

Envelope sender used during the handshake. No message is ever sent. Use an address on a domain you own for best acceptance rates.

## `relayUrl` (type: `string`):

Base URL of an SMTP relay, e.g. https://relay.example.com. Required on any host that cannot open outbound port 25 — Apify drops those connections, so without a relay every SMTP-dependent address comes back unknown. Leave empty to connect directly.

## `relayToken` (type: `string`):

Bearer token for the relay. Store it as a secret — anyone holding it can drive the relay.

## Actor input object example

```json
{
  "emails": [
    "sumit@angelnumbercodex.com",
    "no.such.person.99312@angelnumbercodex.com",
    "someone@gmail.com",
    "test@mailinator.com"
  ],
  "concurrency": 15,
  "smtpTimeoutMs": 8000,
  "verbose": false,
  "heloName": "angelnumbercodex.com",
  "mailFrom": "verify@angelnumbercodex.com"
}
```

# Actor output Schema

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

All verification records for this run, one per address submitted.

## `resultsCsv` (type: `string`):

The same records as CSV, for spreadsheet and CRM imports.

# 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": [
        "sumit@angelnumbercodex.com",
        "no.such.person.99312@angelnumbercodex.com",
        "someone@gmail.com",
        "test@mailinator.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("sumitr_mardy/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": [
        "sumit@angelnumbercodex.com",
        "no.such.person.99312@angelnumbercodex.com",
        "someone@gmail.com",
        "test@mailinator.com",
    ] }

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

```

## MCP server setup

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