Skip to content

tutorials

Catch spam complaints with email feedback loops

August 10, 2026

#deliverability#feedback-loop#arf-report#complaint-handling#sender-reputation
Catch spam complaints with email feedback loops
Visual: Wes Pastel on okslop

You send a campaign, deliverability looks fine, opens look fine. Meanwhile 40 people clicked "Report Spam" and you never heard about it. Those complaints are quietly dragging your domain toward the spam folder, and the only way to see them is a feedback loop you never set up.

A spam complaint is the strongest negative signal a recipient can send: they actively told their mailbox provider your mail is unwanted. Bounces you can infer from delivery failures, but complaints are invisible by default. The provider knows, and your reputation score reflects it, but you are never told which message or which recipient unless you enroll in that provider's feedback loop (FBL). This tutorial sets up FBLs for the major providers, parses the resulting ARF (Abuse Reporting Format) reports by hand, and wires the complaining addresses into a suppression list. At the end I show how a Cloudflare-native stack turns the same ARF parsing into an inbound interpreter so you never touch the MIME by hand, but the mechanics below apply no matter what you send through.

What is an email feedback loop and why do you need one?

An email feedback loop is a service from a mailbox provider that forwards you a complaint report every time one of their users marks your mail as spam. Without one, spam complaints accumulate silently and you only notice when delivery has already collapsed.

Here is the chain of events. A recipient at Yahoo opens your newsletter, decides they did not want it, and clicks "Report Spam". Yahoo records that complaint against your sending domain and uses it to score your reputation. If you have enrolled in Yahoo's Complaint Feedback Loop, Yahoo also generates an ARF report and emails it to the address you registered. That report tells you which recipient complained (so you can suppress them) and which message they complained about (so you can spot the campaign that triggered it).

Skip the FBL and you lose both signals. Your complaint rate still climbs, your reputation still drops, but you are flying blind. Worse, you keep mailing the complainer on the next campaign, which produces another complaint, which compounds the damage. The whole point of an FBL is to close that loop: complaint comes in, address goes onto the suppression list, that address is never mailed again.

Complaints matter more than bounces. A 2 percent bounce rate is bad; a 0.3 percent complaint rate is the line Gmail draws before it starts throttling you. The arithmetic is brutal because complaints are scored heavily, so even a handful per thousand sends is a problem. See why your email goes to spam and how to diagnose it for where complaints sit among the other deliverability signals.

How do you sign up for feedback loops with the major providers?

You enroll separately with each provider, prove you control the sending domain, and point the reports at a dedicated address. There is no single FBL registry; each major mailbox provider runs its own program with its own quirks.

Yahoo / AOL (Complaint Feedback Loop, CFL). Yahoo runs the most widely used per-message FBL, now covering the old AOL and Verizon domains too. You enroll at their CFL signup page, supply the DKIM d= domain you sign with, and verify ownership. Yahoo then sends an ARF report to your registered address for each complaint. Yahoo keys the loop to the DKIM signing domain, not the From address, so your outbound mail must be DKIM-signed or you will receive nothing.

Microsoft (SNDS + JMRP). Microsoft splits this into two programs. SNDS (Smart Network Data Services) gives you per-IP reputation and complaint-rate data. JMRP (Junk Mail Reporting Program) is the actual feedback loop: it forwards ARF reports when Outlook/Hotmail users mark your mail as junk. Both are keyed to sending IP, which means JMRP is most useful when you control a dedicated IP. On a shared HTTPS sender you typically rely on the provider's aggregate handling instead.

Gmail (Postmaster Tools). Gmail does not send per-complaint ARF reports. Instead you verify your domain in Postmaster Tools and read an aggregate spam-rate chart, plus an optional FBL header program that returns complaint counts grouped by a campaign identifier you stamp into a Feedback-ID header. You never learn the individual complaining address from Gmail. You learn the rate, and the rate must stay under roughly 0.3 percent (see meeting Gmail and Yahoo bulk sender requirements).

Before you enroll, set up a dedicated address to receive the reports:

fbl@yourdomain.com        # the address you register with Yahoo CFL
complaints@yourdomain.com # alias if you prefer; route both to one inbox

