# Bulk Email Health: SPF, DMARC, DKIM & MX Checker (`cottony_brittlegill/domain-mailhealth-bulk`) Actor

Bulk email deliverability and security audit: MX, SPF, DMARC and DKIM (common selectors) for up to 100,000 domains in one normalized JSON per domain, with an A-F grade and actionable issues. Failed lookups are never charged.

- **URL**: https://apify.com/cottony\_brittlegill/domain-mailhealth-bulk.md
- **Developed by:** [cottony\_brittlegill](https://apify.com/cottony_brittlegill) (community)
- **Categories:** Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 mail health reports

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

## Domain Mail-Health Bulk — SPF, DMARC & DKIM Checker at Scale

Bulk email deliverability and anti-spoofing audit: MX, SPF, DMARC and DKIM
(common selectors) for up to 100,000 domains in one run — one normalized
JSON per domain, with an A-F grade and a list of actionable issues. A bulk
SPF checker, a DMARC lookup tool and a DKIM record checker combined, so you
don't have to run `dig` three times per domain and reconcile the answers by
hand.

### The three records, briefly (for non-experts)

- **SPF** (Sender Policy Framework) — a DNS TXT record listing which mail
  servers are allowed to send email "from" your domain. Without it, anyone
  can forge an email that claims to be from you.
- **DMARC** — a DNS TXT record (at `_dmarc.<domain>`) that tells receiving
  mail servers what to do with messages that fail SPF/DKIM checks:
  `p=none` (monitor only), `p=quarantine` (send to spam) or `p=reject`
  (refuse outright). `p=none` is the common half-configured state: DMARC is
  published, but it enforces nothing yet.
- **DKIM** — a cryptographic signature added to outgoing mail, verified via
  a DNS TXT record at `<selector>._domainkey.<domain>`. The `<selector>` is
  chosen by whoever sends your mail (Google, Microsoft, your ESP...) and
  isn't discoverable from DNS alone — see "Honest limits" below.

Together, a domain with a strict SPF, a `p=reject` DMARC and a valid DKIM
key is hard to spoof. Missing any one of them leaves a real phishing/spoofing
gap — this actor tells you exactly which one, per domain.

### What you get per domain

Every dataset item is a single `MailHealthReport`:

| Field | Type | Meaning |
|---|---|---|
| `domain` | string | Normalized hostname you queried (punycode if IDN) |
| `registrableDomain` | string | eTLD+1 actually queried |
| `status` | `analyzed` | `no_mail` | `error` | See "Honest limits" below |
| `mx.present` / `mx.hosts` | boolean / array | Whether the domain publishes MX records, and which hosts |
| `spf` | object | `null` | SPF presence, qualifier (`fail`/`softfail`/`neutral`/`pass`/`null`), `includes`, `dnsLookups`, `issues`. `null` only if the DNS read itself errored |
| `dmarc` | object | `null` | DMARC presence, `policy`, `subdomainPolicy`, `pct`, `rua`, `ruf`, `issues`. `null` only if the DNS read itself errored |
| `dkim` | object | `null` | `selectorsTried`, `selectorsFound`, `selectorsErrored`, `present`, `issues`. `null` when DKIM probing is turned off |
| `grade` | `A`-`F` | `null` | See "Grade & score" below. `null` only when `status` is `error` |
| `score` | 0-100 | `null` | Same as above |
| `issues` | string\[] | Aggregated, human-readable, actionable (deduplicated) |
| `errors` | string\[] | Non-fatal DNS/lookup errors (technical, not a verdict) |
| `checkedAt` | ISO date | When the check ran |
| `chargeable` | boolean | `true` unless `status` is `error` |
| `charged` | boolean | Whether the Apify platform actually billed this report (see Pricing) |

Real output for `google.com` from a live run (dates/exact selectors will
differ on your own run):

```json
{
  "domain": "google.com",
  "registrableDomain": "google.com",
  "status": "analyzed",
  "mx": {
    "present": true,
    "hosts": [{ "priority": 10, "exchange": "smtp.google.com" }],
    "error": null
  },
  "spf": {
    "present": true,
    "raw": "v=spf1 include:_spf.google.com ~all",
    "allQualifier": "softfail",
    "includes": ["_spf.google.com"],
    "dnsLookups": 1,
    "issues": []
  },
  "dmarc": {
    "present": true,
    "raw": "v=DMARC1; p=reject; rua=mailto:mailauth-reports@google.com",
    "policy": "reject",
    "subdomainPolicy": null,
    "pct": null,
    "rua": ["mailto:mailauth-reports@google.com"],
    "ruf": [],
    "issues": []
  },
  "dkim": {
    "selectorsTried": ["google", "selector1", "selector2", "default", "k1", "k2", "dkim", "mail", "s1", "s2", "mandrill", "mxvault", "zoho", "fm1", "fm2", "fm3", "protonmail", "pm"],
    "selectorsFound": [],
    "selectorsErrored": [],
    "present": false,
    "issues": ["No DKIM key found at common selectors (the domain may use a custom selector we did not probe)"]
  },
  "grade": "B",
  "score": 82,
  "issues": ["No DKIM key found at common selectors (the domain may use a custom selector we did not probe)"],
  "errors": [],
  "checkedAt": "2026-07-28T21:24:29.018Z",
  "chargeable": true,
  "charged": false
}
```

Notice `google.com` grades **B**, not A — this is the honest DKIM limitation
in action (see below), not a bug: Google signs its own mail with a rotating,
dated selector (not one of the 18 common defaults we probe), so `dkim.present`
correctly comes back `false` even though Google obviously does sign its mail.
The `~all` SPF qualifier (softfail, not the strictest `-all`) also costs a
few points. Both are real, defensible signals — not false negatives.

### Grade & score — a v1 heuristic, not a certification

`score` starts at 100 and subtracts fixed penalties per dimension (no SPF
-30, SPF `+all` (permissive "pass everything") -25, SPF with no `all`
mechanism or a neutral `?all` -12, no DMARC -30, `p=none` -18,
`p=quarantine` -8, no DKIM at common selectors -15, multiple/duplicate
records -12 to -25, missing DMARC aggregate reports -5, partial `pct`
enforcement -5, and a few more — see `src/core/report.ts` for the exact
table). `grade` maps the score to A (≥90) / B (≥75) / C (≥60) / D (≥40) / F
(below). These weights are **our v1 judgment call**, not an industry
standard or a certification — treat the grade as a prioritization signal
("fix this domain first"), not a compliance attestation.

**Cap-at-C rule:** whenever a dimension could not actually be checked — the
SPF or DMARC DNS read itself errored, or **even one** of the (up to 18)
DKIM selector lookups errored — that dimension contributes **no** penalty
of its own, but the final grade is capped at C. This is deliberately
conservative, not a bug: a selector lookup that errored is a selector we
never got a real answer for, not one we verified as absent, so we treat
that DKIM check as unreliable rather than assume the failed lookup would
have come back clean. A single flaky DNS answer among 18 selector lookups
is enough to withhold a top grade — we would rather under-grade a domain
on incomplete evidence than hand out a false A or B. The cap never
upgrades a grade: a domain that already scores D or F on the dimensions we
*could* check stays D or F.

### Honest limits (read before buying)

- **DKIM: common-selector probing, not proof of absence.** There is no DNS
  mechanism to enumerate which selector(s) a domain uses for DKIM signing —
  you can only check `<selector>._domainkey.<domain>` for a selector you
  already know. We probe **18** widely-used default selectors (`google`,
  `selector1`, `selector2`, `default`, `k1`/`k2`, `dkim`, `mail`, `s1`/`s2`,
  `mandrill`, `mxvault`, `zoho`, `fm1`-`fm3`, `protonmail`, `pm` — the
  definitive, current list is `COMMON_SELECTORS` in `src/core/dkim.ts`, and
  every report's own `dkim.selectorsTried` echoes exactly what was probed
  for that domain, so it can never drift out of sync with what actually
  ran), or your own list via the `dkimSelectors` input. `dkim.present:
  false` means **"not found at the selectors we tried"** — never "this
  domain has no DKIM." The `google.com` example above is a real
  illustration: Google signs its own mail, but with a selector outside our
  default list.
- **The 100,000-domain cap truncates, it doesn't error.** Submit more than
  100,000 domains in one run (across `domains` and `domainsText` combined)
  and only the first 100,000 raw entries are looked at — anything past
  that is dropped before any DNS lookup, with no dataset row and no error.
  This is never silent: whenever it happens, the run log carries an
  explicit warning stating exactly how many domains were submitted,
  processed and dropped. Split larger lists across multiple runs instead.
- **Dataset row count can be lower than your input list.** Two things
  reduce it, neither of which is an error: exact-duplicate domains are
  deduplicated to a single report, and malformed entries (not a real
  domain) are dropped entirely — logged as `Invalid domain skipped: ...`
  in the run log, never written to the dataset. If you're reconciling row
  counts against your input, check the run log rather than assuming a 1:1
  mapping.
- **A DNS error is reported as `error`, never as a false "insecure".** If
  the MX lookup itself fails (timeout, SERVFAIL, etc. — anything other than
  the benign "no record" answer), `status` is `"error"`, `grade`/`score` are
  `null`, and the report is **not charged** (`chargeable: false`). We never
  turn an unreliable DNS answer into a low grade — that would misrepresent
  a network hiccup as a security failing.
- **`no_mail` is not a failure.** A domain that simply doesn't receive
  email (no MX records) gets `status: "no_mail"` — IS charged (the lookup
  succeeded and the data is reliable), and still gets SPF/DMARC/DKIM
  checked — best practice is for such a domain to publish `v=spf1 -all`
  and a DMARC `reject` policy specifically to prevent anyone spoofing mail
  "from" a domain that never sends any.
- **SPF's DNS-lookup counting is a heuristic**, not a full RFC 7208
  resolver: we count `include`/`a`/`mx`/`ptr`/`exists`/`redirect`
  mechanisms in the record itself and flag when they exceed 10 (the
  PermError threshold), but we do not recursively resolve nested
  `include:` chains to get an exact final count.
- **No mailbox-level testing.** This is a DNS-record audit (what you
  publish), not a live send test (whether a message actually lands in the
  inbox) — spam-filter reputation, content filtering and IP reputation are
  outside this actor's scope.

### Pricing

Pay-Per-Event (PPE) — no subscription:

| Event | Price | When it fires |
|---|---|---|
| `actor-start` | $0.005 | Once per run |
| `mail-report` | $0.002 | Once per domain report with `chargeable: true` (`status` other than `error`) |

**$2.00 per 1,000 domains** (`mail-report` events) plus a flat $0.005 per
run. Domains whose lookup itself failed (`status: "error"`) are never
billed — `chargeable`/`charged` are pushed on every dataset item so you can
reconcile your own invoice against the data.

### Use cases

**1. Deliverability audit of a prospect list.** Before a cold-email
campaign, check every domain in your prospect/lead list for MX/SPF/DMARC —
domains without a receiving mailbox or with a hostile DMARC policy waste
send reputation; skip or deprioritize them.

**2. Vendor/supplier due-diligence.** Part of a security or compliance
review: does this vendor's domain enforce DMARC `reject`? No DKIM at all?
A weak mail posture on a domain you're about to exchange sensitive data
with is a real signal, cheap to check in bulk before onboarding.

**3. Monitoring your own domain fleet.** Companies with dozens of
marketing/regional/legacy domains routinely forget to lock down SPF/DMARC
on domains that don't send mail — exactly the domains attackers spoof.
Run this on your whole portfolio on a schedule and catch drift.

Standard Apify API call (synchronous, returns the dataset items directly):

```bash
curl -X POST \
  "https://api.apify.com/v2/acts/YOUR_USERNAME~domain-mailhealth-bulk/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "domains": ["google.com", "freewebs.com", "test.com"],
    "dkim": true
  }'
```

### FAQ

**How do I check SPF, DMARC and DKIM for a list of domains at once?**
Feed the actor a list (or newline/comma-separated text) of domains; it
checks MX, SPF, DMARC and 18 common DKIM selectors per domain in one run
and returns a normalized JSON with an A-F grade and specific issues per
domain — no need to run `dig`/`checkdmarc` once per domain and reconcile
the answers yourself.

**Can you check DKIM without knowing the selector?**
Not really — there's no DNS mechanism to enumerate which selector a domain
uses for signing. We probe 18 widely-used default selectors (or your own
list via `dkimSelectors`); `dkim.present: false` means "not found at the
selectors we tried," never "this domain has no DKIM."

**Why doesn't `google.com` get an A grade from this checker?**
Because Google signs its mail with a rotating, dated selector outside our
18 common defaults, so `dkim.present` correctly comes back `false` even
though Google obviously signs its mail — an honest "not found at the
selectors we tried" result, not a bug. Its `~all` SPF softfail (rather than
the stricter `-all`) also costs a few points. See the worked example above.

