Skip to main content

Processing Feedback Loops and Spam Complaints

Subscribe to SES, SendGrid, and Postmark complaint notifications, parse ARF reports, suppress complainers instantly, and enroll in Yahoo CFL and Microsoft JMRP feedback loops.

When a recipient clicks "Report spam," that signal can reach you — but only if you have wired up the feedback loops that carry it, and only if you act on it the instant it arrives. Ignored complaints are the fastest route to the spam folder for every other recipient on your domain. This guide covers parsing the complaint, suppressing the address immediately, and enrolling in the provider feedback loops that deliver these reports.

Root cause: the spam button generates an ARF complaint you must consume

When a user marks your message as spam, the mailbox provider records it. For senders enrolled in a feedback loop (FBL), the provider then sends back an ARF complaint (Abuse Reporting Format, RFC 5965) — a multipart/report message with report-type="feedback-report" that identifies the offending message. If you send through an ESP, that ESP typically receives the FBL on your behalf and re-exposes it as a complaint event (SES Complaint, SendGrid spamreport, Postmark SpamComplaint).

The danger is purely statistical. Mailbox providers compute a complaint rate = complaints ÷ delivered, and weight it heavily. Gmail's 2024 bulk-sender requirements set a hard expectation: keep the user-reported spam rate below 0.3%, and ideally under 0.1% as reported in Postmaster Tools. Each complaint you fail to suppress means that recipient keeps getting mail and keeps complaining, and the rate climbs. The fix is mechanical: capture the complaint, stop mailing that person, log it for analysis. The suppression store that "stop mailing" relies on is built in bounce and complaint handling.

Complaint feedback loop A recipient marks mail as spam; the mailbox provider emits an ARF report through a feedback loop to your ESP, which forwards a complaint event that suppresses the address. Complaint Feedback Loop Recipient clicks "spam" Mailbox Provider Gmail / Yahoo / MS ESP / FBL ARF report Suppress + log for analysis Keep complaint rate under 0.1% — Gmail's 2024 threshold is 0.3% maximum. Suppression must be immediate — never on the next batch.
The path a spam click takes back to your system: act on it the instant the complaint event lands, not on the next send cycle.

Exact fix: subscribe, suppress immediately, log, and parse ARF

Subscribe to the complaint notification for whichever provider you send through, suppress the address the moment the event arrives, and persist the raw report for trend analysis. Complaints, unlike soft bounces, never retry — there is no backoff that makes a spam report better.

import json

def handle_complaint(provider: str, payload: dict, store, log) -> None:
    """Normalize each provider's complaint shape, then suppress + log."""
    addresses = []

    if provider == "ses":
        # SES: Complaint notification arrives via SNS (same topic as bounces).
        msg = json.loads(payload["Message"]) if "Message" in payload else payload
        if msg.get("notificationType") != "Complaint":
            return
        c = msg["complaint"]
        addresses = [r["emailAddress"] for r in c["complainedRecipients"]]
        # complaintFeedbackType is the ARF feedback-type: "abuse", "fraud", etc.
        detail = c.get("complaintFeedbackType", "abuse")

    elif provider == "sendgrid":
        # SendGrid Event Webhook delivers an ARRAY; filter event == "spamreport".
        for ev in payload if isinstance(payload, list) else [payload]:
            if ev.get("event") == "spamreport":
                addresses.append(ev["email"])
        detail = "spamreport"

    elif provider == "postmark":
        # Postmark sends Type == "SpamComplaint" on the bounce webhook.
        if payload.get("Type") == "SpamComplaint":
            addresses.append(payload["Email"])
        detail = "SpamComplaint"

    for addr in addresses:
        # IMMEDIATE suppression — stop ALL non-essential mail to this address now.
        store.suppress(addr, reason="complaint", source=provider, detail=detail)
        # Log the raw event so you can analyze WHICH campaigns drive complaints.
        log.write(json.dumps({"provider": provider, "email": addr,
                              "type": detail}) + "\n")

