Email Security Check: SPF, DKIM, DMARC, MX
Pricing
from $2.50 / 1,000 domain auditeds
Email Security Check: SPF, DKIM, DMARC, MX
Audit SPF (with its DNS lookup count), DMARC, DKIM key sizes, MTA-STS, TLS-RPT, BIMI and MX for a list of domains. Get a grade from A to F, issues with exact fixes, and a Google/Yahoo bulk sender check. Optional monitoring with change alerts.
Pricing
from $2.50 / 1,000 domain auditeds
Rating
0.0
(0)
Developer
Plainsource
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
a day ago
Last modified
Categories
Share
What this Actor does
Email Security Audit checks the email authentication records of a list of domains and tells you, in plain language, what is wrong and the exact DNS change that fixes it. It is a bulk DMARC checker, SPF checker and DKIM lookup in one run, with one row per domain.
For each domain you get:
- A grade from A to F and a score out of 100.
- Issues with a severity and a fix line, for example
Add a TXT record at _dmarc.example.com: "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com". - A Google and Yahoo sender requirements check: which of the February 2024 rules for all senders and for bulk senders the domain's DNS passes.
- The details behind the grade: the full SPF include tree, DMARC tags, DKIM keys with their size, MX records and mail provider, MTA-STS policy, TLS-RPT, BIMI and DNSSEC.
Typical uses:
- Check your own domains before a cold email or newsletter campaign
- Audit every client domain at once if you run an agency or an MSP
- Find SPF records that break the 10-lookup limit after a new tool was added
- Re-run on a schedule in monitoring mode and get a Slack or Discord message when a record changes
What it checks
SPF
- Parses the record and every
include:andredirect=below it. - Counts DNS lookups (
include,a,mx,ptr,exists,redirect) across the whole tree against the RFC 7208 limit of 10, and counts void lookups (names that return nothing; the limit is 2). - Flags
+all,?alland a missingall, duplicate SPF records, the deprecatedptrmechanism, includes that have no SPF record, loops,mxterms with more than 10 hosts, syntax errors (including malformedip4:/ip6:ranges) and typos such asinclude=that receivers silently ignore. - Returns the include tree and a flattened list of every IPv4 and IPv6 range the record authorizes (
aandmxterms are resolved).
DMARC
- Policy (
p), subdomain policy (sp),pct,ruaandrufreport addresses, alignment modes (adkim,aspf). - Falls back to the organizational domain for subdomains, as receivers do.
- Flags a missing record,
p=none,pctunder 100,sp=none, missingrua, duplicate records, syntax errors, and report addresses on another domain that has not published the authorization record receivers require (RFC 7489, section 7.1). - In monitoring mode, flags
p=nonethat has stayed in place for 30 days or more.
DKIM
- Looks up 52 common selectors (google, selector1, selector2, k1, k2, s1, s2, default, mandrill, zoho, protonmail and others) plus up to 20 of your own.
- Reads each key's type (RSA or Ed25519) and size. Keys under 1024 bits are a high-severity issue; 1024-bit keys are a low-severity note recommending 2048.
- Also flags revoked keys, test-mode keys (
t=y), SHA-1-only keys, keys that cannot be decoded, and wildcard records that answer for every selector.
MTA-STS, TLS-RPT, BIMI and MX
- MTA-STS: the
_mta-stsTXT record, then the policy file athttps://mta-sts.<domain>/.well-known/mta-sts.txt. The policy is validated (version, mode, max_age, mx lines, text/plain type) and every MX host is matched against itsmx:patterns. Redirects are refused, as RFC 8461 requires of senders. - TLS-RPT: the
_smtp._tlsrecord and its report destinations. - BIMI: the
default._bimirecord, the logo URL, and whether a certificate URL (a=, a VMC or CMC) is present. The certificate itself is not downloaded. - MX: the records, whether each host resolves, null MX (RFC 7505), and the mail provider (Google Workspace, Microsoft 365, Proofpoint, Mimecast and about 40 others).
- DNSSEC (optional, on by default): whether answers for the domain are DNSSEC-validated, from Google Public DNS. Broken signatures (validating resolvers return SERVFAIL) are a high-severity issue. DNSSEC does not change the grade.
Google and Yahoo sender requirements
Since February 2024, Gmail and Yahoo Mail require every sender to set up SPF or DKIM, and bulk senders (for Google, more than 5,000 messages a day to Gmail accounts) to set up both SPF and DKIM and publish a DMARC record (p=none is enough). Google also requires DKIM keys of at least 1024 bits. Sources: Google's email sender guidelines and Yahoo's sender best practices, both checked on 2026-10-26.
The bulkSenderRequirements field lists each rule with pass, fail or unverified:
| Rule | Applies to | How it is checked |
|---|---|---|
| SPF or DKIM set up | All senders | Valid SPF record, or a DKIM key at a checked selector |
| DKIM key of 1024 bits or more | All senders (Google) | Size of every key found |
| Both SPF and DKIM set up | Bulk senders | Both of the above (unverified when SPF is valid and no DKIM key was found) |
DMARC with p=none or stronger | Bulk senders | Valid DMARC record on the domain or its organizational domain |
| From: domain aligned with SPF or DKIM | Bulk senders | unverified: needs a real message |
| One-click unsubscribe | Bulk senders | unverified: a property of each message |
| Forward and reverse DNS of sending IPs | All senders | unverified: needs the sending IP |
| Spam rate below 0.3% | All senders | unverified: only visible in Postmaster Tools |
allSendersDnsChecks and bulkSendersDnsChecks are pass when every rule that DNS can answer passes and fail when one fails. bulkSendersDnsChecks is unverified when SPF is valid but no DKIM key was found at the checked selectors: the domain may sign with a selector this Actor cannot guess, so it is not called a failure. Both are not_applicable for a domain that neither sends nor receives mail. A pass here does not mean the domain meets every rule; the unverified ones still need checking.
How the grade works
Domains that send or receive mail are scored out of 100:
| Part | Points | Full points for |
|---|---|---|
| SPF | 25 | A valid record ending in -all (~all gets 23) |
| DMARC | 35 | p=reject with a rua address (quarantine 29, none 13) |
| DKIM | 15 | A key of 2048 bits or Ed25519 (1024 bits gets 12) |
| MTA-STS | 10 | mode: enforce with every MX listed (only for domains with MX records) |
| TLS-RPT | 5 | A record (only for domains with MX records) |
| MX | 5 | Every MX host resolves (only for domains with MX records) |
| BIMI | 5 | A record, with DMARC at quarantine or reject |
Caps: no valid DMARC record caps the score at 54 (grade D), a missing or broken SPF record at 69 (C), +all at 39 (F). Grades: A 85 and up, B 70 to 84, C 55 to 69, D 40 to 54, F below 40.
A domain with no MX records and no senders in SPF or DKIM is scored as a parked domain: v=spf1 -all (40), DMARC p=reject (50) and a null MX (10). scoreBreakdown in each row shows the points.
Input
The only required field is Domains.
| Field | What it does |
|---|---|
Domains (domains) | Domains to audit. URLs and email addresses are reduced to the domain, a leading www. is dropped, and subdomains are kept (a sending subdomain has its own SPF and DKIM). Up to 5,000 per run. |
Extra DKIM selectors (dkimSelectors) | Your own selector names, checked as well as the 52 common ones. Up to 20. |
Check DNSSEC (checkDnssec) | Ask Google Public DNS whether the domain is DNSSEC-validated. On by default. |
Monitoring list name (stateKey) | Turns on monitoring mode (see below). Empty by default. |
Webhook URL (webhookUrl) | Monitoring mode only. An https:// URL that gets a POST when a domain changed. |
Webhook format (webhookFormat) | json (default), slack or discord. |
Respect robots.txt (respectRobotsTxt) | Applies to the MTA-STS policy fetch, the only request sent to the domain's own servers. On by default. |
Domains checked at once (maxConcurrency) | 1 to 20, default 5. |
Request timeout (timeoutSecs) | 5 to 60 seconds, default 15, for the MTA-STS policy fetch. |
Example input:
{"domains": ["yourcompany.com", "mail.yourcompany.com", "client-one.com"],"dkimSelectors": ["s2024"],"stateKey": "agency-clients","webhookUrl": "https://hooks.slack.com/services/...","webhookFormat": "slack"}
Output
One row per domain. This is a real row from a run on Apify (run iAW7c0hL14NgywOyg). To keep it short, the issue list, the include tree, the flattened IP list, the DKIM keys and the sender checks are cut to their first few entries; the run returned 4 issues (plus 1 informational), 8 include-tree entries, 55 IPv4 and 7 IPv6 entries, 5 DKIM keys and 8 sender checks.
{"input": "python.org","domain": "python.org","status": "ok","grade": "C","score": 56,"summary": "Mail domain, grade C. No critical or high issues.","mailProvider": "Self-hosted or other","receivesMail": true,"sendsMail": true,"dmarcPolicy": "none","spfResult": "valid","spfLookupCount": 8,"spfAll": "~all","dkimSelectorsFound": ["google", "k2", "k3", "mailjet", "mx"],"mtaStsMode": null,"hasTlsRpt": false,"hasBimi": false,"dnssecValidated": false,"allSendersDnsChecks": "pass","bulkSendersDnsChecks": "pass","criticalIssues": 0,"highIssues": 0,"issueCount": 4,"issues": [{"severity": "medium","area": "DMARC","code": "dmarc-p-none","message": "The DMARC policy is \"p=none\" (monitoring only). Mail that fails SPF and DKIM is still delivered, so spoofed mail is not stopped.","fix": "Once the reports show all your real senders passing, change the record at _dmarc.python.org to \"p=quarantine\", then \"p=reject\"."},{"severity": "low","area": "DKIM","code": "dkim-1024","message": "The DKIM keys at \"google\", \"mailjet\", \"mx\" are 1024 bits. They still work, but 2048 bits is the current recommendation (RFC 8301, Google's sender guidelines).","fix": "In the service that signs with each selector, generate a 2048-bit key (usually under a new selector), switch signing to it, then remove the old record."},{"severity": "low","area": "MTA-STS","code": "mtasts-missing","message": "No MTA-STS policy, so an attacker on the network path can make senders deliver mail to this domain without encryption.","fix": "Publish a policy file at https://mta-sts.python.org/.well-known/mta-sts.txt (\"version: STSv1\", \"mode: testing\", one \"mx:\" line per mail server, \"max_age: 604800\") and a TXT record at _mta-sts.python.org: \"v=STSv1; id=2026102601\"."}],"bulkSenderRequirements": {"allSendersDnsChecks": "pass","bulkSendersDnsChecks": "pass","checks": [{"id": "spf-or-dkim","appliesTo": "all senders","requirement": "SPF or DKIM is set up for the sending domain","status": "pass","detail": "SPF is valid; DKIM key at google, k2, k3, mailjet, mx","sources": ["https://support.google.com/a/answer/81126", "https://senders.yahooinc.com/best-practices/"]},{"id": "dkim-key-1024","appliesTo": "all senders","requirement": "DKIM keys are at least 1024 bits (Google)","status": "pass","detail": "Smallest key: 1024 bits.","sources": ["https://support.google.com/a/answer/81126"]},{"id": "spf-and-dkim","appliesTo": "bulk senders","requirement": "Both SPF and DKIM are set up","status": "pass","detail": "SPF valid; DKIM key found","sources": ["https://support.google.com/a/answer/81126", "https://senders.yahooinc.com/best-practices/"]},{"id": "dmarc-policy","appliesTo": "bulk senders","requirement": "A valid DMARC record with p=none or stronger","status": "pass","detail": "p=none","sources": ["https://support.google.com/a/answer/81126", "https://senders.yahooinc.com/best-practices/"]},{"id": "from-alignment","appliesTo": "bulk senders","requirement": "The From: domain aligns with the SPF or DKIM domain","status": "unverified","detail": "Needs the headers of a real message. DNS can show only that the records exist.","sources": ["https://support.google.com/a/answer/81126", "https://senders.yahooinc.com/best-practices/"]}],"note": "Checked from DNS only. Requirements marked \"unverified\" need a real message, the sending IP or the provider dashboards."},"spf": {"record": "v=spf1 mx ip4:188.166.95.178/32 ip6:2a03:b0c0:2:d0::71:1 include:stspg-customer.com include:_spf.google.com include:mailgun.org ~all","recordCount": 1,"allQualifier": "~all","lookupCount": 8,"lookupLimit": 10,"lookupLimitExceeded": false,"voidLookups": 0,"usesPtr": false,"includes": ["stspg-customer.com", "_spf.google.com", "mailgun.org"],"redirect": null,"includeTree": [{"depth": 0,"domain": "python.org","via": null,"record": "v=spf1 mx ip4:188.166.95.178/32 ip6:2a03:b0c0:2:d0::71:1 include:stspg-customer.com include:_spf.google.com include:mailgun.org ~all","lookups": 4},{"depth": 1,"domain": "stspg-customer.com","via": "include","record": "v=spf1 ip4:23.253.182.103 ip4:23.253.183.145 ip4:23.253.183. ... ~all","lookups": 0},{"depth": 1,"domain": "_spf.google.com","via": "include","record": "v=spf1 ip4:74.125.0.0/16 ip4:209.85.128.0/17 ip6:2001:4860:4864::/56 ip6:2404:6800:4864::/56 ip6:2607:f8b0:4864::/56 ip6:2800:3f0:4864::/56 ip6:2a00:1450:4864::/56 ip6:2c0f:fb50:4864::/56 ~all","lookups": 0}],"flattened": {"ip4": ["188.166.95.178", "23.253.182.103", "23.253.183.145", "23.253.183.146"],"ip6": ["2a03:b0c0:2:d0::71:1", "2001:4860:4864::/56"],"complete": true,"note": null},"result": "valid","authorizesSenders": true},"dmarc": {"record": "v=DMARC1; p=none; pct=100; rua=mailto:***@dmarc.postmarkapp.com; sp=none; aspf=r;","recordLocation": "_dmarc.python.org","source": "domain","policy": "none","effectivePolicy": "none","subdomainPolicy": "none","pct": 100,"rua": ["mailto:***@dmarc.postmarkapp.com"],"ruf": [],"reportDestinations": [{"domain": "dmarc.postmarkapp.com", "external": true, "authorized": true}],"adkim": "r","aspf": "r","valid": true},"dkim": {"selectorsChecked": 52,"selectorsFound": ["google", "k2", "k3", "mailjet", "mx"],"keys": [{"selector": "google","keyType": "rsa","keyBits": 1024,"revoked": false,"testing": false,"sha1Only": false,"parseError": false,"custom": false},{"selector": "k2","keyType": "rsa","keyBits": 2048,"revoked": false,"testing": false,"sha1Only": false,"parseError": false,"custom": false}],"strongestKeyBits": 2048,"wildcard": false,"dnsErrors": 0},"mx": {"records": [{"priority": 50, "exchange": "mail.python.org", "resolves": true}],"provider": "Self-hosted or other","nullMx": false},"mtaSts": {"record": null,"id": null,"policyUrl": null,"policyChecked": false,"policyFetched": false,"mode": null,"maxAge": null,"mx": [],"mxCovered": null,"policyError": null},"tlsRpt": {"record": null, "rua": []},"bimi": {"record": null, "logoUrl": null, "certificateUrl": null, "hasCertificate": false},"dnssec": {"validated": false, "dsRecord": false, "error": null},"scoreBreakdown": [{"check": "SPF", "points": 23, "max": 25},{"check": "DMARC", "points": 13, "max": 35},{"check": "DKIM", "points": 15, "max": 15},{"check": "MTA-STS", "points": 0, "max": 10},{"check": "TLS-RPT", "points": 0, "max": 5},{"check": "MX", "points": 5, "max": 5},{"check": "BIMI", "points": 0, "max": 5}],"monitoring": null,"checkedAt": "2026-10-26T20:02:53.128Z","error": null}
Field notes:
statusisok,invalid_input,not_found(the domain does not exist in DNS),not_allowed,failed(DNS did not answer) orskipped(your maximum cost was reached).issues[].severityiscritical,high,medium,loworinfo.issueCountleaves outinfo.spfResultisvalid,permerror(receivers fail SPF),temperror(a lookup timed out) ornone.dmarcPolicyis the policy that applies to the domain, including one inherited from its organizational domain.mtaStsModeisenforce,testing,none,policy unreadable,not checkedor null when there is no record.monitoringis null unless monitoring mode is on.
The run's key-value store also holds OUTPUT: counts per status and per grade, and the domains that changed.
Monitoring mode
Set Monitoring list name and schedule the Actor (for example weekly) in Apify. The first run saves each domain's result as a baseline (monitoring.status: "baseline"). Later runs with the same name compare against it and report:
- the grade, SPF record, SPF lookup count, SPF
allqualifier, DMARC record and policy, DKIM keys, MX records, MTA-STS mode and id, TLS-RPT and BIMI records and DNSSEC state, before and after - issues that are new or resolved
- how long DMARC has been at
p=none(monitoring.dmarcNoneSince); from 30 days on, thep=noneissue becomes high severity
If some DNS lookups fail during a run, that domain is not compared or saved (monitoring.status: "not_compared"), so a timeout is never reported as a removed record. Results are kept in your account in a key-value store named plainsource-email-security-audit-state. Use a different list name for each client or list.
With a webhook URL, one POST is sent per run when at least one domain changed. Slack and Discord get a short text message, and text from DNS records is escaped so a record cannot mention @everyone or @channel. json sends the list of changes.
Pricing
Pay per event:
| Event | Price | When |
|---|---|---|
| Actor start | $0.002 | Once per run |
| Domain audited | $0.0025 | Each domain with status ok |
| Change detected | $0.003 | Monitoring mode only: each domain whose records changed since the last run |
Invalid, not-found, refused and failed domains are free. Example: 200 domains cost $0.002 + 200 × $0.0025 = $0.502. A weekly monitoring run on 20 domains where 1 changed costs $0.002 + $0.05 + $0.003 = $0.055.
If you set a maximum cost for a run, domains past the limit are returned with status: "skipped" and are not charged. In monitoring mode, a change that could not be charged is reported again on the next run.
Limits
- Up to 5,000 domains per run. Two runs of 140 domains took 26 and 38 seconds on Apify with the default settings.
- DKIM keys can only be found by selector name. A domain that signs with a selector outside the 52 common ones gets "DKIM not found" until you add the selector under Extra DKIM selectors. You can read it from the
s=value in the DKIM-Signature header of a message the domain sent. - From:-domain alignment, one-click unsubscribe, PTR records of sending IPs and spam rates cannot be read from DNS. They are listed as
unverified. - STARTTLS on the MX hosts is not tested. Apify's network blocks outbound connections to port 25 (a test run against four mail servers, including Google's and Microsoft's, timed out on all four), so the Actor only reports what DNS and the MTA-STS policy say about TLS.
- The BIMI certificate (VMC or CMC) is not downloaded or validated; the Actor only reports whether its URL is present.
- DNS answers come from Cloudflare (1.1.1.1) and Google (8.8.8.8) public resolvers. A record changed in the last few minutes may still show the old value.
- Blocklist (RBL) checks are not included.
Privacy and data use
- The Actor reads public DNS records, one public file per domain (the MTA-STS policy) and, for DNSSEC, one answer from Google Public DNS. It never connects to a mail server, never sends mail and never tests mailboxes.
- Email addresses inside DMARC and TLS-RPT records are returned with the part before
@masked (mailto:***@example.com). If you enter an email address as input, the row shows it masked the same way. - Names that point to private or local IP addresses are refused, and the MTA-STS policy URL (with any redirect) and the webhook URL pass the same check.
FAQ
Is this an SPF checker that counts nested lookups?
Yes. Every include and redirect is followed and every lookup-causing term is counted, so spfLookupCount is the number receivers use. spf.includeTree shows where the lookups come from.
Why does a large company get "DKIM not found"? Many large senders use date-based or random selector names that no list can guess. If you know the selector, add it under Extra DKIM selectors.
Does a grade of A mean my mail will reach the inbox? No. The grade measures authentication and transport security records. Inbox placement also depends on your sending reputation, content and complaint rate.
What is the difference between this and a domain profile? This Actor does only the email audit, adds the sender requirements check, fix lines and monitoring, and costs less per domain. It does not look up registration (WHOIS/RDAP), hosting or TLS certificates of websites.
Can I check subdomains?
Yes. mail.example.com is audited as entered. If it has no DMARC record, the organizational domain's policy is used, as receivers do, and the row says so in dmarc.source.
Why was a domain refused?
not_allowed is returned for local or internal names, names that point to private IP addresses, and a few sites whose terms forbid automated access (LinkedIn, Facebook, Instagram, X/Twitter, TikTok).