---
name: "email_deliverability"
description: "Verify and maintain email deliverability infrastructure: check SPF, DKIM, and DMARC DNS records with dig, ramp new domains through a warm-up cadence, enforce hard-bounce and complaint suppression hygiene, and screen subjects and content against spam-filter triggers. Use before any outbound email at volume, when a domain or inbox is new, or when mail starts landing in spam. Does not write email copy — pair with cold-email-drafting for subject lines, body copy, and personalization."
metadata:
  includeInPrompt: true
---

## Purpose

Technical deliverability for agent-run email: authenticate the sending domain, warm up its reputation before real volume, and keep lists clean of bounces and complaints — so outbound mail reaches the inbox instead of spam or the reject pile. This skill is the plumbing, not the prose: it never drafts copy, picks subject lines, or personalizes. Run it before first send from any domain, and re-run it whenever placement drops.

## Workflow

1. **Check SPF.** `dig +short TXT <domain>` → look for a TXT record starting `v=spf1`. Multiple SPF records = broken (receivers treat it as a permerror).
2. **Check DKIM.** Get the selector from your sending provider, then `dig +short TXT <selector>._domainkey.<domain>` → must return a key record (starts `v=DKIM1;`). DKIM signs every message; without it, forwarding breaks SPF and you lose authentication.
3. **Check DMARC.** `dig +short TXT _dmarc.<domain>` → must start `v=DMARC1;` and carry a `p=` policy (`none` = monitor, `quarantine` = spam-folder, `reject` = block). DMARC passes only when SPF **or** DKIM passes **and** aligns with the visible From domain — relaxed alignment (default) allows subdomains; strict requires an exact match.
4. **Run the checker script.** Save the script below as `check_deliverability.py` and run `python3 check_deliverability.py <domain> [dkim-selector]`. It parses the dig output and flags: missing records, duplicate SPF records, more than 10 DNS-lookup mechanisms in SPF (the RFC 7208 limit — every `include`, `a`, `mx`, `ptr`, `exists`, and `redirect` counts), missing DMARC policy, and missing `rua=` reporting address.

```python
#!/usr/bin/env python3
"""check_deliverability.py - SPF/DKIM/DMARC sanity check.
Save this file, then run: python3 check_deliverability.py example.com [dkim-selector]
Needs dig, or falls back to DNS-over-HTTPS via curl's path. stdlib only."""
import json, re, shutil, subprocess, sys, urllib.request

LOOKUP_MECHS = re.compile(r'\b(include|a|mx|ptr|exists)\b|redirect=')

def dig_txt(name):
    recs = []
    if shutil.which('dig'):
        try:
            out = subprocess.run(['dig', '+short', 'TXT', name],
                                 capture_output=True, text=True, timeout=10).stdout
        except Exception:
            out = ''
        for line in out.splitlines():
            line = line.strip()
            if line.startswith('"') and line.endswith('"'):
                recs.append(line[1:-1])
    if not recs:  # DNS-over-HTTPS fallback for sandboxes where dig is blocked
        try:
            req = urllib.request.Request(
                f'https://cloudflare-dns.com/dns-query?name={name}&type=TXT',
                headers={'accept': 'application/dns-json'})
            data = json.load(urllib.request.urlopen(req, timeout=10))
            recs = [a['data'].strip('"') for a in data.get('Answer', [])]
        except Exception:
            pass
    return recs

def check_spf(recs):
    spf = [r for r in recs if re.match(r'^\s*v=spf1\b', r)]
    if not spf:
        return ['FAIL: no SPF record (TXT starting with v=spf1)']
    issues, rec = [], spf[0]
    if len(spf) > 1:
        issues.append(f'FAIL: {len(spf)} SPF records published (multiple = permerror)')
    n = len(LOOKUP_MECHS.findall(rec))
    if n > 10:
        issues.append(f'FAIL: {n} DNS-lookup mechanisms (RFC 7208 limit is 10)')
    if 'redirect=' not in rec and not re.search(r'[~\-?]all\b', rec):
        issues.append('WARN: no ending all mechanism (default to ~all, use -all only when sure)')
    return issues or ['PASS: ' + rec[:90]]

def check_dmarc(recs):
    dm = [r for r in recs if re.match(r'^\s*v=DMARC1\b', r)]
    if not dm:
        return ['FAIL: no DMARC record at _dmarc.<domain>']
    issues, rec = [], dm[0]
    m = re.search(r'\bp=(none|quarantine|reject)\b', rec)
    if not m:
        issues.append('FAIL: DMARC record has no p= policy')
    elif m.group(1) == 'none':
        issues.append('INFO: p=none (monitor only; move to quarantine/reject once reports are clean)')
    if 'rua=' not in rec:
        issues.append('WARN: no rua= address — you will not receive aggregate reports')
    return issues or ['PASS: ' + rec[:90]]

def main():
    if len(sys.argv) < 2:
        print('usage: python3 check_deliverability.py <domain> [dkim-selector]')
        sys.exit(2)
    domain = sys.argv[1]
    print(f'--- SPF: {domain} ---')
    print('\n'.join(check_spf(dig_txt(domain))) or 'no TXT records returned')
    print(f'--- DMARC: _dmarc.{domain} ---')
    print('\n'.join(check_dmarc(dig_txt(f'_dmarc.{domain}'))) or 'no TXT records returned')
    if len(sys.argv) > 2:
        sel = sys.argv[2]
        print(f'--- DKIM: {sel}._domainkey.{domain} ---')
        recs = dig_txt(f'{sel}._domainkey.{domain}')
        print(recs[0][:120] if recs else 'FAIL: no DKIM key record — confirm the selector with your provider')
    else:
        print('--- DKIM: skipped (pass your provider\'s selector as 2nd arg) ---')

main()
```