When you receive a raw ARF message directly (running your own FBL endpoint rather than going through an ESP), parse the message/feedback-report MIME part to extract the feedback type and the original recipient:

from email import message_from_bytes

def parse_arf(raw_mime: bytes) -> dict:
    msg = message_from_bytes(raw_mime)
    result = {}
    for part in msg.walk():
        # The feedback-report part carries Feedback-Type and Original-Rcpt-To.
        if part.get_content_type() == "message/feedback-report":
            report = message_from_bytes(part.get_payload(decode=True))
            result["feedback_type"] = report.get("Feedback-Type")      # "abuse"
            result["original_rcpt"] = report.get("Original-Rcpt-To")   # the complainer
            result["reported_domain"] = report.get("Reported-Domain")
    return result

"Stop all non-essential mail" is a deliberate scope: after a complaint you should suppress marketing and lifecycle mail outright, and send only genuinely required transactional messages (a password reset the user themselves just requested, a legal notice). Continuing to blast newsletters at someone who reported you is what turns one complaint into a pattern.

Variant: per-provider feedback-loop enrollment

ESPs receive most FBLs automatically, but if you operate your own IPs or want first-party data, enroll directly:

  • Yahoo Complaint Feedback Loop (CFL): enroll your sending domain/DKIM d= at Yahoo's sender portal; Yahoo then routes ARF reports to the address you register. Yahoo requires a valid DKIM signature to match complaints to a sender.
  • Microsoft JMRP (Junk Mail Reporting Program): enroll your sending IPs at the Smart Network Data Services portal; Outlook.com/Hotmail then forward ARF complaints for those IPs. Pair it with Microsoft SNDS (Smart Network Data Services), which reports spam-trap hits and complaint rates per IP even without individual ARF reports.
  • Gmail: there is no per-recipient FBL for normal senders; Gmail exposes the aggregate complaint rate through Postmaster Tools instead. The high-volume FBL is header-based and aggregated, so for Gmail you watch the dashboard rather than parse individual reports.

Whichever loops you enroll in, every report must funnel into the same handle_complaint path so suppression is uniform regardless of source.

Yahoo Complaint Feedback Loop (CFL) enrollment

Yahoo (which also covers AOL and Verizon mailboxes) runs a domain-keyed FBL. You enroll the DKIM d= domain your mail is signed with — not your IP — at Yahoo's sender feedback portal, and Yahoo routes every spam complaint for mail bearing that DKIM signature to the report address you register (typically fbl@yourdomain or an ESP-managed mailbox). Two preconditions trip people up:

  • Mail must carry a valid DKIM signature with the d= you enrolled, or Yahoo cannot attribute the complaint and simply drops it. DKIM alignment is therefore a prerequisite, configured per the SPF, DKIM, and DMARC reference.
  • Yahoo's CFL reports are redacted ARF — the recipient's address is hashed/obscured in many fields, so you match the complaint back to a send via the Original-Rcpt-To / X- headers your ESP stamped, not by reading the address off the report body. Keep a mapping from message-id to recipient so a redacted report still resolves to an address you can suppress.

Microsoft JMRP and SNDS

Microsoft (Outlook.com, Hotmail, Live) gates its complaint data behind two free programs you enroll separately, and both are IP-keyed, so they matter mainly when you send from your own dedicated IPs rather than an ESP's shared pool:

  • JMRP (Junk Mail Reporting Program) is the actual feedback loop: enroll your sending IPs and a report address, and Outlook.com forwards an ARF complaint each time a user there reports your mail as junk. Wire these into the same handle_complaint path.
  • SNDS (Smart Network Data Services) is the aggregate dashboard: per-IP it shows complaint rate, spam-trap hits, filter result (inbox vs junk), and a red/yellow/green reputation band — even for the complaints JMRP does not individually forward. Treat SNDS as the early-warning gauge and JMRP as the per-address suppression feed; you need both, because SNDS tells you that reputation is slipping while JMRP tells you which addresses to suppress.

