# Email Cleaner: dedupe, fix and flag disposable emails (no SMTP) (`ochinimus/email-list-cleaner`) Actor

Clean the email list a scraper left behind: fixes mailto: and encoding leftovers, drops junk like logo@2x.png and tracking IDs, removes duplicates, and checks the domain (MX, disposable, role, typos). Never claims a mailbox exists.

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

## Pricing

from $0.30 / 1,000 checked emails

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 Cleaner: fix, dedupe and check scraped emails (no SMTP)

**Cleans the email list a scraper left behind. It fixes the leftovers around an address, drops the junk that is not an address at all, removes duplicates, and checks each domain with DNS: does it take mail, is it disposable, is the address a role account, is the domain a typo. It does not check that a mailbox exists: there is no SMTP probe, and the actor never says an address is "deliverable".**

Point it at the output of a contact, Google Maps or website email scraper, or paste a list. One result per unique address, in input order, with a status, the reasons and the checks behind it.

### What it does not do, and why

- **No mailbox check.** It does not connect to the recipient's mail server (SMTP). Outbound port 25 is blocked on the Apify platform: connections to the mail servers of Gmail, Outlook, Yahoo, iCloud and Proton on port 25 time out from an Actor, while port 443 connects normally (measured on 15 Oct 2026). A mailbox check from here would be a guess, so the actor does not make one.
- **No "deliverable" and no "verified".** `valid` means: the domain takes mail and nothing about the address looks wrong. A `valid` address can still bounce.
- **No catch-all test.** A catch-all domain accepts every address, which only an SMTP probe can detect. The `mx_provider` field names who runs the mail (Google, Microsoft 365, Zoho, Proofpoint, Mimecast and more) so you can reason about it yourself.
- **If you need mailbox-level answers:** run the `valid` and `risky` results through an SMTP verifier. This actor removes the junk, the duplicates and the dead domains first, so you pay the SMTP verifier for far fewer addresses.

### What it cleans, in order

1. **Fixes the leftovers.** `mailto:` prefixes and their `?subject=` part, `%20` and other percent-encoding, the JSON remnants `u003e` and `u003c`, quotes, angle brackets, brackets, trailing punctuation, invisible characters, and the address inside `Name <name@site.com>`. The domain is lower-cased (the part before the `@` keeps its case) and an international domain becomes its ASCII form. The raw line stays in `input`; `fixed_from` holds it when cleaning changed something.
2. **Drops junk that is not an address.** `file_name`: asset names read as addresses (`logo@2x.png`, `icon@3x.jpg`), version strings and paths (`bootstrap@5.2.3`). `tracking_id`: machine ids at error-tracking hosts (`5f3a...@sentry.wixpress.com`, `...@sentry.io`) and local parts of 28 or more hex characters or a UUID. `placeholder`: fill-in addresses (`name@example.com`, `you@domain.com`, `.test` and `.invalid` names). These are `invalid` and cost no DNS lookup.
3. **Removes duplicates** (case-insensitive, after step 1), within the run and across the whole list.
4. **Looks up each domain once** (cached), a bounded number at a time: MX first; a null MX (RFC 7505) is `invalid`; no MX falls back to an A or AAAA record (RFC 5321 section 5.1) and is `risky`; a domain that does not exist is `invalid`.
5. **Flags** a disposable domain (public list, parent domains included), a role account (`info@`, `sales@`, `support@`, `noreply@`, `info.chicago@`), a free provider, a likely typo (`gmai.com`, `gmail.con`) with a suggested fix in `did_you_mean`, and a non-ASCII local part.

### Statuses

| Status | Meaning | Charged |
|---|---|---|
| `valid` | The domain takes mail and nothing about the address looks wrong. | yes |
| `risky` | The domain takes mail, but the address is disposable, a role account, a likely typo, or reachable only through an A record. | yes |
| `invalid` | Bad syntax, junk, a domain that does not exist, a null MX, or a domain with no mail host. | yes |
| `unknown` | The DNS lookup did not finish (timeout or a server failure). Run it again. | **no** |

A line with no `@` at all is returned as `invalid` with the reason `not_an_email` and is not charged.

