# Email DNS & DMARC Change Monitor (`ironstark_daysky/email-dns-drift-monitor`) Actor

Monitor SPF, DMARC, MX, and DKIM records, detect configuration changes between runs, and flag email security regressions.

- **URL**: https://apify.com/ironstark\_daysky/email-dns-drift-monitor.md
- **Developed by:** [Caio Fábio M Silva](https://apify.com/ironstark_daysky) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.50 / 1,000 domain checks

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?

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

## Email DNS & DMARC Change Monitor

Monitor **SPF, DMARC, MX, and DKIM** records and detect configuration **changes between runs**.

### What does Email DNS & DMARC Change Monitor do?

Most DNS tools tell you what your email authentication looks like **right now**. This Actor tells you
what **changed** — and whether the change made things better or worse.

Give it a list of domains. It queries their public DNS records (MX, SPF, DMARC, and any DKIM
selectors you name), normalizes the answers, stores them as a **persistent baseline**, and on every
subsequent run compares the live records against that baseline. Every difference comes out as a
typed, classified change:

| Transition | Change type | Direction |
|---|---|---|
| DMARC `p=reject` → `p=quarantine` | `DMARC_POLICY_WEAKENED` | Degraded |
| DMARC `p=quarantine` → `p=none` | `DMARC_POLICY_WEAKENED` | Degraded |
| DMARC `p=none` → `p=reject` | `DMARC_POLICY_STRENGTHENED` | Improved |
| SPF record disappears | `SPF_REMOVED` | Degraded |
| SPF `-all` → `~all` | `SPF_ALL_WEAKENED` | Degraded |
| MX moves from Google Workspace to Microsoft 365 | `MX_CHANGED` | Neutral |
| A configured DKIM selector stops resolving | `DKIM_REMOVED` | Degraded |

Classification is **fully deterministic** — a fixed set of rules over normalized records. No language
model decides whether your domain got safer.

Because it runs on Apify, you can put it on a **schedule**, call it from the **API**, and pipe its
output into your own alerting.

### Why use it?

- **Catch silent regressions.** A contractor relaxes `p=reject` to `p=none` to unblock a campaign,
  and nobody tells security. You find out on the next run, not after a phishing incident.
- **Watch your vendors and acquisitions.** Track domains you do not control but depend on.
- **Detect email provider migrations.** An MX change is often the first public signal of a migration.
- **Notice DKIM key rotation and revocation** on the selectors you actually publish.
- **Feed a compliance trail.** Every run is a timestamped, structured record of your email DNS posture.
- **No agents, no credentials.** It reads only public DNS. Nothing is installed anywhere.

### Features

- MX, SPF, DMARC and explicit-selector DKIM checks
- Persistent baseline across runs, isolated per monitor and per domain
- Deterministic change classification with `INFO`/`LOW`/`MEDIUM`/`HIGH` severity
- A safe `UNKNOWN` state that never masquerades as "record removed"
- Best-effort email provider identification (Google Workspace, Microsoft 365, Proton, Zoho, Mimecast,
  Proofpoint, Fastmail and more) with no external API calls
- Bounded concurrency, short retries and per-run DNS health statistics
- Accepts bare domains, URLs and email addresses; handles IDN and deduplicates automatically

### Scan mode

`mode: "scan"` is a stateless, one-off check. It resolves everything, reports the current
configuration, and **never reads or writes a baseline**. Use it to explore a list of domains, or to
audit something once.

### Monitor mode

`mode: "monitor"` requires a `monitorId` and is where drift detection happens:

1. **First run for a domain** — establishes the baseline. No changes are reported, because there is
   nothing to compare against. `baselineCreated` is `true`.
2. **Every later run** — loads the baseline, resolves the live records, emits the differences, then
   rolls the baseline forward.

Each `monitorId` is a completely separate namespace. `production` and `staging` can watch the same
domain and never see each other's history.

### How to use Email DNS & DMARC Change Monitor

1. Open the Actor and put your domains in the **Domains** field.
2. Add any **DKIM selectors** you publish (see the DKIM limitation below — they cannot be discovered).
3. Set **Mode** to `monitor` and pick a **Monitor ID**, e.g. `production`.
4. Click **Start**. The first run establishes the baseline.
5. Go to the **Schedules** tab and schedule the same configuration daily (or hourly).
6. From then on, every run reports what changed. Filter the dataset on `changed = true`.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `domains` | array | — | **Required.** Up to 100 domains per run. Accepts `example.com`, `https://example.com/x` or `user@example.com`. |
| `mode` | string | `scan` | `scan` or `monitor`. |
| `monitorId` | string | — | Required for `monitor`. Letters, digits, `-` and `_`, up to 64 chars. |
| `dkimSelectors` | array | `[]` | Selectors probed on every domain. |
| `dkimSelectorsByDomain` | object | `{}` | Extra selectors per domain, e.g. `{"example.com": ["selector1"]}`. |
| `resolverTimeoutSeconds` | integer | `5` | Per-query timeout, 1–30. |
| `maxConcurrency` | integer | `10` | Domains resolved in parallel, 1–50. |
| `baselineStoreName` | string | `email-dns-baselines` | Named key-value store holding baselines. |

```json
{
  "domains": ["example.com", "example.org"],
  "mode": "monitor",
  "monitorId": "production",
  "dkimSelectors": ["google", "selector1"],
  "dkimSelectorsByDomain": { "example.com": ["k1"] },
  "maxConcurrency": 10
}
```

### Output

One dataset item per domain. You can download the dataset in various formats such as JSON, HTML,
CSV, or Excel.

```json
{
  "domain": "example.com",
  "checkedAt": "2026-09-07T14:26:06Z",
  "mode": "monitor",
  "monitorId": "production",
  "changed": true,
  "severity": "HIGH",
  "changes": [
    {
      "component": "DMARC",
      "type": "DMARC_POLICY_WEAKENED",
      "severity": "HIGH",
      "direction": "SECURITY_DEGRADED",
      "previous": "reject",
      "current": "none"
    }
  ],
  "mxStatus": "PRESENT",
  "mxProvider": "Google Workspace",
  "spfStatus": "PRESENT",
  "spfAll": "-all",
  "dmarcStatus": "PRESENT",
  "dmarcPolicy": "none",
  "dmarcPct": 100,
  "dkimSelectorsChecked": 1,
  "dkimPresentCount": 1,
  "unknownComponents": 0
}
```

Each item also carries the full `currentStatus` and `previousStatus` structures, plus `mx`, `spf`,
`dmarc` and `dkim` sub-objects for API consumers.

A run-level `SUMMARY` record is written to the default key-value store with `domainsChecked`,
`mxPresent`, `spfPresent`, `dmarcPresent`, `dkimSelectorsChecked`, `changesDetected`,
`securityImproved`, `securityDegraded`, `unknownComponents`, `executionTimeSeconds`, DNS health
statistics and billing counters.

#### Data table

| Field | Description |
|---|---|
| `domain` | Normalized domain (lowercase, punycode). |
| `checkedAt` | UTC ISO 8601 observation time. |
| `changed` | Whether any change was detected against the baseline. |
| `severity` | Worst severity among this domain's changes. |
| `changes[]` | Typed changes with severity, direction and before/after values. |
| `mxStatus` / `mxProvider` | MX presence and best-effort provider label. |
| `spfStatus` / `spfAll` | SPF presence and its `all` qualifier. |
| `dmarcStatus` / `dmarcPolicy` / `dmarcPct` | DMARC presence, policy and sampling rate. |
| `dkimSelectorsChecked` / `dkimPresentCount` | DKIM selectors probed and found. |
| `baselineUpdated` | Whether this run wrote a new baseline. Unchanged domains skip the write. |
| `unknownComponents` | Components that could not be resolved this run. |
| `nxdomain` | The domain itself does not exist in DNS. |

### Change types

**MX** — `MX_ADDED`, `MX_REMOVED`, `MX_CHANGED`
**SPF** — `SPF_ADDED`, `SPF_REMOVED`, `SPF_CHANGED`, `SPF_ALL_WEAKENED`, `SPF_ALL_STRENGTHENED`,
`SPF_INCLUDES_CHANGED`, `SPF_REDIRECT_CHANGED`
**DMARC** — `DMARC_ADDED`, `DMARC_REMOVED`, `DMARC_CHANGED`, `DMARC_POLICY_WEAKENED`,
`DMARC_POLICY_STRENGTHENED`, `DMARC_SUBDOMAIN_POLICY_WEAKENED`, `DMARC_SUBDOMAIN_POLICY_STRENGTHENED`,
`DMARC_PCT_DECREASED`, `DMARC_PCT_INCREASED`, `DMARC_ADKIM_CHANGED`, `DMARC_ASPF_CHANGED`
**DKIM** — `DKIM_ADDED`, `DKIM_REMOVED`, `DKIM_CHANGED`

Enforcement strength is ordered `none < quarantine < reject` for DMARC and
`+all < ?all < ~all < -all` for SPF. Anything moving down that order is `SECURITY_DEGRADED`; anything
moving up is `SECURITY_IMPROVED`. Changes with no defensible direction (a new MX host, a rotated DKIM
key, a reshuffled SPF include list) are `NEUTRAL`.

### Severity

| Severity | Meaning | Examples |
|---|---|---|
| `HIGH` | Enforcement lost, or mail delivery broken | `SPF_REMOVED`, `DMARC_REMOVED`, DMARC dropped to `none`, SPF `+all`, `MX_REMOVED` |
| `MEDIUM` | Enforcement meaningfully relaxed, or infrastructure moved | `reject` → `quarantine`, `-all` → `~all`, `MX_CHANGED`, `DKIM_REMOVED` |
| `LOW` | Real but low-impact change | Includes edited, DKIM key rotated, alignment flags changed |
| `INFO` | Nothing changed, or a record was added | `SPF_ADDED`, `DMARC_ADDED` |
| `UNKNOWN` | Something could not be resolved this run | Timeout, SERVFAIL |

`CRITICAL` is deliberately unused. Losing DMARC enforcement is serious, but this Actor sees public
DNS only, and severity should not overstate what that evidence supports.

### Scheduling

Create a schedule from the Actor's **Schedules** tab with `mode: "monitor"` and a stable `monitorId`.
A daily run is enough for most portfolios; hourly is reasonable for a small, high-value set. The
baseline lives in a named key-value store, so it persists across schedules, rebuilds and versions.

### API

```bash
curl -X POST "https://api.apify.com/v2/acts/<ACTOR_ID>/runs?token=<YOUR_TOKEN>" \
  -H 'Content-Type: application/json' \
  -d '{
    "domains": ["example.com"],
    "mode": "monitor",
    "monitorId": "production"
  }'
```

Fetch results from `GET /v2/datasets/<datasetId>/items` and the run summary from
`GET /v2/key-value-stores/<storeId>/records/SUMMARY`.

### Examples

**One-off audit of a domain portfolio**

```json
{ "domains": ["example.com", "example.org", "example.net"], "mode": "scan" }
```

**Daily production monitoring with DKIM**

```json
{
  "domains": ["example.com"],
  "mode": "monitor",
  "monitorId": "production",
  "dkimSelectors": ["google", "selector1", "selector2"]
}
```

**Separate namespaces for separate concerns**

Run the same domain under `monitorId: "production"` and `monitorId: "vendor-watch"`; the two
baselines never interact.

### Accuracy and limitations

#### DNS behavior

DNS is a caching, eventually-consistent system. A record you just published may not be visible to
this Actor's resolver until the previous TTL expires, and different resolvers can disagree for a
while. A change reported minutes after an edit is expected; treat single observations near a change
window with care.

#### UNKNOWN does not mean absent

This is the most important rule in the product. A timeout, a SERVFAIL, or any resolver error yields
`UNKNOWN` — never `MISSING`. `UNKNOWN` components are **excluded from comparison** and **do not
overwrite** the stored baseline, so a bad network moment cannot manufacture a fake `SPF_REMOVED` on
this run or a fake `SPF_ADDED` on the next. `NXDOMAIN` and `NOANSWER`, which are authoritative
answers, do map to `MISSING`.

#### DKIM selector limitations

**DKIM selectors cannot be discovered from DNS.** There is no record that enumerates them; a selector
is only findable if you already know its name or read it from a signed message header. This Actor
therefore checks **only the selectors you provide** and makes no attempt to guess. A selector you did
not list is simply not checked — it is never reported as missing. Common selectors worth listing:
`google` (Google Workspace), `selector1`/`selector2` (Microsoft 365), `k1` (Mailchimp/Mandrill),
`s1`/`s2` (SendGrid, various), `mail`, `default`, `dkim`.

#### Other limits

- SPF parsing is a normalizer, not a full RFC 7208 evaluator. It does not resolve `include:` chains,
  count DNS lookups, or evaluate a specific sending IP.
- Provider identification is a local hostname heuristic. `UNKNOWN` is a normal, correct answer for
  self-hosted or unusual setups.
- Only the apex domain is checked, not subdomains.
- Only public DNS is read. There is no SMTP connection, no message sending and no inbox testing.

### Troubleshooting

**"No changes are reported on my first monitor run."** That is correct. The first run establishes the
baseline; `baselineCreated` is `true`. Changes start on the second run.

**"A domain I expected is missing from the output."** It was rejected during validation. Check
`rejectedDomains` in the `SUMMARY` record — IP addresses, single-label names and malformed entries
are skipped by design.

**"Everything comes back UNKNOWN."** Check `SUMMARY.dns.unknownRate` and `errorsByKind`. A high
timeout count usually means `resolverTimeoutSeconds` is too low or `maxConcurrency` is too high for
the resolver in use. Try `resolverTimeoutSeconds: 10` and `maxConcurrency: 5`.

**"My DKIM selector shows MISSING but I know it exists."** Confirm the exact selector name and that
`<selector>._domainkey.<domain>` resolves. Selector names are case-insensitive here but must
otherwise match exactly.

**"The baseline's `updatedAt` is older than my last run."** That is by design. A monitor run only
rewrites a baseline when the configuration actually changed, so `updatedAt` tells you when the
configuration last moved. When the Actor last *looked* is `checkedAt` on every dataset item.

**"I want to reset a baseline."** Use a new `monitorId`, or delete that monitor's records from the
`email-dns-baselines` key-value store.

### Privacy

This Actor reads **public DNS records only**. It does not collect credentials, mailbox contents,
messages, contact lists or login data. It does not send email and does not test SMTP relays.

DMARC `rua`/`ruf` reporting destinations are reduced to **counts** rather than stored as addresses,
and DKIM public keys are stored as a short **fingerprint** rather than the key material. No data
leaves the Apify platform: there are no third-party API calls and no proxy is used.

### Pricing concept

The intended economic unit is one **domain-check**: a domain is billed once per run, regardless of
how many DNS queries it took (MX + SPF + DMARC + every DKIM selector).

A domain is billed **only after its result has been stored in the dataset**, never before. Domains
rejected during input validation are never billed, a domain is never billed twice in one run, and a
domain whose result could not be stored is not billed at all. If a run reaches its charge limit, the
Actor stops processing further domains rather than doing work it cannot deliver.

Instrumentation for this is already in the code and inert until a price is configured.

### Responsible use

Query only domains you own or have a legitimate interest in monitoring. This Actor reads public
records at a modest rate and issues no authenticated requests, but a large domain list on a tight
schedule is still traffic pointed at someone else's nameservers. Keep `maxConcurrency` sensible and
your schedule proportionate to how fast the data actually changes — for most portfolios, daily is
plenty.

Findings describe **public DNS configuration**, not the security of an organization. Report them in
those terms.

# Actor input Schema

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

Domains to check. Accepts bare domains (example.com), URLs (https://example.com/path) or email addresses (user@example.com) - the hostname is extracted. Maximum 100 domains per run.

## `mode` (type: `string`):

scan = one-off check, never touches the stored baseline. monitor = compare against the stored baseline for this monitor ID, report drift, then update the baseline.

## `monitorId` (type: `string`):

Required in monitor mode. Identifies an isolated baseline namespace so several monitors can coexist in one account. Allowed characters: letters, digits, dash and underscore (1-64 chars).

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

DKIM selectors to probe on every domain, e.g. 'google', 'selector1', 'k1'. DKIM selectors are NOT enumerable via DNS - only the selectors you list here are checked.

## `dkimSelectorsByDomain` (type: `object`):

Extra DKIM selectors for specific domains, e.g. {"example.com": \["selector1", "selector2"]}. Merged with the global list.

## `resolverTimeoutSeconds` (type: `integer`):

Per-query DNS timeout. Lower values finish faster but raise the UNKNOWN rate on slow authoritative servers.

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

How many domains are resolved in parallel. Keep it moderate to avoid hammering resolvers.

## `baselineStoreName` (type: `string`):

Named key-value store used to persist baselines between runs. Change it only if you want fully separate storage.

## Actor input object example

```json
{
  "domains": [
    "apify.com",
    "github.com"
  ],
  "mode": "scan",
  "monitorId": "my-portfolio",
  "dkimSelectors": [],
  "dkimSelectorsByDomain": {},
  "resolverTimeoutSeconds": 5,
  "maxConcurrency": 10,
  "baselineStoreName": "email-dns-baselines"
}
```

# Actor output Schema

## `results` (type: `string`):

No description

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

// Run the Actor and wait for it to finish
const run = await client.actor("ironstark_daysky/email-dns-drift-monitor").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": [
        "apify.com",
        "github.com",
    ] }

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

```

## MCP server setup

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

```

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/hfU0gymjifhu4lcUl/builds/aRu5bFkmHIXt6caQB/openapi.json
