# Email List Pre-Flight: dedupe, syntax, MX, disposable screen (`cozyish_taqueria/email-list-preflight`) Actor

Screen a large email list before you pay a per-address verifier: normalise, de-duplicate, syntax-check, classify role/disposable/freemail addresses and flag domains with no MX. No SMTP probe, so it is priced as a pre-filter and never claims mailbox-level deliverability.

- **URL**: https://apify.com/cozyish_taqueria/email-list-preflight.md
- **Developed by:** [Michael Campanelli](https://apify.com/cozyish_taqueria) (community)
- **Categories:** Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.30 / 1,000 email address screeneds

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

## Email List Pre-Flight

**Screen a large email list before you pay a per-address verifier.**
Deduplicate, normalise, syntax-check, classify (role / disposable / freemail) and
flag domains that cannot receive mail — at a fraction of the cost of a full
mailbox-level verification pass.

### Why a pre-filter and not another "email verifier"

The Apify Store already has strong per-address SMTP verifiers at roughly
$0.00065–$0.00089 per address (observed posted prices, 2026-10-08). They are
worth it, but they are priced *per address*, so every address that was obviously
dead before you started — a typo, a duplicate, a `mailinator.com` throwaway, a
domain with no MX record at all — is money spent on nothing.

This actor does that first pass deterministically and cheaply, and it never
claims more than it measures. Its output separates *definite* problems
(`invalid_syntax`, `duplicate`, `no_mx`, `disposable`, `role_account`) from
*unknowns* (`mxFound: null` when a domain was not resolved). Nothing is ever
reported as "deliverable": `stats.smtpChecked` is `false` in every run.

### What it checks

| Check | What it means | Needs DNS |
| --- | --- | --- |
| normalise | strips display names, `mailto:`, quotes, trailing domain dots; lowercases | no |
| dedupe | one row per unique address; later copies get `verdict: duplicate` and `duplicateOf` | no |
| syntax | RFC-lite dot-atom local part, length limits (64 local / 254 total / 63 per label), valid label and TLD shape | no |
| MX | does the domain publish any MX record at all | yes |
| disposable | throwaway providers (short, auditable exact-domain list) | no |
| role | shared mailboxes (`info@`, `sales@`, `noreply@`, …) — deliverable, but usually not the person you want | no |
| freemail | consumer providers (gmail, outlook, …) — a flag, never a failure verdict | no |
| typo suggestion | `gmial.com` → `gmail.com`, `example.con` → `example.com` | no |

### Input

```json
{
  "addresses": ["Dana <dana@acme.example>", "sales@acme.example"],
  "emailsText": "optional pasted blob, newline/comma/space separated",
  "checks": { "mx": true, "disposable": true, "role": true, "freemail": true },
  "concurrency": 8,
  "resolverTimeoutMs": 2500
}
```

`checks.mx: false` makes a run completely DNS-free (pure CPU), which is the
cheapest possible shape: no proxies, no external services, no browser.

### Output

One dataset row per input entry, in input order:

```json
{
  "input": "Dana <dana@acme.example>",
  "normalized": "dana@acme.example",
  "domain": "acme.example",
  "verdict": "ok",
  "duplicateOf": null,
  "flags": { "syntaxOk": true, "mxFound": true, "isDisposable": false, "isRole": false, "isFreemail": false },
  "mxHosts": ["mx1.acme.example", "mx2.acme.example"],
  "suggestedCorrection": null,
  "screened": true
}
```

`screened` marks the rows that were actually screened (and therefore billed);
duplicate rows are reported with `screened: false` and are never charged.

Plus a `SUMMARY` key-value record with the run totals (input, unique, screened,
duplicates, per-verdict counts, resolver errors).

#### Declared outputs

The Actor ships a machine-readable output schema (`.actor/output_schema.json`),
so the run's Output tab, the `output` property of the API's run response, and
MCP/agent clients can find the results without reading this file:

| Output | Link |
| --- | --- |
| Screened addresses (JSON) | `{{links.apiDefaultDatasetUrl}}/items` |
| Screened addresses (CSV) | `{{links.apiDefaultDatasetUrl}}/items?format=csv` |
| Run summary | `{{links.apiDefaultKeyValueStoreUrl}}/records/SUMMARY` |

The CSV link is the one to use if you want the cleaned list straight in a mail
tool; the JSON link keeps the `flags` object per row.

`verdict` is one of `ok`, `invalid_syntax`, `no_mx`, `disposable`,
`role_account`, `duplicate`. A `duplicate` row is reported but not billed.

### Billing model

Pay-per-event: one `address-screened` event per **unique address evaluated**
(duplicates are never charged twice). `stats.screened` is the number of events
the run requests; `SUMMARY.chargedEvents` is the number the platform actually
confirmed, so a buyer can audit the bill from the dataset alone. The two differ
only when a run hits the buyer's `maxTotalChargeUsd` budget, and then the
shortfall is logged and recorded in `chargeLimitReached`/`chargeError` instead
of being hidden.

### Limits, stated plainly

- No SMTP probe. Mailbox existence, catch-all domains, full mailboxes and
  greylisting are out of scope by design — run a verifier after this pass.
- The disposable and freemail lists are deliberately short, exact-domain lists
  rather than a scrape of a public blocklist: they are auditable and stable, and
  they will not catch every throwaway provider in existence.
- Local-part case is normalised for deduplication (RFC 5321 allows
  case-sensitive local parts; in practice none of the large providers use that).
- DNS answers are point-in-time and are reported as measured, never cached
  across runs.

### Development

```
node test/screen.test.js && node test/input.test.js && node test/pipeline.test.js
```

`run.js` replays the whole pipeline offline from a JSON input that carries a
captured `mx` map, so the deterministic behaviour (and the charged-event count)
can be reproduced without any network access.

### Offline replay (`run.js`)

```
PENNY_INPUT_FILE=/path/to/input.json node run.js
```

`run.js` calls the same `lib/pipeline.js` the actor calls, so its output is
byte-deterministic. If `PENNY_INPUT_FILE` is unset, unreadable or does not carry
`addresses[]`/`emailsText`, it falls back to an embedded demo fixture and says so
on stderr; the emitted JSON always carries `inputSource` (`file:<path>` or
`demo`), so a verifier can never mistake the fallback for their own input.

# Actor input Schema

## `addresses` (type: `array`):

Email addresses to screen. Entries may be bare (a@b.com), display-name forms (Dana <a@b.com>) or mailto: links.

## `emailsText` (type: `string`):

Alternative to the list: paste addresses separated by newlines, commas, semicolons or spaces.

## `checkMx` (type: `boolean`):

Look up each domain's MX records and flag domains that cannot receive mail. Turn this off for a fully offline run (no DNS traffic at all).

## `flagDisposable` (type: `boolean`):

Mark addresses on known throwaway providers, from a short auditable list.

## `flagRole` (type: `boolean`):

Mark shared mailboxes such as sales@, support@ or noreply@ that are usually not a person.

## `flagFreemail` (type: `boolean`):

Mark addresses on large consumer providers (gmail.com, outlook.com, ...) which are deliverable but usually not a business lead.

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

Parallel MX lookups (1-25). Results are cached per domain, so concurrency only matters when the list spans many distinct domains.

## `resolverTimeoutMs` (type: `integer`):

Per-domain resolver timeout, 100-10000 ms.

## Actor input object example

```json
{
  "addresses": [
    "dana@acme.example",
    "sales@acme.example",
    "Dana <dana@acme.example>",
    "throwaway@mailinator.com"
  ],
  "checkMx": true,
  "flagDisposable": true,
  "flagRole": true,
  "flagFreemail": true,
  "concurrency": 8,
  "resolverTimeoutMs": 2500
}
```

# Actor output Schema

## `screenedAddresses` (type: `string`):

JSON array of rows: input, normalized, domain, verdict, duplicateOf, flags (syntaxOk, mxFound, isDisposable, isRole, isFreemail), mxHosts, suggestedCorrection, screened. verdict is one of ok, invalid_syntax, no_mx, disposable, role_account, duplicate. Duplicate rows are reported but never billed.

## `screenedAddressesCsv` (type: `string`):

The same rows as a CSV download, for pasting straight into a mail tool, CRM or suppression list.

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

JSON record with the totals input, unique, screened, duplicates, syntaxInvalid, noMx, disposable, role, freemail, checkMx, byVerdict (per-verdict counts), domainsResolved, smtpChecked, claim, dnsErrors, and the charge fields chargeEvent, chargedEvents, chargeLimitReached, chargeableWithinLimit and chargeError (chargeError is null when billing succeeded, or the platform's message when it did not). smtpChecked is always false: this is a pre-filter, not a mailbox verifier.

# 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 = {
    "addresses": [
        "dana@acme.example",
        "sales@acme.example",
        "Dana <dana@acme.example>",
        "throwaway@mailinator.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("cozyish_taqueria/email-list-preflight").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 = { "addresses": [
        "dana@acme.example",
        "sales@acme.example",
        "Dana <dana@acme.example>",
        "throwaway@mailinator.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("cozyish_taqueria/email-list-preflight").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 '{
  "addresses": [
    "dana@acme.example",
    "sales@acme.example",
    "Dana <dana@acme.example>",
    "throwaway@mailinator.com"
  ]
}' |
apify call cozyish_taqueria/email-list-preflight --silent --output-dataset

```

## MCP server setup

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

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/Xu11gnKfASxKmZ12O/builds/jSFP39MvoVnfPBOm7/openapi.json