Reasons: `invalid_syntax`, `not_an_email`, `junk_file_name`, `junk_tracking_id`, `junk_placeholder`, `domain_not_found`, `null_mx`, `no_mail_host`, `a_record_fallback`, `disposable`, `role_account`, `did_you_mean`, `non_ascii_local_part`, `dns_timeout`, `dns_error`.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `emails` | array of strings | prefill: a 10-line sample | The addresses, one per line, as the scraper gave them (up to 200,000 lines in total). |
| `datasetId` | dataset | none | A dataset from another Actor. Pick it in the Console, or pass its id through the API; the run gets read access to that one dataset. Can be used together with `emails`. |
| `field` | string | `email` | The field that holds the address in each dataset item. A dotted path (`contact.email`) works, and a field that holds a list of addresses (or `a@x.com; b@y.com`) is read one by one. |
| `dedupe` | boolean | `true` | On: a repeated address is dropped from the output. Off: every line is returned, a repeat copies the first verdict and names it in `duplicate_of`. A repeat is never charged either way. |

With no input at all the actor runs the 10-line sample, so the default run always returns data. Values that are not text are skipped and counted; if no line contains an `@` the run stops with a clear message before any lookup. Your maximum charge per run is respected: the run stops cleanly and keeps the rows already returned.

### Output (one item per unique address)

| Field | Meaning |
|---|---|
| `input` | The line exactly as it came in. |
| `email` | The cleaned address (empty when the line has no `@`). |
| `status`, `reasons` | The verdict and why. |
| `fixed_from` | The raw line, when cleaning changed it. |
| `junk` | `file_name`, `tracking_id` or `placeholder`, else empty. |
| `domain` | The domain, lower case, ASCII form. |
| `accepts_mail`, `mx_null` | Whether the domain has a mail server (or an address record to fall back on); whether it publishes a null MX. |
| `mx_provider` | `google`, `microsoft`, `zoho`, `proofpoint`, `mimecast`, `yahoo`, `icloud`, `proton`, `fastmail` and more; `other` for an MX we do not know; `none` when there is no MX. |
| `disposable`, `role`, `free_provider` | The list-based flags. |
| `did_you_mean` | A corrected address when the domain looks like a typo. |
| `duplicate_of` | With `dedupe` off: the first input line that held the same address. |

The dataset has an **Overview** table. A summary of the run (counts by status, duplicates, charged rows, which DNS path answered) is saved in the key-value store under `SUMMARY`.

### How to run it

**In the Apify Console:** paste the addresses, or pick the dataset of a scraper run, and click Start.

**After another Actor, through the API** (replace `YOUR_API_TOKEN` and the dataset id):

```bash
curl -X POST "https://api.apify.com/v2/acts/ochinimus~email-list-cleaner/run-sync-get-dataset-items" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"datasetId": "SCRAPER_RUN_DEFAULT_DATASET_ID", "field": "email"}'
```

### Run it automatically after a scraper (Actor-to-Actor integration)

Open the scraper's page in the Console, go to **Integrations**, click **Add integration** and search for **Email Cleaner**. Then set:

- **Start when:** `run succeeded`.
- **Actor input:** `datasetId` = `{{resource.defaultDatasetId}}` (the dataset of the run that just finished) and `field` = the name of the email field in that scraper's output, for example `emails` for the Contact Details Scraper (`emails` holds a list; each address is read one by one). `field` defaults to `email`.

Every time the scraper finishes, the cleaner starts on its dataset and writes its own dataset of cleaned rows. The run gets read access to that one dataset only. If the scraper found no address (the field is there but empty), the cleaner finishes with nothing to check and nothing charged; if the field name does not exist in the items, the run stops with a message naming the fields it found.

### Sample output

Real results for three lines of the default sample, from a run on the Apify platform on 15 Oct 2026:

```json
[
  {
    "input": "Jane.Doe@Gmail.COM",
    "email": "Jane.Doe@gmail.com",
    "status": "valid",
    "reasons": [],
    "fixed_from": "Jane.Doe@Gmail.COM",
    "junk": null,
    "domain": "gmail.com",
    "accepts_mail": true,
    "mx_null": false,
    "mx_provider": "google",
    "disposable": false,
    "role": false,
    "free_provider": true,
    "did_you_mean": null,
    "duplicate_of": null
  },
  {
    "input": "logo@2x.png",
    "email": "logo@2x.png",
    "status": "invalid",
    "reasons": [
      "junk_file_name"
    ],
    "fixed_from": null,
    "junk": "file_name",
    "domain": "2x.png",
    "accepts_mail": null,
    "mx_null": false,
    "mx_provider": null,
    "disposable": false,
    "role": false,
    "free_provider": false,
    "did_you_mean": null,
    "duplicate_of": null
  },
  {
    "input": "john@gmai.com",
    "email": "john@gmai.com",
    "status": "risky",
    "reasons": [
      "did_you_mean"
    ],
    "fixed_from": null,
    "junk": null,
    "domain": "gmai.com",
    "accepts_mail": true,
    "mx_null": false,
    "mx_provider": "other",
    "disposable": false,
    "role": false,
    "free_provider": false,
    "did_you_mean": "john@gmail.com",
    "duplicate_of": null
  }
]
```

