| name | domain-health-check |
| description | Check a list of domains for email blacklist listings (Spamhaus DBL, URIBL, SURBL + IP-based ZEN/SpamCop/Barracuda) and missing email-auth DNS records (SPF, DKIM, DMARC). Use when the user pastes a list of domains — bare, one-per-line, comma-separated, or a CSV column — with no other instruction, or asks to check domains for blacklisting, blocklists, missing records, SPF/DKIM/DMARC, or email deliverability health. Pure public DNS, no API keys, no cost. |
Domain Health Check
Checks every domain against 3 domain-reputation blacklists and 3 IP-reputation blacklists, plus SPF, DKIM (12 common selectors), and DMARC — using only public DNS. Requires Node 18+ on PATH (if node -v fails, tell the user Node 18+ is required and stop).
Workflow
- Extract domains from whatever the user provided: bare list, comma/space separated, CSV column, URLs, or messy text. Take anything that looks like a domain. Don't ask for confirmation — just run. (The script itself also lowercases, strips
https:///www./paths, dedupes, and skips invalid tokens.)
- Get the script: if a
check.mjs file exists in the same directory as this SKILL.md, use it directly. Otherwise write the script below verbatim to a temp/scratchpad file named check.mjs. Do not modify it.
- Run it once for the whole batch:
node check.mjs domain1.com domain2.com ... — it prints JSON. For very large lists (300+), split into runs of ~250 and merge results. Expect roughly 10–30 s per 50 domains.
- Render the two tables (format below) from the JSON. Never show raw JSON.
Output format
Start with a one-line summary, worst news first, e.g.:
2 listed · 3 missing SPF · 1 no DKIM · 14 fully clean — all 6 blocklists reachable ✓
If any zone in zoneStatus is not "ok", say instead: "⚠ unreachable from this network — its column is inconclusive, not clean." If skipped is non-empty, add one line listing the skipped tokens.
Table 1 — Domain blacklists + email auth (one row per domain, rows with any failure sorted to the top):
| Domain | Spamhaus DBL | URIBL | SURBL | SPF | DKIM | DMARC |
|---|
- Blacklist cells:
✅ clean, 🚫 LISTED (reason) (bold the row's domain), ⚠️ inconclusive
- SPF:
✅ ok, ❌ missing, or 🚫 INVALID (multiple SPF records) (two+ SPF records is an RFC violation — receivers treat it as permerror)
- DKIM:
✅ ok (selectors) showing which selector(s) matched, or ❌ none found
- DMARC:
✅ ok (p=policy) or ❌ missing
Table 2 — IP-based blacklists (checks the IPs behind each domain's MX records, falling back to A records):
| Domain | IPs checked (MX/A) | Spamhaus ZEN | SpamCop | Barracuda |
|---|
Use — when the domain has no MX/A records ("no IPs").
After the tables — fix guidance
For each type of failure present (once per type, not per domain), add a short remediation note:
- Spamhaus DBL / ZEN listed → look up & request removal at https://check.spamhaus.org/
- URIBL listed → check reason & removal at https://uribl.com/lookup.shtml (black = active spam sighting; grey = often just a young/parked domain)
- SURBL listed → https://surbl.org/surbl-lookup — fix the underlying cause first; delisting requests on unfixed domains are denied
- SpamCop listed → auto-expires ~24h after spam reports stop; details at https://www.spamcop.net/bl.shtml
- Barracuda listed → removal request at https://www.barracudacentral.org/rbl/removal-request
- SPF missing → add a TXT record on the root, e.g. Google Workspace:
v=spf1 include:_spf.google.com ~all, Microsoft 365: v=spf1 include:spf.protection.outlook.com ~all (one record only — never add a second)
- DKIM none found → enable signing in the email provider's admin console (Google Admin → Apps → Gmail → Authenticate email; M365 → Defender → Email authentication) and publish the selector record it gives you
- DMARC missing → add TXT at
_dmarc.<domain>, safe starter: v=DMARC1; p=none; rua=mailto:you@yourdomain.com, tighten to p=quarantine/p=reject once SPF+DKIM pass
Caveats to keep in mind (mention only when relevant)
- DKIM
none found ≠ proof of no DKIM — only 12 common selectors are swept; a custom selector may exist. If the user knows their selector, check <selector>._domainkey.<domain> TXT directly.
- IP-list hits on shared hosting (Cloudflare/parked IPs) are usually the host's problem, not the sender's — sending through Google/M365 uses Google/Microsoft IPs, not these. Say so if a hit looks like that.
inconclusive means the blocklist refused/failed to answer this network, never "clean". Re-run on another network if it persists.
- Checking is passive: DNS lookups can never cause a listing, so it's safe to re-run as often as wanted.
The script — write verbatim to check.mjs
#!/usr/bin/env node
import { Resolver } from 'node:dns/promises';
const TIMEOUT = 4000;
const sys = new Resolver({ timeout: TIMEOUT, tries: 1 });
const withTimeout = (p) => Promise.race([
p, new Promise((_, rej) => setTimeout(() => rej(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' })), TIMEOUT + 500)),
]);
const DOMAIN_ZONES = [
{ zone: , : , : , : ,
: {
(ip.()) ;
x = (ip.()[]);
kind = x === ? : x === ? : x === ? : x === ?
: x >= && x <= ? : ;
;
} },
{ : , : , : , : ,
: {
(ip === || ip === ) ;
bits = (ip.()[]);
parts = [];
(bits & ) parts.();
(bits & ) parts.();
(bits & ) parts.();
+ (parts. ? : );
} },
{ : , : , : , : , : ,
: {
bits = (ip.()[]);
parts = [];
(bits & ) parts.();
(bits & ) parts.();
(bits & ) parts.();
(bits & ) parts.();
+ (parts. ? : );
} },
];
= [
{ : , : , : , : ,
: {
(ip.()) ;
x = (ip.()[]);
x >= ? : x >= ? : ;
} },
{ : , : , : , : , : },
{ : , : , : , : , : },
];
= [, , , , , , , , , , , ];
() {
.(zones.( (z) => {
{
ns = (sys.(z.));
ips = [];
( h ns.(, )) {
{ ips.(... (sys.(h))); } {}
(ips. >= ) ;
}
(ips.) {
z. = ({ : , : });
z..(ips.(, ));
} z. = sys;
} { z. = sys; }
name = z. ? z. : ;
{
a = (z..(name));
z. = a.( ip.(z.));
} { z. = ; }
}));
}
() {
(!z.) ;
{
ips = (z..(name));
(!ips || !ips.) ;
hit = ips.(z.).( v.());
hit || ;
} (e) {
(e && (e. === || e. === )) ;
;
}
}
() {
{ ( (sys.(name))).( c.()); }
{ []; }
}
() {
out = { domain, : {} };
.(.( (z) => {
out.[z.] = (z, );
}));
rootTxt = (domain);
spf = rootTxt.( t.().());
out. = spf. === ? : spf. > ? : ;
dmarcTxt = ();
dmarc = dmarcTxt.( t.().());
(!dmarc) out. = ;
{ p = .(dmarc); out. = ; }
sel = .(.( (s) => {
recs = ();
recs.( t.() || t.()) ? s : ;
}));
found = sel.();
out. = found. ? : ;
ips = []; out. = ;
{
mx = (sys.(domain));
hosts = mx.( a. - b.).(, ).( m.);
( h hosts) { { ips.(... (sys.(h))); } {} }
out. = hosts.() || ;
} {}
(!ips.) { { ips = (sys.(domain)); out. = out. || ; } {} }
ips = [... (ips)].(, );
out. = ips;
out. = {};
.(.( (z) => {
(!ips.) { out.[z.] = ; ; }
verdicts = .(ips.( (z, )));
hit = verdicts.( v.());
out.[z.] = hit || (verdicts.() ? : );
}));
out;
}
raw = process..().( !a.());
seen = (); domains = []; skipped = [];
( d raw) {
d = d.().().(, ).(, ).(, );
(!d) ;
(!.(d)) { skipped.(d); ; }
(!seen.(d)) { seen.(d); domains.(d); }
}
(!domains.) { .(); process.(); }
([..., ...]);
zoneStatus = .([..., ...].( [z., z. ? : ]));
= ;
results = [];
( i = ; i < domains.; i += ) {
results.(... .(domains.(i, i + ).(checkDomain)));
}
.(.({ zoneStatus, skipped, results }, , ));