Yahoo & AOL Complaint Feedback Loops: Enrollment and a Suppression Pipeline That Actually Fires
Enrolling in Yahoo's Complaint Feedback Loop takes 15 minutes. The real work is attributing a redacted spam complaint back to a subscriber with VERP, then enforcing suppression at send time so that person never gets mailed again.
EvilMail TeamJuly 13, 202613 min read
A Yahoo user opens your campaign, sighs, and clicks "Spam." If you're enrolled in the Complaint Feedback Loop, Yahoo emails you a machine-readable report about that click. You open it and find that the one field you actually need — the recipient's address — has been redacted. Original-Rcpt-To is stripped. No name, no email, nothing that says *who* just told Yahoo you're a spammer.
So the naive setup does the worst possible thing: it enrolls, checks the box, and keeps mailing the person who complained. They get mailed again, click spam again. Complaint rate creeps from 0.08% to 0.2% to 0.35%, Yahoo starts deferring your mail with 421 throttles, and eventually the domain gets blocked. The feedback loop was working the entire time. The pipeline behind it wasn't.
Enrollment is the easy 15-minute part. The engineering that protects your reputation is two things: making every complaint attributable despite redaction, and wiring the parsed report into a global suppression list enforced at send time — atomically, before the next batch goes out. A spam-button click tells you nothing unless it deterministically removes that subscriber forever. This is how you build the part that fires.
One loop, two brands: what the Yahoo CFL actually covers
Yahoo Mail and AOL are one mail system. AOL's old scomp/postmaster feedback loop folded into Yahoo's infrastructure during the Oath/Verizon Media era, and today both are served from senders.yahooinc.com. You enroll once and cover both brands. There is no separate AOL sign-up anymore; if you're chasing a legacy AOL FBL form, stop — it's gone.
The architectural fact that trips people up: the Yahoo CFL is keyed on your DKIM `d=` domain, not on your sending IP. Legacy FBLs (and Microsoft's JMRP) key on IP. Yahoo keys on the signing domain. Two consequences follow directly:
You must DKIM-sign 100% of your mail. Unsigned mail generates complaints you'll never be told about.
You enroll per signing domain, not per IP. Rotate IPs freely; your FBL coverage doesn't care.
This is also the last major consumer feedback loop still standing. Gmail never offered one — you get spam-rate charts in Postmaster Tools instead. Microsoft splits it into SNDS (IP data) and JMRP (the actual complaint feed). Yahoo/AOL is the only place a consumer spam click lands in your inbox as a parseable report, and it covers a large slice of consumer mail. That makes it worth doing right.
Prerequisites: DKIM, a report mailbox, and a domain you control
Before you touch the enrollment form, three things must exist and work.
1. A valid DKIM signature aligned to the domain you'll register. Yahoo validates your DKIM at enrollment and rejects you if it fails. Publish the public key:
And sign with the matching selector. A minimal OpenDKIM config:
ini
# /etc/opendkim.conf
Domain evilmail.pro
Selector mail
KeyFile /etc/opendkim/keys/evilmail.pro/mail.private
Canonicalization relaxed/simple
Mode s
SignatureAlgorithm rsa-sha256
Confirm it validates before proceeding — send yourself a message and check for dkim=pass in the Authentication-Results header. If DKIM is broken, everything downstream is theatre.
2. A dedicated report mailbox. Route FBL and bounce traffic to its own subdomain with its own MX so complaints never land in your primary inbox:
dns
bounces.evilmail.pro. IN MX 10 mx.bounces.evilmail.pro.
Reports go to [email protected]. Keeping this off your main MX means you can pipe the whole mailbox straight into a parser without touching human mail.
3. DNS control to prove ownership during enrollment.
Enrolling at senders.yahooinc.com
Go to https://senders.yahooinc.com/complaint-feedback-loop/ and fill in the Complaint Feedback Loop form:
DKIM `d=` domain:evilmail.pro — the exact domain in your signatures.
Report destination:[email protected]. It does *not* have to live at the registered domain, but it must be deliverable and monitored.
Verify ownership via the DKIM/DNS method Yahoo presents.
Approval typically lands in a day or two. Once you're live, spam-button clicks start arriving as ARF reports. While you're there, check the Yahoo Sender Hub for reputation and deliverability signals — the CFL tells you *who* complained; the Sender Hub tells you whether your reputation is trending toward a wall.
The redaction problem — and why VERP is the fix
Here's the field you were counting on, and here's what Yahoo does to it:
text
Original-Rcpt-To: rfc822; redacted
Gone. So how do you know who complained? Two tempting but wrong answers:
Match on Message-ID. Fragile. It means storing every Message-ID you've ever sent and hoping the original survives intact in the returned copy. It won't scale and it will drift.
Trust Original-Rcpt-To. It's redacted. There's nothing to trust.
The right answer exploits a field Yahoo *doesn't* redact: it echoes back Original-Mail-From — your SMTP envelope sender, the MAIL FROM / Return-Path — completely unredacted. So put the subscriber's identity in the envelope-from itself using VERP (Variable Envelope Return Path). Every message to every subscriber gets a unique, self-identifying envelope sender.
Don't embed the raw subscriber ID — that's forgeable and leaks. Sign it with an HMAC so the token is tamper-proof and decodable offline with no database lookup:
Your MTA rewrites MAIL FROM per recipient to carry this token. When the complaint comes back, Original-Mail-From hands you the token, you re-derive the HMAC to confirm it's genuinely yours (not a forged bounce or a joe-job), and you recover subscriber_id=4821 with zero ambiguity. This survives redaction because it *is* the sender, not the recipient.
Anatomy of the ARF report
An ARF report (RFC 5965) is a multipart/report; report-type=feedback-report message with exactly three parts. Learn the shape and the parser writes itself:
Reliable fields: Feedback-Type: abuse, Original-Mail-From (your gold), Reported-Domain, Arrival-Date. Redacted and useless: Original-Rcpt-To. Everything you need lives in part (b).
Parsing complaints into suppression
Pipe the FBL mailbox into a parser. In Postfix master.cf, a transport that hands each message to the script on stdin:
text
# master.cf
fblpipe unix - n n - - pipe
flags=Rq user=fbl argv=/opt/evilmail/fbl_parse.py
The parser walks the MIME tree, grabs the message/feedback-report part, extracts Original-Mail-From, verifies the HMAC, and inserts into suppression:
python
#!/usr/bin/env python3
import sys, re, hmac, hashlib, psycopg2
from email import message_from_binary_file, policy
SECRET = open("/etc/evilmail/fbl.key", "rb").read()
def recover_subscriber(mail_from: str) -> int | None:
m = re.match(r"fbl\+(\d+)\.([0-9a-f]{10})@", mail_from or "")
if not m:
return None
sub_id, sig = m.group(1), m.group(2)
good = hmac.new(SECRET, sub_id.encode(), hashlib.sha256).hexdigest()[:10]
return int(sub_id) if hmac.compare_digest(good, sig) else None
msg = message_from_binary_file(sys.stdin.buffer, policy=policy.default)
report = next((p for p in msg.walk()
if p.get_content_type() == "message/feedback-report"), None)
if report is None:
sys.exit(0) # not an ARF report; drop quietly
fields = report.get_payload(0) if report.is_multipart() else report
mail_from = fields.get("Original-Mail-From", "")
addr_m = re.search(r"[\w.+-]+@[\w.-]+", mail_from)
env_from = addr_m.group(0) if addr_m else None
sub_id = recover_subscriber(env_from)
conn = psycopg2.connect("dbname=maildb user=mailadmin")
with conn, conn.cursor() as cur:
cur.execute("""
INSERT INTO suppressions (address, subscriber_id, reason, source, created_at)
VALUES (%s, %s, 'fbl_complaint', 'yahoo', now())
ON CONFLICT (address) DO NOTHING
""", (env_from, sub_id))
The table:
sql
CREATE TABLE suppressions (
address text PRIMARY KEY,
subscriber_id bigint,
reason text NOT NULL, -- fbl_complaint, hard_bounce, unsubscribe
source text NOT NULL, -- yahoo, gmail, manual
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON suppressions (subscriber_id);
ON CONFLICT DO NOTHING makes reprocessing safe — a complaint that arrives twice, or a mailbox you re-poll after a crash, never errors and never double-counts. If piping through Postfix isn't an option, drop reports into a maildir and run the same script from a one-minute cron poller. Minutes matter here; hours don't.
Enforcing suppression at send time
This is the half everyone forgets. A suppression list swept nightly is a list that lets a complained-about subscriber receive tomorrow morning's blast. Suppression must be consulted on every send, atomically, at the last possible moment — an anti-join against the recipient batch as you build it, not a cleanup job:
sql
SELECT s.id, s.email, s.subscriber_id
FROM subscribers s
WHERE s.list_id = $1
AND s.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM suppressions x
WHERE x.address = s.email
OR x.subscriber_id = s.subscriber_id
);
Match on both address and subscriber_id so a complaint pins the human even if they later change their email of record.
Scope matters. A spam complaint means "never again, from anyone" — suppress it globally, across marketing *and* transactional streams. Yes, that means someone who spam-flagged a newsletter stops getting your "your invoice is ready" mail too. That's correct: they told a mailbox provider you're abusive, and continuing to mail them is exactly what drives your complaint rate up. Honor it everywhere. The gap between ingest and enforcement should be minutes, and it must close before the next campaign — not after.
Watching the number that gets you blocked
Target under 0.1% — one complaint per 1,000 delivered. Healthy consumer mail lives well below this.
Alert at ~0.2%. Something in a recent campaign is off.
Sustained above ~0.3% and Yahoo starts deferring you with 421 throttles, then blocking. Recovery is slow and manual.
Because Gmail gives you no feedback loop, pair the CFL with Google Postmaster Tools' spam-rate chart to watch the other consumer giant. A daily rollup on the suppressions table catches a bad campaign before Yahoo does:
sql
SELECT date_trunc('day', created_at) AS day,
source, count(*) AS complaints
FROM suppressions
WHERE reason = 'fbl_complaint'
AND created_at > now() - interval '30 days'
GROUP BY 1, 2 ORDER BY 1 DESC;
A spike on one day almost always maps to one segment or one subject line. Find it, kill it, move on.
Operational checklist
DKIM signs 100% of mail and validates (dkim=pass) — no exceptions.
Enrolled per `d=` domain at senders.yahooinc.com, not per IP.
FBL mailbox on an isolated subdomain, monitored and piped to the parser.
VERP HMAC token on every envelope-from, so every complaint is self-identifying.
Parser verifies the HMAC, recovers the subscriber, inserts idempotently (ON CONFLICT DO NOTHING).
Send path enforces suppression via anti-join / NOT EXISTS on every batch — never a nightly sweep.
Re-verify enrollment and DKIM after every key rotation. A rotated selector you forgot to publish breaks signing and silently kills the FBL — you'll be blind exactly when you're generating complaints.
The feedback loop never lowers your complaint rate. It only tells you who to stop mailing. Everything that protects your reputation happens in how fast, and how reliably, you act on that.