5. **Apply the bulk-sender bar.** At ~5,000+ messages/day to personal Gmail or Outlook/Hotmail/Live addresses, providers require: SPF **and** DKIM (both, not either), DMARC published at `p=none` minimum, From-domain alignment with SPF or DKIM, valid forward/reverse DNS and TLS, and one-click unsubscribe (RFC 8058 `List-Unsubscribe` header) on marketing mail, honored within ~2 days. All senders at any volume need SPF or DKIM. Keep user spam complaints under 0.3% per day in Google Postmaster Tools, targeting under 0.1%. These thresholds were current as of a 2026-09-21 check — verify before quoting them to anyone, and note Gmail now rejects non-compliant bulk mail outright (no more quiet spam-foldering).
6. **Warm up new domains and inboxes.** Practitioner consensus (not a provider mandate — treat as a starting point, verify before quoting): ~3–4 weeks total. Start at 10–20 sends/day to real recipients who open and reply, ramp gradually toward your target per-inbox rate, keep engagement-style mail flowing alongside outreach, and never spike volume day-over-day. Default to a separate subdomain or dedicated sending domain for cold outreach so a reputation hit can't touch the main domain — unless the program is low-volume 1:1 mail where one domain is fine.
7. **Enforce bounce/complaint hygiene.** Hard bounces (5xx / "address not found") → suppress immediately, never retry. Soft bounces (4xx / full mailbox) → retry at most 2–3 times spaced days apart, then suppress. Any spam complaint → suppress permanently. Never re-add a suppressed address without a fresh, explicit re-opt-in. Default to verifying addresses (syntax + MX record) before first send rather than discovering bounces afterward.
8. **Screen for spam-filter triggers.** Mechanical check before every campaign — all-caps subjects, 3+ exclamation marks, URL shorteners, image-only bodies, misleading From/display names, and deceptive `Re:`/`Fwd:` prefixes. This blocks patterns; it doesn't write the copy.

## Output Contract

Return a Deliverability Report containing:

- **Record status:** SPF / DKIM / DMARC — PASS or FAIL, with the exact failing condition and the concrete fix (e.g. "DMARC record missing → publish a TXT record at `_dmarc` with `v=DMARC1; p=none; rua=mailto:<addr>`").
- **Bulk-sender checklist** (when volume applies): alignment status, one-click unsubscribe present, PTR/TLS status.
- **Warm-up plan:** current day/volume per inbox, target volume, ramp table.
- **Hygiene actions:** counts suppressed this batch (hard bounce / soft bounce / complaint), list-verification result.
- **Trigger screen:** PASS, or flagged patterns with the exact subject/body line that tripped them.
- **Confidence note:** which thresholds and warm-up numbers were verified fresh vs. labeled "verify before quoting".

## Operating Rules

1. Default to verifying authentication before first send from any domain; skip re-verification only when nothing changed since the last green check.
2. Treat a hard bounce as permanent suppression — don't "retry just in case" unless the provider's response explicitly indicates a temporary failure.
3. Never return a suppressed address to a list without a fresh, explicit re-opt-in.
4. Keep complaints under 0.3%/day as the ceiling and under 0.1% as the operating target — verify the current numbers before quoting them.
5. Default to `p=none` on first DMARC publish (monitor via `rua=` reports); move to `quarantine`/`reject` only after reports show clean alignment — unless it's a high-spoof-risk brand, in which case start stricter.
6. Default to separate sending domains or subdomains for cold outreach vs. main/transactional mail; share a domain only when volume is low and 1:1.
7. Don't warm up with purchased, scraped, or long-dormant lists — only recipients who can genuinely engage.
8. Label time-sensitive numbers (warm-up volumes, complaint thresholds, provider policies) as "verify before quoting" whenever passing them on — never present them as guarantees.
9. If `dig` is blocked in your environment, verify records via the script's DNS-over-HTTPS fallback rather than declaring checks un-runnable.