### Pricing

Pay per event: **$0.30 per 1,000 checked emails** ($0.0003 each, event `checked-email`), plus Apify's start fee of $0.00005 per run.

Charged: one event per **unique** address that got a verdict (`valid`, `risky` or `invalid`). **Free:** a repeated address, an `unknown` (the lookup did not finish), a line that is not an address, a blank line, and a run that stops on bad input (a dataset that cannot be read, no `@` anywhere, an invalid field name).

### Limits and data

- One DNS lookup per domain, cached for the run; 4 s per lookup with the system resolver. If the platform's resolver stops answering, the actor switches to DNS over HTTPS (port 443) and the summary records which path answered.
- The disposable-domain list is a snapshot (see `SOURCES.md` for its source, licence and date) and the other lists are our own; a new throwaway service can be missing until the list is refreshed.
- `role`, `disposable` and typo flags are rules, not certainty: `risky` means "look at this one", not "will bounce".
- Up to 200,000 lines per run; 256 MB of memory.

### Source code

[github.com/seekdaseek/agentfeed-apify/tree/main/email-list-cleaner](https://github.com/seekdaseek/agentfeed-apify/tree/main/email-list-cleaner) (MIT). Data sources and licences: [SOURCES.md](https://github.com/seekdaseek/agentfeed-apify/blob/main/email-list-cleaner/SOURCES.md).

# Actor input Schema

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

The addresses to clean, one per line, exactly as the scraper gave them (up to 200,000). Leftovers such as mailto:, %20, u003e, quotes and angle brackets are removed first. Every unique address that gets a verdict is one result; a repeat, a value that is not an address, and a lookup that could not finish are free.

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

Optional. A dataset with an email field, for example the output of a contact or Google Maps email scraper, to clean it in the same run (pick it, or pass its id through the API). The run gets read access to this one dataset only. Leave empty to use only the list above.

## `field` (type: `string`):

The field that holds the address in each dataset item. A dotted path such as contact.email works, and a field that holds a list of addresses is read one by one.

## `dedupe` (type: `boolean`):

On (the default): an address that appears again is dropped from the output and never charged twice. Off: every input line is returned, a repeat copies the first verdict and names it in Duplicate of, and is still never charged.

## Actor input object example

```json
{
  "emails": [
    "Jane.Doe@Gmail.COM",
    "mailto:info@apify.com",
    "john@gmai.com",
    "logo@2x.png",
    "5f3a9c1e7b2d4a6f8c0e1d3b5a7c9e2f@sentry.wixpress.com",
    "name@example.com",
    "temp@mailinator.com",
    "ceo@apify.com",
    "someone@this-domain-does-not-exist-af.com",
    "jane.doe@gmail.com"
  ],
  "field": "email",
  "dedupe": true
}
```

# Actor output Schema

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

No description

# 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",
        "mailto:info@apify.com",
        "john@gmai.com",
        "logo@2x.png",
        "5f3a9c1e7b2d4a6f8c0e1d3b5a7c9e2f@sentry.wixpress.com",
        "name@example.com",
        "temp@mailinator.com",
        "ceo@apify.com",
        "someone@this-domain-does-not-exist-af.com",
        "jane.doe@gmail.com"
    ],
    "field": "email"
};

// Run the Actor and wait for it to finish
const run = await client.actor("ochinimus/email-list-cleaner").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",
        "mailto:info@apify.com",
        "john@gmai.com",
        "logo@2x.png",
        "5f3a9c1e7b2d4a6f8c0e1d3b5a7c9e2f@sentry.wixpress.com",
        "name@example.com",
        "temp@mailinator.com",
        "ceo@apify.com",
        "someone@this-domain-does-not-exist-af.com",
        "jane.doe@gmail.com",
    ],
    "field": "email",
}

# Run the Actor and wait for it to finish
run = client.actor("ochinimus/email-list-cleaner").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",
    "mailto:info@apify.com",
    "john@gmai.com",
    "logo@2x.png",
    "5f3a9c1e7b2d4a6f8c0e1d3b5a7c9e2f@sentry.wixpress.com",
    "name@example.com",
    "temp@mailinator.com",
    "ceo@apify.com",
    "someone@this-domain-does-not-exist-af.com",
    "jane.doe@gmail.com"
  ],
  "field": "email"
}' |
apify call ochinimus/email-list-cleaner --silent --output-dataset

```

## MCP server setup

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

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/fYzYT6ryw1pZcDPcI/builds/DeuyHse161UjyOCDF/openapi.json