Gmail Postmaster Tools

Gmail does not offer a per-recipient feedback loop for ordinary senders — there is no ARF report per complaint to parse. Instead, verify your domain in Gmail Postmaster Tools and watch the aggregate dashboards: the Spam Rate chart (the user-reported rate Gmail holds you to, with the 0.1% target and 0.3% ceiling), the Domain/IP Reputation bands, and the Authentication pass rates for SPF, DKIM, and DMARC. Because there is no event to suppress on, your Gmail control loop is different: alert when the Postmaster spam-rate line approaches 0.1%, then use your own per-campaign complaint logs (from the providers that do report individually) to find and pause the message stream driving it. Gmail also participates in the standards-track Feedback-ID header — stamp a structured Feedback-ID: campaign:stream:senderid on outbound mail so Postmaster Tools can break the spam rate down by your own dimensions.

Provider FBL type Keyed on What you get How you act
Yahoo / AOL CFL (per-complaint ARF, redacted) DKIM d= domain Individual complaints Suppress via message-id mapping
Microsoft Outlook.com JMRP (per-complaint ARF) Sending IP Individual complaints Suppress directly
Microsoft Outlook.com SNDS (aggregate) Sending IP Complaint rate, trap hits, reputation Alert / throttle
Gmail Postmaster Tools (aggregate) Domain (+ Feedback-ID) Spam rate, reputation, auth Alert + pause campaign

Transactional pipeline integration

The complaint endpoint should validate the provider signature, normalize, and enqueue — exactly like the bounce path — and dedupe on the provider event id so a retried webhook does not double-log. SES complaints share the SNS topic and handler described in handling hard bounces with Amazon SES and SNS, so a single subscriber covers both event types. Track complaint rate per campaign in your logs so you can find and kill the specific message stream driving complaints before the domain-wide rate crosses 0.1%.

Two operational details make the per-campaign attribution work in practice. First, stamp a structured Feedback-ID header (Feedback-ID: <campaign>:<stream>:<senderid>) on every outbound message — Gmail and several other providers expose your spam rate broken down by it, and it lets you slice your own complaint logs by campaign without joining back to a separate send log. Second, keep a durable message-id → recipient mapping for at least your complaint window (30–60 days), because redacted FBLs (Yahoo CFL especially) hand you a report you can only resolve to an address through that mapping. Without it, an enrolled FBL produces complaints you cannot suppress, which is worse than not enrolling, since the rate climbs while you watch.

Finally, distinguish the list-unsubscribe signal from a spam complaint. Mail that includes a one-click List-Unsubscribe/List-Unsubscribe-Post header (required by Gmail and Yahoo for bulk senders) lets a recipient unsubscribe instead of reporting spam; an unsubscribe should suppress the marketing/lifecycle stream for that address but is not the reputation-damaging event a spamreport is. Route unsubscribes to a reason="unsubscribe" suppression and reserve reason="complaint" for genuine spam reports so your complaint-rate metric is not polluted by people who simply opted out cleanly.

Validation & deployment checklist

  • Complaint notifications subscribed for every provider you send through (SES SNS, SendGrid webhook, Postmark webhook).
  • Each complaint event suppresses the address immediately, with no retry path.
  • After a complaint, only required transactional mail is allowed; marketing/lifecycle is stopped.
  • Raw complaint events are logged with provider, address, and feedback type for analysis.
  • ARF parsing extracts Feedback-Type and Original-Rcpt-To when handling raw reports.
  • Enrolled in Yahoo CFL and Microsoft JMRP/SNDS for any self-managed sending IPs.
  • Gmail Postmaster Tools monitored; alert when reported spam rate nears 0.1%.
  • Complaint ingest is idempotent on the provider event id.

← Back to Bounce and Complaint Handling