mailoutofspam
End-to-end protocol to diagnose and repair email deliverability for a domain whose outbound mail is landing in spam folders. Designed for cold-outreach / commercial-mail senders that have proper authentication on paper but are still flagged by Gmail/Outlook/Yahoo.
This skill is diagnostic-first: it never assumes DNS is broken before checking. In >50% of real cases, SPF/DKIM/DMARC are already correct and the cause is reputation/headers/content. Skip the DNS rewrite if the audit shows pass.
When to invoke
- A user reports their cold-mails or transactional mails are going to spam
- After Gmail/Yahoo's Feb 2024 sender requirements rolled out (List-Unsubscribe-Post=One-Click became mandatory for >5000/day senders)
- DMARC aggregate (rua) reports show pass-rate dropping
- Mail-tester.com score is below 8/10
- A bounce-rate spike on a previously-stable mailbox
Hard rules (do not violate)
- Pause outbound workflows BEFORE making changes. Do not edit a live-sending pipeline.
- Preserve all logs and current configuration — snapshot before mutation.
- No spam-evasion tactics — no domain rotation, no fake
Re:/Fwd:, no hidden unsubscribe. - Do not move DMARC to
p=quarantineorp=rejectuntil SPF/DKIM/DMARC pass consistently. If you find the domain already at quarantine but no aggregate reports verify pass, drop top=nonefirst, verify, ramp back. - No domain rotation to bypass reputation — fix the existing reputation.
Step 1 — Pause sending
Disable the outbound send workflows. Keep reply-handler / bounce-listener active.
For n8n:
curl -X POST "https://<n8n-host>/api/v1/workflows/<wid>/deactivate" \
-H "X-N8N-API-KEY: $N8N_API_KEY"
Snapshot all workflow JSON to snapshot/.
Step 2 — DNS authentication audit
Use DNS-over-HTTPS so it works without dig:
import urllib.request, json
def doh(name, rtype):
url = f"https://1.1.1.1/dns-query?name={name}&type={rtype}"
r = urllib.request.Request(url, headers={"accept":"application/dns-json"})
d = json.loads(urllib.request.urlopen(r, timeout=10).read())
return [a.get("data","").strip('"') for a in d.get("Answer",[])]
DOMAIN = "factbinger-websites.com"
print("A: ", doh(DOMAIN,"A"))
print("MX: ", doh(DOMAIN,"MX"))
print("SPF: ", [t for t in doh(DOMAIN,"TXT") if t.startswith("v=spf1")])
print("DMARC:", doh(f"_dmarc.{DOMAIN}","TXT"))
for sel in ("hostingermail1","default","selector1","google","s1","mail","key1"):
r = doh(f"{sel}._domainkey.{DOMAIN}","TXT")
if r and not r[0].startswith("ERR"):
print(f"DKIM {sel}: {r[0][:120]}…")
Document findings in snapshot/dns_audit.md. Compare against a working sister-domain on the same provider if you have one — if both are identical, DNS is not the problem.
Step 3 — Fix SPF/DKIM/DMARC (only if needed)
- Exactly one SPF record. Multiple SPF TXTs at the apex break verification.
- DKIM selector exists — provider-specific (Hostinger uses
hostingermail1, Google usesgoogle, etc.). Do not guess; check provider docs. - DMARC: start at
p=none, full reporting:v=DMARC1; p=none; sp=none; pct=100; rua=mailto:dmarc@<domain>; ruf=mailto:dmarc@<domain>; aspf=r; adkim=r; fo=1;fo=1→ forensic on any failure (not just full)sp=none→ subdomain inheritance- The mailbox in
rua=mailto:MUST exist. Ifdmarc@doesn't exist, reports vanish — repoint to a verified mailbox likecontact@or create the dmarc@ inbox first.
- Ramp to
p=quarantineonly after 5–7 days of aggregate reports showing 100% pass-rate.
If using Hostinger, the API rejects merge-mode TXT-record updates — duplicate records will be created. Always:
DELETEthe existing TXT namePUTthe new single record
Step 4 — Suppression infrastructure
Five reasons per spec: unsubscribed, hard_bounce, complaint, invalid_email, manual_block.
CREATE TYPE suppression_reason AS ENUM (
'unsubscribed','hard_bounce','complaint','invalid_email','manual_block'
);
ALTER TABLE suppressions
ADD COLUMN IF NOT EXISTS reason suppression_reason,
ADD COLUMN IF NOT EXISTS source TEXT,
ADD COLUMN IF NOT EXISTS smtp_code TEXT,
ADD COLUMN IF NOT EXISTS notes TEXT,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS email_lower TEXT GENERATED ALWAYS AS (LOWER(email)) STORED;
CREATE UNIQUE INDEX IF NOT EXISTS suppressions_idx
ON suppressions (email_lower, COALESCE(flow,''));
Idempotent suppression check before every send via LOWER(l.email) NOT IN (SELECT email_lower FROM suppressions ...).
Full SQL migration in examples/01_suppression_table.sql.
Step 5 — RFC 8058 one-click unsubscribe
Build an HTTPS endpoint that accepts BOTH:
GET /unsubscribe?t=<token>— for the link in the email body (browser visit)POST /unsubscribewith form bodyt=<token>&List-Unsubscribe=One-Click— what Gmail/Yahoo's mail-clients call automatically when the user clicks the inbox-level unsub button
Token strategy: random URL-safe 32-char string (NOT HMAC if your sandbox blocks crypto). Store in unsub_tokens(token, email, flow, used_at, ip, user_agent) — generated at email-render time, looked up on hit.
Headers added to every commercial email before DKIM signing:
List-Unsubscribe: <https://example.com/unsubscribe?t=TOKEN>, <mailto:contact@example.com?subject=unsubscribe>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Precedence: bulk
Auto-Submitted: auto-generated
Verify the headers ARE in the DKIM h= list — otherwise mail-clients will not trust them for one-click action.
n8n implementation: see examples/n8n_unsubscribe_workflow.json. Two parallel webhook nodes (one for GET, one for POST) merging into the same Code → Postgres → Respond chain. DO NOT use multipleMethods on a single webhook — it splits outputs and the downstream Code node only receives one branch.
Step 6 — Bounce parsing + auto-suppress
DSN (Delivery Status Notification) detection in the reply-handler:
const fromIsDaemon = /(mailer-daemon|postmaster|do[-_]?not[-_]?reply|noreply|bounces?@)/i.test(from);
const subjIsBounce = /(undelivered|delivery (failed|status|notification)|returned|failure notice|mail.*failed|bounced?|nicht zustellbar|unzustellbar)/i.test(subject);
const bodyHasSmtp5xx = /\b5\d{2}\s/.test(body) || /Status:\s*5\.\d/.test(body);
const is_bounce = (fromIsDaemon && (subjIsBounce || bodyHasSmtp5xx)) || (subjIsBounce && bodyHasSmtp5xx);
// Extract failed recipient
const xfail = body.match(/X-Failed-Recipients:\s*([^\r\n,]+)/i);
const finalrcpt = body.match(/Final-Recipient:\s*(?:rfc822;)?\s*([^\r\n;,\s]+)/i);
const angled = body.match(/<([^@<>\s]+@[^@<>\s]+)>/);
const bounced_email = (xfail||finalrcpt||angled||[])[1] || '';
// SMTP code
const codeMatch = body.match(/\b(5\d{2})\s+(\d\.\d\.\d)?\s*([^\r\n]{0,120})/);
On detection: insert into suppressions with reason='hard_bounce', source='smtp-bounce', smtp_code=…, idempotent via the unique index.
Step 7 — Template cleanup
- Plaintext-first, optional minimal HTML mirror (multipart/alternative)
- ONE clear CTA (the reply, plus visible unsubscribe link — no other links)
- No attachments
- Sender identity, business name, postal address, imprint URL all visible in footer
- Defense-in-depth strip: collapse
!{2,}, lowercaseGRATIS|KOSTENLOS|JETZT|EXKLUSIV|RIESEN, drop$$$/€€€
Salutation: Sehr geehrte/r <Lastname> for B2B, <Firma>-Team as fallback. Avoid Hallo for cold-outreach.
Step 8 — Throttle
- Disable any burst-sending node
- Per-provider hourly cap via Postgres helper:
CREATE FUNCTION fn_under_provider_cap(_d TEXT, _cap INT, _w INTERVAL DEFAULT '1 hour') RETURNS BOOLEAN LANGUAGE SQL STABLE AS $$ SELECT COUNT(*) < _cap FROM provider_rate_log WHERE recipient_domain = LOWER(_d) AND sent_at >= NOW() - _w