# Bulk Email Verifier: syntax, MX, disposable, role and typo (`edgef-4/bulk-email-verifier`) Actor

Check email lists without sending anything: syntax, mail server (MX) lookup, disposable domains, role accounts, free providers and typo suggestions. One row per address with a status and reasons. No login, no API key. Charged per address that gets a verdict.

- **URL**: https://apify.com/edgef-4/bulk-email-verifier.md
- **Developed by:** [Edge](https://apify.com/edgef-4) (community)
- **Categories:** Lead generation, Developer tools, Automation
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.60 / 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/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Bulk Email Verifier: syntax, MX, disposable, role and typo checks

Paste a list of email addresses and get one row per address with a status (`valid`, `invalid`, `risky` or `unknown`), the reasons behind it, and flags for disposable domains, role accounts, free providers and likely typos. Nothing is sent to any address. No login, no API key, no website is scraped: the Actor asks DNS which mail server takes mail for each domain and compares the address with built-in lists.

By default this Actor **does not check that the mailbox exists**. It tells you whether the address is well formed, whether its domain exists and accepts mail, and whether it looks risky. An optional mailbox probe over SMTP (`checkSmtp`) is described below, with its limits.

### What a run does, for each address

1. Cleans the entry: removes a display name (`Name <a@b.com>`), `mailto:`, brackets and surrounding punctuation, splits entries that hold several addresses, removes duplicates ignoring upper and lower case, keeps the input order.
2. Checks the syntax (a practical subset of the email address standard: local part up to 64 characters, whole address up to 254, valid domain labels, a top level domain of letters (whether it exists is settled by the DNS lookup); quoted local parts and IP-literal domains are rejected).
3. Looks up the MX records of the domain over DNS. If there are none, it falls back to the domain's own A or AAAA record, as mail systems do. It checks that at least one mail host (of the first five by priority) resolves to an address. A domain with a "null MX" (a published statement that it takes no mail) is `invalid`.
4. Compares with the built-in lists: 277 disposable domains (and their subdomains), 92 free provider domains, 68 role names, 116 known typo domains.
5. Optionally probes the mailbox (see below).

The lists are written by hand and are not complete: new disposable services appear every week. A domain missing from the list is not a guarantee that it is a real mailbox provider.

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `emails` | list of text | three sample addresses | One address per line. Also accepted: `Name <address>`, `mailto:` links, several addresses in one line separated by commas or semicolons. Up to 100,000 entries; needed unless `datasetId` is set. An entry that is not an address becomes a row with status `invalid`. |
| `checkSmtp` | true or false | false | Optional mailbox probe, see below. |
| `timeoutSeconds` | whole number, 2 to 60 | 10 | How long to wait for one DNS answer or one mail server reply. DNS lookups are tried twice. |
| `maxEmails` | whole number, 1 to 100,000 | 10,000 | Stops reading the list after this many different addresses. Duplicates do not count. |
| `datasetId` | dataset ID | none | A dataset to read the addresses from, such as a scraper's results. See below. |
| `emailField` | text | `email` | The field of each dataset row that holds the address. |

### Run it after a scraper

Ran a lead scraper? Pass its results instead of pasting a list.

1. Open the scraper's page in the Console, select the Integrations tab and add this Actor, to run when the scraper's run succeeds.
2. In this Actor's input there, put this line in the Dataset field (`datasetId`): `{{resource.defaultDatasetId}}`
3. In Email field (`emailField`), name the field of the scraper's rows that holds the address. The default is `email`.

Each run then reads up to 100,000 rows of that dataset. A field that holds a list of addresses gives one address per item. A row with no address in that field is skipped and not charged, and the `dataset` part of `RUN_SUMMARY` counts these rows. Addresses pasted in `emails` are checked too, except the three sample ones of the form. Duplicates are removed and `maxEmails` caps the total, as for a pasted list. For a single run you can also put the ID of a dataset you own in `datasetId`. The Actor gets read access to that one dataset only.

### Output

One dataset row per unique address, in input order. Every row has every field.

| Field | Meaning |
|---|---|
| `email` | The address as read from the input, after the cleanup in step 1. |
| `normalized` | Lower case, domain in ASCII (punycode). Null when the syntax is invalid. |
| `domain` | The domain in lower case ASCII. Null when the syntax is invalid. |
| `status` | See the table below. |
| `reasons` | List of short codes, in the order found. See the table below. |
| `mxHost` | The mail server that would receive mail (the first one by priority that resolves). The domain itself when it has no MX record but an address record. Null otherwise. |
| `isDisposable` | The domain, or a parent of it, is on the disposable list. |
| `isRole` | The part before the @ (without a +tag) is a role name such as `info`, `support` or `sales`. |
| `isFreeProvider` | The domain is a free mailbox provider such as gmail.com. Information only: it does not make a row risky. |
| `didYouMean` | The full address with the domain a known typo most likely stands for (`john@gmial.com` gives `john@gmail.com`), or null. |
| `smtpResult`, `smtpDetail` | Outcome of the mailbox probe and the server's reply, or null when the probe is off or not tried for the row. |

#### Status

| `status` | Meaning | Charged |
|---|---|---|
| `valid` | Syntax fine, the domain accepts mail, nothing risky found. | yes |
| `invalid` | Bad syntax; the domain does not exist or accepts no mail; or (probe on) the mail server refused the mailbox. | yes |
| `risky` | Deliverable in principle, but the domain is disposable, the address is a role account, the domain looks like a typo of a big provider, the local part is not plain ASCII, or (probe on) the server accepts every address. | yes |
| `unknown` | DNS did not answer in time, so no verdict is given. A temporary DNS failure is never reported as `invalid`. | no |

#### Reasons

| Code | Meaning |
|---|---|
| `bad_syntax` | The address is malformed; the next code says how: `empty`, `missing_at`, `multiple_at`, `empty_local_part`, `empty_domain`, `bad_local_part`, `quoted_local_part`, `local_part_too_long`, `address_too_long`, `bad_domain`, `domain_without_dot`, `bad_tld`, `ip_literal_domain`. |
| `domain_not_found` | The domain does not exist (DNS answered "no such domain"). |
| `domain_accepts_no_mail` | The domain has a null MX, or no MX and no address record at all. |
| `mail_host_has_no_address` | The domain names mail hosts but none of the first five has an address. |
| `dns_temporary_failure` | DNS did not answer after the retries (status `unknown`). |
| `mx_found` | A mail host for the domain was found. |
| `no_mx_uses_address_record` | No MX record, but the domain has an address record, so mail would go there. |
| `disposable_domain` | On the disposable list. |
| `role_account` | A role name such as `info` or `admin`. |
| `possible_typo` | The domain is on the typo list; see `didYouMean`. |
| `non_ascii_local_part` | The part before the @ has non-ASCII characters, which many mail servers refuse. |
| `check_timeout` | One address took longer than 12 times `timeoutSeconds` (status `unknown`). |
| `smtp_mailbox_accepted` | Probe on: the server accepted this address and refused a random one on the same domain. |
| `smtp_mailbox_rejected` | Probe on: the server said this mailbox does not exist (status `invalid`). |
| `smtp_catch_all` | Probe on: the server accepts any address on the domain (status `risky`). |
| `smtp_blocked` | Probe on: the server refused for a reason about us (blocklist, policy), not about the mailbox. The status is not changed. |
| `smtp_temporary` | Probe on: a temporary reply such as greylisting. The status is not changed. |
| `smtp_unreachable` | Probe on: no connection could be made. The status is not changed. |
| `smtp_protocol_error` | Probe on: the server sent something unexpected or hung up. The status is not changed. |

Example row (shape only, probe off; the values are made up):

```json
{"email": "john@gmial.com", "normalized": "john@gmial.com", "domain": "gmial.com", "status": "risky",
 "reasons": ["mx_found", "possible_typo"], "mxHost": "mx1.gmial.com", "isDisposable": false, "isRole": false,
 "isFreeProvider": false, "didYouMean": "john@gmail.com", "smtpResult": null, "smtpDetail": null}
```

### The mailbox probe (`checkSmtp`, off by default)

When on, for each address that passed the DNS check (and is not on a disposable domain) the Actor connects to its mail host on port 25, says hello, names an empty sender, asks whether it would accept mail for the address, then asks the same about one random address on the same domain, and says goodbye. **No message is ever sent** (no DATA command is issued). At most two connections are open to one mail host at a time.

What to expect:

- Outbound port 25 is blocked on many platforms and networks, and many mail servers refuse or delay probes from shared addresses. If 5 connections fail and none has succeeded, probing is switched off for the rest of the run, the rows say `smtp_unreachable`, and the run summary says so. The DNS result of each row is kept.
- A server may answer in ways that do not tell you about the mailbox. Those replies are recorded (`smtp_blocked`, `smtp_temporary`) and leave the status alone.
- A mailbox probe is not a delivery guarantee: servers can accept an address and bounce later.

This behavior is tested against a scripted mail server in memory. It has not been tried against real mail servers from the Apify platform, so treat the first probe run as a test with a short list.

### Pricing

Pay per event. One event, charged once for each row with status `valid`, `invalid` or `risky`:

| Event | Price | When |
|---|---|---|
| `email-verified` | 0.0006 USD (0.60 USD per 1,000 addresses), the same on every plan | per dataset row with status `valid`, `invalid` or `risky` |

Rows with status `unknown` are delivered and never charged. Duplicates are removed before checking and are not charged. Apify also applies its standard run start event once per run, shown on the pricing tab of this Actor. Set a maximum cost per run in the Console and the Actor stops with the results it has when that limit is reached.

### Reliability

- DNS lookups use the name servers of the container the Actor runs in. A lookup is tried twice over every configured server, with a pause between the tries; a truncated UDP answer is asked again over TCP; answers that do not match the question are ignored.
- Each address has a deadline of 12 times `timeoutSeconds`; past it the row is `unknown` and is not charged.
- Up to 50 addresses are checked at once. A domain shared by many addresses is looked up once.
- DNS stop: if 20 addresses in a row end as `unknown` because DNS does not answer, the run stops with a clear message instead of spending compute on a dead network. The rest are not checked and not charged.
- Each run writes a `RUN_SUMMARY` record to the key-value store: results by status, charged results, reason counts, DNS queries and retries, list sizes, timing and why a run stopped.

### Privacy

The Actor contacts no website and no third-party service: it sends DNS queries about the domain part of each address to the container's name servers, and, only with `checkSmtp` on, talks to the mail host of the address. Addresses are written to your run's dataset (the platform also keeps your input with the run), never to the run log, and the Actor keeps nothing between runs. Make sure you have a lawful basis to process the addresses you submit.

### Support

If a verdict looks wrong, open an issue on the Actor page with the address's domain (not the whole list) and the reasons the row shows. Edge

# Actor input Schema

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

One address per line. Also accepted: Name <address>, mailto: links, and several addresses in one line separated by commas or semicolons. Duplicates (ignoring upper and lower case) are removed and the input order is kept. Up to 100,000 entries per run. An entry that is not an address becomes a row with status invalid.

## `checkSmtp` (type: `boolean`):

Off by default. When on, the Actor connects to the mail server of each address on port 25, asks whether it would accept mail for that address, and hangs up without sending a message. It also asks about one random address on the same domain to spot servers that accept everything. Many platforms and mail servers block this: if the first 5 connections fail, probing is switched off for the rest of the run and each row says smtp_unreachable. Addresses on disposable domains and addresses that already failed the DNS check are not probed.

## `timeoutSeconds` (type: `integer`):

How long to wait for one DNS answer or one mail server reply before trying again or giving up. DNS lookups are tried twice. A lookup that never answers gives status unknown, which is not charged.

## `maxEmails` (type: `integer`):

Stops reading the list after this many different addresses. Duplicates do not count.

## `datasetId` (type: `string`):

Run it after a scraper: in the Integrations form put {{resource.defaultDatasetId}} here, or paste the ID of a dataset you own. Up to 100,000 rows are read, and the field named in Email field holds the address. Addresses pasted above are checked too, except the three sample ones. This Actor gets read access to this one dataset only.

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

The field of each dataset row that holds the address, such as email or contactEmail. A field that holds a list of addresses gives one address per item. Rows without an address in this field are skipped and not charged.

## Actor input object example

```json
{
  "emails": [
    "jane.doe@gmail.com",
    "info@mailinator.com",
    "john@gmial.com"
  ],
  "checkSmtp": false,
  "timeoutSeconds": 10,
  "maxEmails": 10000,
  "emailField": "email"
}
```

# Actor output Schema

## `verdicts` (type: `string`):

The dataset: one row per address with status, reasons and flags. Rows with status valid, invalid or risky are charged; unknown rows are not.

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

The RUN_SUMMARY record of the default key-value store: results by status, charged results, reason counts, DNS queries and retries, list sizes, timing and why a run stopped.

# 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": [
        "jane.doe@gmail.com",
        "info@mailinator.com",
        "john@gmial.com"
    ],
    "emailField": "email"
};

// Run the Actor and wait for it to finish
const run = await client.actor("edgef-4/bulk-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": [
        "jane.doe@gmail.com",
        "info@mailinator.com",
        "john@gmial.com",
    ],
    "emailField": "email",
}

# Run the Actor and wait for it to finish
run = client.actor("edgef-4/bulk-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": [
    "jane.doe@gmail.com",
    "info@mailinator.com",
    "john@gmial.com"
  ],
  "emailField": "email"
}' |
apify call edgef-4/bulk-email-verifier --silent --output-dataset

```

## MCP server setup

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