**What does DMARC `p=none` mean, and is it enough?**
`p=none` means DMARC is published but enforces nothing — messages that fail
SPF/DKIM still get delivered, just reported on. `p=quarantine` sends
failures to spam, `p=reject` refuses them outright. `p=none` is the common
half-configured state this actor flags as an issue, not a pass.

### Input / Output

#### Input

```json
{
  "domains": ["google.com", "github.com"],
  "domainsText": "",
  "dkim": true,
  "dkimSelectors": [],
  "globalConcurrency": 20,
  "lookupTimeoutMs": 10000
}
```

| Field | Type | Default | Description |
|---|---|---|---|
| `domains` | string\[] | `["google.com", "github.com"]` | Domains to check (max 100,000). Bare domains or full URLs; scheme/`www`/path stripped automatically. |
| `domainsText` | string | — | Alternative/additional domains pasted as newline- or comma-separated text; merged with `domains`. |
| `dkim` | boolean | `true` | Probe common DKIM selectors. Turning this off skips ~18 sequential DNS lookups per domain (faster, but no `dkim` data — reports come back with `dkim: null`). |
| `dkimSelectors` | string\[] | — | Optional: probe your own selector list instead of the 18 common defaults (useful if you know your ESP's selector). |
| `globalConcurrency` | integer | `20` | Concurrent domains in flight. |
| `lookupTimeoutMs` | integer | `10000` | Per-lookup timeout in milliseconds (applies to every individual MX/TXT query). |

#### Output (dataset items)

One `MailHealthReport` per domain (see field table above), in the order
resolved (not necessarily input order, since domains run concurrently).
Three real examples from a live run — a well-configured domain, a domain
that receives mail but publishes no auth records at all, and a domain with
no mail setup whatsoever:

```json
{
  "domain": "freewebs.com",
  "registrableDomain": "freewebs.com",
  "status": "analyzed",
  "mx": {
    "present": true,
    "hosts": [
      { "priority": 0, "exchange": "eu-smtp-inbound-1.mimecast.com" },
      { "priority": 5, "exchange": "eu-smtp-inbound-2.mimecast.com" }
    ],
    "error": null
  },
  "spf": { "present": false, "raw": null, "allQualifier": null, "includes": [], "dnsLookups": 0,
    "issues": ["No SPF record: sender spoofing is not mitigated"] },
  "dmarc": { "present": false, "raw": null, "policy": null, "subdomainPolicy": null, "pct": null, "rua": [], "ruf": [],
    "issues": ["No DMARC record: no domain-level email authentication enforcement"] },
  "dkim": {
    "selectorsTried": ["google", "selector1", "selector2", "default", "k1", "k2", "dkim", "mail", "s1", "s2", "mandrill", "mxvault", "zoho", "fm1", "fm2", "fm3", "protonmail", "pm"],
    "selectorsFound": [], "selectorsErrored": [], "present": false,
    "issues": ["No DKIM key found at common selectors (the domain may use a custom selector we did not probe)"]
  },
  "grade": "F",
  "score": 25,
  "issues": [
    "No SPF record: sender spoofing is not mitigated",
    "No DMARC record: no domain-level email authentication enforcement",
    "No DKIM key found at common selectors (the domain may use a custom selector we did not probe)"
  ],
  "errors": [],
  "checkedAt": "2026-07-28T21:24:28.776Z",
  "chargeable": true,
  "charged": false
}
```

```json
{
  "domain": "test.com",
  "registrableDomain": "test.com",
  "status": "no_mail",
  "mx": { "present": false, "hosts": [], "error": null },
  "spf": { "present": false, "raw": null, "allQualifier": null, "includes": [], "dnsLookups": 0,
    "issues": ["No SPF record: sender spoofing is not mitigated"] },
  "dmarc": { "present": false, "raw": null, "policy": null, "subdomainPolicy": null, "pct": null, "rua": [], "ruf": [],
    "issues": ["No DMARC record: no domain-level email authentication enforcement"] },
  "dkim": {
    "selectorsTried": ["google", "selector1", "selector2", "default", "k1", "k2", "dkim", "mail", "s1", "s2", "mandrill", "mxvault", "zoho", "fm1", "fm2", "fm3", "protonmail", "pm"],
    "selectorsFound": [], "selectorsErrored": [], "present": false,
    "issues": ["No DKIM key found at common selectors (the domain may use a custom selector we did not probe)"]
  },
  "grade": "F",
  "score": 25,
  "issues": [
    "Domain has no MX records (does not receive email); it should still publish SPF -all and DMARC reject to prevent spoofing",
    "No SPF record: sender spoofing is not mitigated",
    "No DMARC record: no domain-level email authentication enforcement",
    "No DKIM key found at common selectors (the domain may use a custom selector we did not probe)"
  ],
  "errors": [],
  "checkedAt": "2026-07-28T21:24:31.136Z",
  "chargeable": true,
  "charged": false
}
```

Both examples above are `chargeable: true` (`charged: false` only because
this was a local run outside Apify's Pay-Per-Event ledger, not because the
result was unreliable) — a low grade or `no_mail` status is a legitimate,
billable result. Only genuine DNS-lookup failures (`status: "error"`) are
`chargeable: false`.

### Related tools

Part of the same domain-tooling family, same pay-per-event pricing, same
"never charge a failed lookup" policy:

- **[Bulk Domain Intelligence](https://apify.com/cottony_brittlegill/domain-intelligence-bulk)**
  — WHOIS/RDAP registration data, DNS records, SSL certificate check and
  availability. Run it against the same domain list for a full
  registration + DNS + SSL + mail-security picture; use it when the
  question is "who owns this and is it up," not "can its mail be trusted."
- **[Typosquat & Lookalike Domain Detector](https://apify.com/cottony_brittlegill/domain-typosquat-detector)**
  — finds lookalike/typosquatted domains registered against a brand you own
  and classifies which look like active threats versus parked inventory.
  Use it when you're hunting for impersonators of your brand rather than
  auditing mail security on domains you already have in hand.

# Actor input Schema

## `domains` (type: `array`):

List of domains to check (max 100,000). Accepts bare domains or URLs; www/scheme/path are stripped automatically.

## `domainsText` (type: `string`):

Alternative to the list above: paste domains separated by newlines or commas. Merged with the list.

## `dkim` (type: `boolean`):

Probe common DKIM selectors (slower; ~18 DNS lookups per domain).

## `dkimSelectors` (type: `array`):

Optional: custom DKIM selectors to probe instead of the common defaults.

## `globalConcurrency` (type: `integer`):

How many domains to process in parallel. Higher is faster but uses more memory.

## `lookupTimeoutMs` (type: `integer`):

How long to wait for each individual DNS lookup (MX/TXT) before giving up on it (milliseconds).

## Actor input object example

```json
{
  "domains": [
    "google.com",
    "github.com"
  ],
  "dkim": true,
  "globalConcurrency": 20,
  "lookupTimeoutMs": 10000
}
```

# 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 = {
    "domains": [
        "google.com",
        "github.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("cottony_brittlegill/domain-mailhealth-bulk").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 = { "domains": [
        "google.com",
        "github.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("cottony_brittlegill/domain-mailhealth-bulk").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "domains": [
    "google.com",
    "github.com"
  ]
}' |
apify call cottony_brittlegill/domain-mailhealth-bulk --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=cottony_brittlegill/domain-mailhealth-bulk",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/acts/cbnbZbYTRZsOoLqDA/builds/EUO4OVI8LEyQLjKyp/openapi.json
