# Email DNS Change Audit — SPF, DMARC, DKIM & MX (`iwins/email-dns-change-audit`) Actor

Bulk-check public email DNS and compare it with a supplied baseline. See exact SPF, DMARC, MX and known DKIM selector changes. Ignore TTL noise; keep failed lookups unknown. Read-only DNS evidence, without inbox scores or mail sending.

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

## Pricing

$2.00 / 1,000 domain audits

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 DNS Change Audit

Check email-related DNS across a domain portfolio and see what changed from a reviewed baseline. Built for admins and agencies maintaining several brands, especially after a DNS migration or a new sending-service setup.

The Actor reads **MX, SPF, direct DMARC and the DKIM selectors you specify**. It returns source-linked observations and exact added/removed values. It does not send mail, probe mailboxes, change DNS, assign a deliverability score or promise inbox placement.

### Quick start

```json
{"domains":["google.com","example.com"],"dkimSelectors":[],"previousSnapshots":{}}
```

1. Enter **1–50 bare domains**. Internationalized domains are converted to ASCII; duplicates are checked once. Email addresses, URLs and private DNS names are rejected.
2. Optionally provide up to **five known DKIM selectors**. The same selectors are checked on every domain. If you do not know them, leave the list empty: the result says DKIM was not checked. A missing supplied selector does not prove there is no DKIM.
3. Start and open **Completed domain audits**. Export JSON, CSV or Excel through Apify.
4. In **Reports and candidate snapshots**, open `REPORT` for unknowns and counts, or `CANDIDATE_SNAPSHOTS` for reusable snapshots. `CHECK-001`, etc. preserve individual evidence.

Public example domains demonstrate behavior; they are not customers. `example.com` intentionally publishes null MX, meaning it does not accept mail. That is a valid configuration for a non-mail domain, not automatically a problem.

### Compare with a baseline

After reviewing a successful run, copy its **CANDIDATE\_SNAPSHOTS JSON object** into `previousSnapshots` for the next run. Supply only domains included in that run's input. Keep the same DKIM selector set.

| Comparison | Meaning |
| --- | --- |
| `first_run` | No baseline supplied for this domain. |
| `unchanged` | Normalized records and alias chains match the baseline. |
| `changed` | Added or removed values are listed in `changes`. |
| `not_comparable` | The selector context changed; no change verdict is issued. |
| `unknown` | A required DNS lookup failed or was inconclusive; no change verdict is issued. |

You can keep a fixed approved baseline in an Apify Task and schedule repeated comparisons. For rolling comparisons, your workflow must explicitly fetch and pass reviewed snapshots into the next run. **There is no hidden persistent monitor, automatic baseline acceptance, email alert or Slack message.**

Candidate snapshots contain fresh completed observations plus unchanged copies of supplied baselines for failed or unattempted domains. `REPORT.retainedBaselineDomains` identifies these older copies. Do not treat them as fresh results. Review changes before replacing your approved baseline.

DNS record order and TTL countdowns do not create differences. Split quoted TXT chunks are joined without extra spaces. SPF whitespace is normalized but mechanism order is preserved. Well-formed DMARC/DKIM tag order is normalized; malformed or duplicate tags remain visible. Some cosmetic case changes can still be reported. CNAME changes are tracked separately from record values.

### Observations

- Missing direct SPF or DMARC records, multiple SPF/DMARC records, a pass-qualified SPF `all`, and DMARC tag issues.
- Direct DMARC `p` summary, `t=y` testing notice and legacy `pct` notice. RFC 9989 (May 2026) removed `pct`; the Actor does not calculate an enforcement percentage. Omitted `p` is summarized as `none` only at the tag level, with an explicit note.
- Null MX, no explicit MX, and null MX mixed with other MX records.
- Known DKIM selector presence, multiple TXT records or an empty/revoked `p` value. Public-key cryptography and actual message signatures are not verified.

These are review observations, not a pass/fail security certification. `p=none` may be an intentional monitoring stage. Missing direct DMARC can coexist with an inherited policy. Missing MX does not rule out SMTP A/AAAA fallback.

### Pricing

**USD 0.002 per completed domain audit — $2 per 1,000**, including platform usage and selected DKIM queries. A complete audit with missing records still counts as completed. `unknown` audits are stored in REPORT and do not produce charged dataset items. No startup fee, paid model API or proxy is required.