Keep it out of your human inbox so a parser can drain it cleanly. And make sure every outbound message is DKIM-signed, because Yahoo and most others route the loop by the signing domain:

DKIM-Signature: v=1; a=rsa-sha256; d=yourdomain.com; s=mail2026;
  h=from:to:subject:date; bh=...; b=...

The d=yourdomain.com value is what Yahoo matches against your CFL enrollment. If you DKIM-sign with a different domain than you registered, the reports go nowhere.

What does an ARF feedback report actually look like?

An ARF report is a multipart/report MIME message with three parts: a human-readable note, a machine-readable message/feedback-report part, and the original reported message. RFC 5965 defines the structure precisely, which is what makes it parseable.

Here is a real-shaped Yahoo CFL report, lightly trimmed:

From: complaints@yahoo.com
To: fbl@yourdomain.com
Subject: complaint about message from 203.0.113.10
Content-Type: multipart/report; report-type=feedback-report;
  boundary="boundary-arf"

--boundary-arf
Content-Type: text/plain; charset="US-ASCII"

This is an email abuse report for an email message received
from IP 203.0.113.10 on Thu, 31 Jul 2026 14:02:11 +0000.

--boundary-arf
Content-Type: message/feedback-report

Feedback-Type: abuse
User-Agent: Yahoo!-Mail-Feedback/2.0
Version: 1
Original-Mail-From: bounce+c8a1@yourdomain.com
Original-Rcpt-To: jane.recipient@yahoo.com
Arrival-Date: Thu, 31 Jul 2026 14:02:11 +0000
Reported-Domain: yourdomain.com
Source-IP: 203.0.113.10

--boundary-arf
Content-Type: message/rfc822

From: news@yourdomain.com
To: jane.recipient@yahoo.com
Subject: Your July product update
Message-ID: <camp-2026-07@yourdomain.com>
Feedback-ID: jul-update:list-42:campaign:yourdomain
... (rest of the original message) ...

--boundary-arf--

The part that matters is the middle one, message/feedback-report. Its fields are the structured payload:

  • Feedback-Type: abuse means a spam complaint (other values exist, like fraud or not-spam, but abuse is what you act on).
  • Original-Rcpt-To is the address that complained. This is the field you suppress. Some providers redact it for privacy, in which case you fall back to the To: header of the embedded original message.
  • Original-Mail-From is your envelope sender, often carrying a per-recipient tag (here bounce+c8a1) you can map back to a subscriber.
  • The embedded Feedback-ID header (if you set one on send) ties the complaint to a specific campaign and list, which is how you find the offending send.

One privacy wrinkle: Yahoo and several others redact the recipient address in Original-Rcpt-To, replacing it with redacted or a hash. That is why per-recipient envelope tagging (bounce+c8a1@) and the Feedback-ID header matter so much. If the recipient is redacted, you map back to the subscriber through your own tag, not through the provider's report.

How do you parse an ARF report in code?

Parse the multipart/report structure, find the message/feedback-report part, read its fields as you would email headers, and fall back to the embedded original message when the recipient is redacted. Here is a self-contained Node parser using mailparser, which handles the MIME tree for you:

import { simpleParser } from "mailparser";

// raw = the full RFC822 bytes of the ARF report (from your fbl@ inbox)
export async function parseArfReport(raw) {
  const parsed = await simpleParser(raw);

  // mailparser exposes child parts; find the feedback-report part.
  const reportPart = findPart(
    parsed,
    (node) => node.contentType?.value === "message/feedback-report",
  );
  if (!reportPart) {
    throw new Error("not an ARF report: no message/feedback-report part");
  }

  // The feedback-report body is header-style "Key: value" lines.
  const fields = parseHeaderBlock(reportPart.content.toString("utf8"));

  // Original-Rcpt-To is the complainer, but it may be redacted.
  let complainant = fields["original-rcpt-to"];
  if (!complainant || complainant.toLowerCase() === "redacted") {
    // Fall back to the To: of the embedded original message.
    const original = findPart(
      parsed,
      (node) => node.contentType?.value === "message/rfc822",
    );
    complainant = original?.to?.value?.[0]?.address ?? null;
  }

  return {
    type: (fields["feedback-type"] || "abuse").toLowerCase(),
    complainant,                          // may still be null if fully redacted
    reportedDomain: fields["reported-domain"] ?? null,
    sourceIp: fields["source-ip"] ?? null,
    arrivalDate: fields["arrival-date"] ?? null,
    feedbackId: fields["feedback-id"] ?? null,
  };
}

