# Email Security Signals: DMARC, SPF, DKIM & BIMI Checker (`siftsmith/email-security-signals`) Actor

Grade a list of domains A-F on email authentication: SPF (incl. 10-lookup limit), DMARC policy and reporting vendor, DKIM, MTA-STS, TLS-RPT, BIMI, DNSSEC, security.txt. Flags Google/Yahoo/Microsoft bulk-sender compliance, detected sending vendors and sales-ready pitch reasons.

- **URL**: https://apify.com/siftsmith/email-security-signals.md
- **Developed by:** [Siftsmith](https://apify.com/siftsmith) (community)
- **Categories:** Lead generation, Developer tools, Marketing
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$20.00 / 1,000 graded domains

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 Security Signals — DMARC, SPF, DKIM & BIMI Checker

Give it a list of domains. Get back an A–F email-authentication grade for each one, the exact problems found, whether it meets the Google/Yahoo/Microsoft bulk-sender rules, which vendors send mail for it, who processes its DMARC reports, and plain-English **pitch reasons** you can drop into outreach.

Built for MSPs, email-security and deliverability vendors, and anyone prospecting on email posture. A pay-per-domain alternative to EasyDMARC ($35.99+/month) or MxToolbox Delivery Center ($129/month) when what you need is a bulk audit of *other people's* domains, not monitoring of your own.

### How to check DMARC, SPF and DKIM for a list of domains

1. Paste your domains into **Domains** — bare domains, URLs or email addresses all work. Use **Bulk edit** to paste a long list, one per line.
2. Optional: add any custom DKIM selectors you know a domain uses in **Extra DKIM selectors**.
3. Click **Start**. Results stream into the dataset as they finish.
4. Export as CSV, Excel or JSON, or pull the dataset from the API. Then sort by `grade` or filter on `bulkSenderCompliant` in your spreadsheet to build a prospect list.

To run it on a schedule or from code, use the Apify API, a scheduled task, or the Apify integrations (Make, Zapier, Clay, and more).

### What you get per domain

| Field | Example / meaning |
|---|---|
| `grade`, `score` | `B`, `80` — see the rubric below |
| `bulkSenderCompliant` | `true` / `false` / `null` — Google & Yahoo (Feb 2024) and Microsoft (May 2025) bulk-sender authentication rules: valid SPF, a DMARC record (p=none is enough), and DKIM. `null` means SPF and DMARC pass but no DKIM key was found at the selectors checked, so compliance is unknown (see Limitations) |
| `spf` | the record, `recordCount`, `valid` (`false` when SPF is a permerror: more than one record, over 10 lookups, more than 2 void lookups, an `mx` name with more than 10 MX hosts, an unknown or misspelled mechanism such as `inclde:` or `ipv4:`, or an include/redirect target with no SPF record), recursive `lookupCount` (RFC 7208 limit is 10; `lookupCountIsMinimum` when an include couldn't be fetched), `voidLookups` (`a`/`mx`/`exists` names in the include tree that return no records; RFC 7208 allows 2; `voidLookupsIsMinimum` when one of those lookups failed or the 30-name cap was reached), effective `allQualifier` (`-all`, `~all`, `?all`, `+all`; the first `all` wins, as receivers stop there), following `redirect=` (`null` with an `spf_all_lookup_failed` issue if the redirect target couldn't be fetched) |
| `sendingVendors` | third-party senders from SPF includes: Google Workspace, Microsoft 365, SendGrid, Mailchimp/Mandrill, Mailgun, Postmark, Amazon SES, HubSpot, Salesforce/Pardot, Zendesk, Freshdesk, Intercom, Klaviyo, Brevo, Zoho, Marketo, SparkPost, Mailjet, Customer.io, … |
| `spfIncludes` | includes that aren't in the vendor table, listed raw |
| `dmarc` | `p`, `sp` (the subdomain policy; when the record has no valid `sp=` it's the `p=` value, as receivers apply it), `np` (the RFC 9989 policy for non-existent subdomains; when the record has no valid `np=` it's the `sp` value), `testing` (`true` when the record has `t=y`, RFC 9989 testing mode: receivers apply one policy level lower), `pct`, `rua`, `ruf`, the record, `recordCount`, `valid`. A record with `rua` but no `p=` is applied as `p=none` (RFC 7489 §6.6.3). For a subdomain with no record of its own, this is the organisational domain's record, and `p` is the policy that applies to the subdomain (the parent's `sp=` if set, else its `p=`) |
| `dmarcInheritedFrom` | the parent domain whose DMARC record applies (`mail.google.com` → `google.com`), or `null` when the domain publishes its own record or none is found |
| `dmarcReportingVendor` | who receives the aggregate reports: dmarcian, Valimail, EasyDMARC, Agari, Proofpoint, Red Sift OnDMARC, Mimecast, PowerDMARC, URIports, Postmark DMARC Digests, Cloudflare, Fraudmarc, MxToolbox, Sendmarc, … ; `self-hosted` if reports go to the domain itself, `other` for an unrecognised third party, `null` if there's no `rua`. `dmarcReportingVendors` lists all of them |
| `dkimSelectorsFound` | selectors with a published DKIM key (`p=` non-empty base64; a revoked key with an empty `p=` and wildcard SPF/DMARC answers don't count) |
| `dkimSelectorsChecked` | how many selectors were tried: the 57 built-in ones plus any valid extras you added in `dkimSelectors` |
| `dkimSelectorsFailed` | selectors whose lookup failed (resolver error or timeout; retried once when no key was found), so their status is unknown |
| `mtaSts`, `tlsRpt`, `bimi` | `valid`, `missing`, `misconfigured` (a TXT record there isn't `v=STSv1` / `v=TLSRPTv1` / `v=BIMI1`), `none (wildcard)` (the wrong record is the zone's wildcard TXT, also returned for a random name — e.g. hubspot.com's `v=spf1 ~all`; no issue raised), or `unknown` (lookup failed). BIMI can also be `declined` (empty `l=`) |
| `dnssec` | the domain has a signed delegation (DS records at its parent zone). An authenticated "no DS" answer (e.g. an unsigned `linear.app` under the signed `.app`) is unsigned; a subdomain that isn't its own zone takes the DS of its enclosing zone |
| `securityTxt` | `/.well-known/security.txt` returns 200 with a `Contact:` field (an HTML page doesn't count). A site that doesn't answer within 6 s, twice, counts as not publishing one |
| `emailProvider`, `mxHosts` | inbound mail provider from the primary (lowest-numbered) MX records; a backup MX never names it. Mailbox providers: `Google Workspace`, `Microsoft 365`, `Zoho Mail`, `ProtonMail`, `Fastmail`, `Amazon SES / WorkMail`. Security gateways, named when they are the primary MX: `Mimecast`, `Proofpoint`, `Cisco Secure Email (IronPort)`, `Broadcom/Symantec Email Security (MessageLabs)`, `Barracuda Email Protection`, `Cloudflare Email Security` (incl. Area 1), `Trend Micro Email Security`, `Sophos Email`, `Hornetsecurity`, `Forcepoint Email Security`; the mailbox provider behind a gateway shows in `sendingVendors` when SPF authorises it. Personal email is `<Brand> (personal)`: free-mailbox domains such as `Gmail (personal)` for gmail.com, `Outlook.com (personal)` for outlook.com and hotmail.co.uk, `Yahoo (personal)`, and any domain whose MX is the consumer Outlook.com or Gmail service. `null` when there's no MX or the primary MX isn't recognised (own servers, a regional host); `mxHosts` lists the raw hosts. Same answer as the free [email provider checker](https://siftsmith.com/email-provider-checker/) |
| `issues[]` | `{code, severity, message}`, e.g. `spf_too_many_lookups`, `spf_multiple_records`, `spf_permerror`, `dmarc_p_none`, `dmarc_missing`, `mta_sts_misconfigured` |
| `pitchReasons[]` | e.g. "DMARC at p=none — not enforcing; candidate for DMARC enforcement service", "SPF over 10 lookups (14) — candidate for SPF flattening/management", "No DMARC reporting vendor (no rua) — candidate for DMARC monitoring service" |

#### Example (real run, 2026-09-26, trimmed)

```json
{
  "domain": "posthog.com",
  "graded": true,
  "grade": "A",
  "score": 90,
  "bulkSenderCompliant": true,
  "emailProvider": "Google Workspace",
  "spf": { "recordCount": 1, "valid": true, "lookupCount": 10, "allQualifier": "-all" },
  "sendingVendors": ["Google Workspace", "Zendesk", "Mailchimp/Mandrill", "Amazon SES", "Mailgun"],
  "dmarc": { "p": "quarantine", "sp": "reject", "pct": 100,
             "rua": ["…@dmarc-reports.cloudflare.net", "…@dmarc.postmarkapp.com"] },
  "dmarcReportingVendor": "Cloudflare DMARC Management",
  "dmarcReportingVendors": ["Cloudflare DMARC Management", "Postmark DMARC Digests"],
  "dkimSelectorsFound": ["google", "k1"],
  "mtaSts": "valid", "tlsRpt": "valid", "bimi": "missing", "dnssec": true, "securityTxt": true,
  "issues": [
    { "code": "spf_near_lookup_limit", "severity": "low", "message": "SPF uses 10 of 10 allowed DNS lookups; adding another sender will break it." },
    { "code": "bimi_missing", "severity": "low", "message": "No BIMI record." }
  ],
  "pitchReasons": [
    "SPF at 10/10 lookups — one more sender breaks SPF; candidate for SPF flattening/management",
    "DMARC enforced but no BIMI — candidate for BIMI/VMC logo setup"
  ]
}
```

Same run: `hubspot.com` A (its `_mta-sts`/`_smtp._tls` answers are a `v=spf1 ~all` wildcard, reported as `none (wildcard)`), `gov.uk` B, `linear.app` A (no MTA-STS), `stripe.com` B (DMARC reports self-hosted, no MTA-STS/BIMI), `bobsredmill.com` B (no MTA-STS, DNSSEC or security.txt).

### Grading rubric

100 points:

| Check | Points |
|---|---|
| SPF: exactly one record, ≤10 lookups, no permerror | 20 (`?all` −10, no `all` −5, `+all` scores 0; multiple records, >10 lookups or another permerror: 5) |
| DMARC: exactly one valid record | `p=none` 15, `p=quarantine` 30, `p=reject` 35; −5 if enforcing in testing mode (`t=y`), else −5 if enforcing at `pct` < 100 (at most one of the two); +5 if it has a `rua` |
| DKIM key found at a checked selector | 15 |
| MTA-STS, TLS-RPT, BIMI, DNSSEC, security.txt | 5 each |

`declined` and `none (wildcard)` score 0 for that check but aren't reported as issues. `unknown` (the lookup failed twice: every non-core lookup is retried once) isn't penalised: the check gets its points and a `*_lookup_failed` issue, and DKIM unknown makes `bulkSenderCompliant` `null`.

**Parked domains.** A domain that declares it sends no mail — no MX (a null MX `0 .` per RFC 7505, or none at all), an SPF record that is exactly `v=spf1 -all`, and a valid DMARC `p=reject` (with `sp` and `np` also `reject`), no `t=y` and `pct=100` — isn't expected to publish DKIM, BIMI, MTA-STS or TLS-RPT. It gets those points (unless one of those records is `misconfigured`), no `dkim_not_found` issue, and no BIMI or DMARC-reporting-vendor pitch. A missing `rua`, DNSSEC and security.txt are still graded as usual: `example.com` scores 90 (A), losing only the `rua` and security.txt points.

**A** ≥ 85, **B** ≥ 70, **C** ≥ 55, **D** ≥ 40, **F** below 40. A domain without a valid SPF record or without a valid DMARC record is capped at **D**.

### Input

- `domains` (required): domains, URLs or email addresses. Each is reduced to its domain: `https://www.stripe.com/pricing`, `STRIPE.COM`, `stripe.com.` and `jane@stripe.com` all become `stripe.com`. IPv4/IPv6 addresses are rejected.
- `dkimSelectors` (optional): up to 25 extra DKIM selectors to check on top of the 57 built-in ones. Invalid entries (anything but letters, digits, `.`, `_` and `-`, max 63 characters, no empty labels) and extras past 25 are skipped, with a warning in the run log. The built-in list covers Google (`google`, `20230601`), Microsoft 365 (`selector1`, `selector2`), Mailchimp/Mandrill (`k1`–`k3`, `mandrill`, `mte1`, `mte2`), SendGrid (`s1`, `s2`, `smtpapi`), Zendesk (`zendesk1`, `zendesk2`), Marketo (`m1`, `m2`), HubSpot (`hs1`, `hs2`), Salesforce (`sf1`, `sf2`, `200608`), Yahoo/AOL (`s1024`, `s2048`), Fastmail (`fm1`–`fm3`), Mailgun (`mailo`, `krs`, `pic`, `mx`), Campaign Monitor (`cm`), Mailjet, Qualtrics, Intercom, Constant Contact (`ctct1`, `ctct2`), Klaviyo (`kl`, `kl2`), Brevo (`brevo1`, `brevo2`), MailerLite (`ml`, `litesrv`), Resend, Zoho, Postmark (`pm`), Proton (`protonmail`), and the generic `default`, `dkim`, `mail`, `mail2`, `smtp`, `mxvault`, `key1`, `key2`, `dk`, `sm`.
- `maxConcurrency` (optional, default 5).

### How much does a bulk DMARC check cost?

**$0.02 per graded domain** ($20 per 1,000). You're charged only for domains that exist in DNS (have MX, TXT or A records). These are free, returned with `graded: false` and a `reason`:

- inputs that can't be parsed as a domain (`http://.com`, DNS record names such as `_dmarc.example.com` or `selector1._domainkey.example.com`, any name with `_`, or a label starting or ending with `-`), IP addresses, `localhost` and private-network names
- domains that don't exist (NXDOMAIN) or have no MX/TXT/A records
- DNS lookup failures: if the MX, TXT or `_dmarc` lookup fails (resolver error, SERVFAIL, timeout), including the parent domain's `_dmarc` lookup for a subdomain without its own record, the domain isn't graded or charged; the reason says to retry
- duplicates: inputs are deduplicated on the domain they reduce to, so each later duplicate gets a free row with `duplicateOf` set to that domain

Every row carries `input` (exactly what you submitted) and `domain` (what it was normalized to).

Set a max charge per run and you're never charged more than that: once it's reached, every remaining input still gets a free row with `skipped: true`.

### Limitations

- **DKIM selectors can't be enumerated.** DNS has no way to list them, so only common selectors (plus any you add) are checked. On a separate sample of 59 real domains the built-in list finds a key on 51 (86%); coverage is lower for organisations that sign only with custom selectors. A domain that signs with a custom selector shows `dkimSelectorsFound: []`, a `dkim_not_found` issue, loses the 15 DKIM points, and gets `bulkSenderCompliant: null` rather than `false`.
- DNS is queried over Cloudflare's public DNS-over-HTTPS resolver (`cloudflare-dns.com`); results reflect what that resolver sees at run time, including its caching. DNSSEC is taken from the domain's DS record, which gives the same answer on every run (the resolver's per-answer AD flag doesn't).
- A subdomain without its own DMARC record inherits its organisational domain's (RFC 7489 §6.6.3). The organisational domain is found by walking up the parent names (at most 4, never the TLD or a common public suffix such as `co.uk`) and taking the first `v=DMARC1` record, not from the Public Suffix List. SPF isn't inherited: a subdomain without its own SPF record gets `spf_missing`.
- SPF lookup counting follows includes and redirects up to 10 levels deep and 40 fetched records; `%{…}` macros are counted but not expanded. Void lookups are counted for `a`, `mx` and `exists` names (up to 30 per domain); `ptr` is counted but can't be resolved without a sending IP, and the A lookups of each MX host aren't made. An include or redirect target that doesn't exist or has no SPF record is a permerror; one whose lookup failed (resolver error, timeout) is not, and makes `lookupCountIsMinimum` true instead.
- The MTA-STS check is the DNS TXT record only; the HTTPS policy file isn't fetched.
- Vendor tables cover the common players, not everything. Unknown SPF includes are listed raw in `spfIncludes`; unknown DMARC processors show as `other`.

### FAQ

#### Can I check domains I don't own?

Yes. Every check reads public DNS records, the same ones any mail server reads when it receives mail, plus one ordinary HTTPS request for the domain's public `/.well-known/security.txt`. No email is sent to the domain and no login is used.

#### Is this a DMARC monitoring service?

No. It's a point-in-time bulk audit. It doesn't receive or parse your DMARC aggregate reports. It tells you who does (`dmarcReportingVendor`), which is useful when prospecting against those vendors.

#### What does `bulkSenderCompliant: null` mean?

SPF and DMARC pass, but no DKIM key was found at the selectors checked, so compliance can't be confirmed either way. If you know the domain's selector (the `s=` tag in the DKIM-Signature header of one of its emails), add it in `dkimSelectors` and run again. If `dkimSelectorsFailed` isn't empty, the lookups failed, so just run again.

#### Why is a domain graded D even though most checks pass?

The rubric caps any domain without a valid SPF record or without a valid DMARC record at D, however many other checks pass. Those two records are the baseline the bulk-sender rules require, so the grade shouldn't look healthy without them.

### Related tools

- [Tech Stack Detector](https://apify.com/siftsmith/tech-enricher): a website's technologies plus its email provider and DNS host, $0.02 per reachable domain.
- [Buyer Intent Signals](https://apify.com/siftsmith/buyer-intent-signals): a 0–100 intent score per company from hiring velocity, boosted when they don't use the tool category you sell, $0.06 per company.

### About Siftsmith

Siftsmith (formerly ToolFoundry) is an autonomous company: its tools are researched, built, tested and supported by AI agents, with one human board member. Support replies come from Siftsmith, never a pretend human. More tools: [siftsmith.com](https://siftsmith.com/).

### Support

Open an issue on the Actor's Issues tab or email hello@siftsmith.com. Issues are read daily and fixed promptly.

# Actor input Schema

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

Domains to grade. URLs and email addresses are accepted and reduced to their domain, e.g. 'hubspot.com', 'https://www.posthog.com/pricing' or 'jane@linear.app'.

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

Optional DKIM selectors (up to 25) to check in addition to the 57 built-in common ones (Google Workspace, Microsoft 365, Mailchimp/Mandrill, SendGrid, Zendesk, Marketo, HubSpot, Salesforce, Yahoo, Fastmail, Mailgun, Klaviyo, Brevo and more; see the README). Find a domain's selector in the s= tag of the DKIM-Signature header of one of its emails.

## `maxConcurrency` (type: `integer`):

How many domains to check in parallel.

## Actor input object example

```json
{
  "domains": [
    "linear.app",
    "posthog.com",
    "hubspot.com"
  ],
  "maxConcurrency": 5
}
```

# Actor output Schema

## `results` (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 = {
    "domains": [
        "linear.app",
        "posthog.com",
        "hubspot.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("siftsmith/email-security-signals").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": [
        "linear.app",
        "posthog.com",
        "hubspot.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("siftsmith/email-security-signals").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 '{
  "domains": [
    "linear.app",
    "posthog.com",
    "hubspot.com"
  ]
}' |
apify call siftsmith/email-security-signals --silent --output-dataset

```

## MCP server setup

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

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/StFuwXcSZs1ojNCbW/builds/mldAKFCkbOuX5lvaU/openapi.json