Examples: 10 completed domains cost $0.02; 50 cost $0.10. Set maximum run cost to at least $0.002. Once the number of completed results reaches the budget, remaining domains are unattempted. Each new run is a new audit and may charge again; unchanged results are still audits and are billed.

Evidence is saved before each billed row. Dataset writes are never retried after an uncertain response. Runs with existing dataset rows refuse resurrection to prevent duplicate charges. If interrupted, inspect CHECK records and dataset; final REPORT and candidate snapshots may not exist yet. Owner tests are not paying-customer invoice verification.

### Scope, DNS privacy and limits

- Uses Google's public DNS-over-HTTPS JSON API. Domain names and selector queries are sent to Google; EDNS client subnet is set to `0.0.0.0/0`. No email list, SMTP session, private resolver or domain account is used. Only check public domains you are authorized to process.
- A single recursive resolver is one observation point. Caches and DNS propagation can differ elsewhere; this does not compare authoritative nameservers or prove global consistency.
- DNS SERVFAIL, refused/truncated answers, malformed data and HTTP failures stay unknown. HTTP 200 alone is not treated as DNS success. DNSSEC validation is not disabled; the resolver's AD flag is recorded, not independently verified.
- Checks only `_dmarc` at the exact supplied name. It does **not** perform organizational-domain discovery, RFC 9989 tree walks, inherited-policy resolution or DMARC alignment tests.
- SPF includes, redirects, macros and their recursive DNS lookup costs are **not** evaluated. No sender IP is tested. An observed SPF record is not proof of valid SPF or delivery.
- DKIM selectors are not discoverable from these checks. A visible public key is not proof that a sender uses it correctly.
- No A/AAAA fallback, blocklists, reputation, inbox placement, DMARC aggregate report parsing, TLS or message-content testing.
- 64 KiB decoded DNS response limit, 12-second request deadline, one bounded retry for selected transient HTTP/network failures. At least 250 ms between sequential query starts; Retry-After over ten seconds stops that lookup. Provider limits can still apply.
- Input limit 1 MiB. Each domain uses three DNS queries plus one per supplied selector. CNAME chains are limited to eight links. No external production dependencies.

### Documentation and support

Primary references: [Google DNS JSON API](https://developers.google.com/speed/public-dns/docs/doh/json), [SPF RFC 7208](https://www.rfc-editor.org/rfc/rfc7208.html), [DMARC RFC 9989](https://www.rfc-editor.org/rfc/rfc9989.html), [DKIM RFC 6376](https://www.rfc-editor.org/rfc/rfc6376.html) and [Null MX RFC 7505](https://www.rfc-editor.org/rfc/rfc7505.html). This tool is independent and not endorsed by these organizations.

Open an issue with the error code and a public or fictional reproduction. Do not post secrets, private DNS zones, personal email lists or private customer baselines.

# Actor input Schema

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

1–50 bare public domains, e.g. company.com. No email addresses, URLs or private DNS names. Repeated domains are checked once.

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

Up to five selectors such as google or selector1. The same set is checked on each domain. Leave empty when unknown: DKIM will be not checked, never assumed missing. Do not include .\_domainkey or the domain.

## `previousSnapshots` (type: `object`):

Paste the reviewed CANDIDATE\_SNAPSHOTS object from a prior run, keyed by domain. Leave {} for a first snapshot. Keep the same selector set for a comparable result. No automatic cross-run state is loaded.

## Actor input object example

```json
{
  "domains": [
    "google.com",
    "example.com",
    "apify.com"
  ],
  "dkimSelectors": [],
  "previousSnapshots": {}
}
```

# Actor output Schema

## `audits` (type: `string`):

No description

## `evidence` (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": [
        "google.com",
        "example.com",
        "apify.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("iwins/email-dns-change-audit").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",
        "example.com",
        "apify.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("iwins/email-dns-change-audit").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": [
    "google.com",
    "example.com",
    "apify.com"
  ]
}' |
apify call iwins/email-dns-change-audit --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,iwins/email-dns-change-audit"
        }
    }
}
```

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/5NFq3bz0DDkLhMc2l/builds/WD6BJ3kRwEYLeTCx1/openapi.json