function parseHeaderBlock(text) {
  const out = {};
  for (const line of text.split(/\r?\n/)) {
    const i = line.indexOf(":");
    if (i === -1) continue;
    out[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
  }
  return out;
}

// Walk the MIME tree (mailparser nests parts under .childNodes / attachments).
function findPart(node, predicate) {
  if (predicate(node)) return node;
  for (const child of node?.childNodes ?? node?.attachments ?? []) {
    const hit = findPart(child, predicate);
    if (hit) return hit;
  }
  return null;
}

Now the failure modes, because this is where naive parsers quietly lose complaints.

Failure 1: you assume the recipient is always present. Run the parser against a redacted Yahoo report and Original-Rcpt-To comes back as redacted:

$ node parse.js redacted-report.eml
{ type: 'abuse', complainant: null, reportedDomain: 'yourdomain.com', ... }

A null complainant means you cannot suppress anyone from the report alone. The fix is the per-recipient envelope tag you set at send time. If Original-Mail-From is bounce+c8a1@yourdomain.com, look up c8a1 in your own send log to recover the subscriber. Without that tag you genuinely cannot act on a redacted report, which is why tagging every send is not optional.

Failure 2: you treat every ARF message as a complaint. Some reports carry Feedback-Type: not-spam (the user un-marked it) or Feedback-Type: fraud. Suppressing on not-spam is exactly backwards. Always branch on Feedback-Type and only suppress on abuse (and fraud, which you should suppress and flag):

const report = await parseArfReport(raw);
if (report.type === "abuse" || report.type === "fraud") {
  await suppress(report.complainant, { reason: report.type, source: report.reportedDomain });
} else if (report.type === "not-spam") {
  // do nothing, or log; this is the recipient reversing a complaint
}

Failure 3: case and whitespace. Header field names in the feedback-report part are case-insensitive and providers disagree on capitalization (Original-Rcpt-To, Original-rcpt-to). Lowercasing every key, as parseHeaderBlock does, saves you from missing fields that are technically present.

How should you suppress and act on complaints?

Suppress every complainer immediately and permanently, record the complaint with a source and timestamp, and remove the address from all active lists. A complaint is a one-strike event: there is no retry, no win-back, no second chance.

The suppression record should be unambiguous about why:

async function suppress(address, { reason, source }) {
  if (!address) return; // redacted and untaggable; nothing to suppress
  await db.suppressions.upsert({
    address: address.toLowerCase(),
    reason,                 // "abuse" | "fraud"
    source,                 // "yourdomain.com" via the reporting provider
    suppressedAt: new Date().toISOString(),
    permanent: true,        // complaints never expire
  });
  await db.lists.removeEverywhere(address); // pull from all active lists now
}

Then check that list before every send, exactly as you would for hard bounces. This is the same suppression list described in handling email bounces and suppression the right way, and the operational habits around it are collected in bounce and suppression best practices that protect your reputation; complaints and bounces belong in one table, checked at send time, not after. The only difference is that a complaint is permanent on the first occurrence, whereas a soft bounce gets a bounded retry first.

Two operational rules that save reputation:

  1. Never re-import a complainer. When you import a list later (from Mailchimp, a CSV, anywhere), filter it against suppressions first. A complainer who lands back on a list through a careless import will complain again, and the second complaint is worse than the first.
  2. Watch the rate, not just the events. Keep complaints under 0.1 percent of delivered mail, with 0.3 percent as the hard ceiling Gmail enforces. If a single campaign spikes, that campaign's content or list targeting is the problem; pause and investigate rather than pushing the next send. Pair this with a clean List-Unsubscribe one-click unsubscribe header so annoyed recipients unsubscribe instead of complaining in the first place.

Or skip the ARF parsing: let an interpreter handle complaints

Everything above is genuinely useful to understand, and you can run it by hand forever. But once you have a dedicated fbl@ inbox draining into code, the parsing and suppression are mechanical, and mechanical work is exactly what an inbound email pipeline should absorb for you.

This is where a Cloudflare-native stack like Tinbox fits. Tinbox runs inbound mail through email interpreters: algorithmic and LLM-backed classifiers that tag each incoming message, each with its own eval suite so a prompt change cannot silently regress. A complaint interpreter recognizes ARF feedback reports (the multipart/report; report-type=feedback-report structure and the message/feedback-report fields), extracts the complainant, falls back to the embedded original message when the recipient is redacted, and writes the address to the same suppression table the sender checks before every send. Because it runs on your own Cloudflare account against your own D1 database, the complaint data never leaves your infrastructure, and there is no per-complaint or per-recipient fee for processing it.

You still do the parts only you can do: enroll your domain in Yahoo CFL, verify in Gmail Postmaster Tools, DKIM-sign your mail, and decide your complaint-rate thresholds. Tinbox does the part that is the same for everyone: turn an ARF report into a permanently suppressed address. For the broader inbound-classification pattern this is built on, see detecting OTP and verification emails automatically, which uses the same interpreter-with-evals approach for a different message type, and seven email interpreter recipes for smarter inbound handling for more of the same shape applied to other message types.

Deploy this yourself: npx tinbox init yourdomain.com. The complaint interpreter and the suppression list ship in the box; the docs cover enrolling each provider's feedback loop and wiring the fbl@ address into the inbound pipeline.

Frequently asked questions

What is an email feedback loop (FBL)?

A feedback loop is a service offered by a mailbox provider that notifies a sender whenever one of the provider's users marks the sender's email as spam. When a Yahoo or Outlook user clicks 'Report Spam', the provider generates a complaint report in ARF (Abuse Reporting Format) and emails it to the address you registered for that domain or IP. You parse the report, identify the recipient who complained, and add them to your suppression list. FBLs are how you find out about spam complaints, which are otherwise invisible to you.

What is ARF (Abuse Reporting Format)?

ARF is the standard format for spam complaint reports, defined in RFC 5965. An ARF message is a multipart/report MIME message with three parts: a human-readable explanation, a machine-readable 'message/feedback-report' part containing structured fields like Feedback-Type, User-Agent, and Original-Rcpt-To, and a copy of the original message that was reported (or just its headers). You parse the second part to extract the complaint metadata and the third part to identify which of your messages was reported.

Does Gmail offer a feedback loop?

Not a per-message one. Gmail does not forward individual ARF complaint reports the way Yahoo and Microsoft do. Instead Gmail offers the Postmaster Tools dashboard and an aggregate spam-rate feed, plus the Feedback Loop (FBL) header program that returns complaint statistics grouped by a campaign identifier you stamp into your mail, never the individual complaining address. So for Gmail you monitor a complaint rate, not specific complainers, and you must keep that rate under roughly 0.3 percent.

What should I do when I receive a spam complaint?

Suppress the complaining address immediately and permanently, then stop sending to it across every stream. A complaint is a stronger negative signal than a bounce: the recipient actively told their provider your mail is unwanted. Record the complaint with a timestamp and the source provider, remove the address from all active lists, and never re-add it through a future import. Do not try to win them back; re-mailing a complainer is the fastest way to get your domain blocklisted.

What complaint rate is too high?

Keep your spam complaint rate under 0.1 percent, and treat 0.3 percent as the hard ceiling. Gmail's bulk-sender rules explicitly require staying below 0.3 percent and ideally under 0.1 percent; sustained complaints above that get your mail throttled or sent straight to spam. Because most complaints never reach you without an FBL, a low measured rate with no FBL configured is not reassuring, it just means you are blind.

Why do I need a dedicated address and DKIM to receive feedback loops?

Providers send ARF reports to the address registered for the sending domain or DKIM signature, and they verify you actually control that domain before enrolling you. A dedicated complaints@ or fbl@ address keeps the reports out of your normal inbox and makes automated parsing clean. DKIM matters because Yahoo and others key their feedback loop to the DKIM d= domain in your signature, so reports are routed by the domain that signed the message, not the From address.