Your password reset emails are going to spam. Users can't log in. Support tickets are piling up. You check your dashboard—deliverability looks fine. SPF, DKIM, DMARC: all green. Bounce rate: 0.8%. So what's the problem?
Complaint rate. Users are marking your emails as spam, and inbox providers are listening. But unlike bounces, most ESPs don't show you complaint data by default. You need feedback loops (FBL) to see it.
What is a feedback loop (FBL)?
A feedback loop is a notification service offered by major email providers. When a user marks your email as spam, the provider sends a report back to you (if you're registered).
FBLs let you see who is complaining and which emails triggered the complaint. This is critical intel for protecting sender reputation.
Why FBLs matter
- Complaint data is mostly invisible. Without FBL, you're flying blind. Gmail doesn't tell you when users hit spam.
- Complaints hurt more than bounces. A spam report signals "this sender is unwanted." Providers weight this heavily.
- Proactive suppression prevents blocklists. Remove complainers immediately, before you hit the 0.3% threshold.
Which providers offer FBL?
Not all inbox providers support feedback loops. Here's the current landscape:
Providers with FBL
- Yahoo / AOL / Verizon Media. Comprehensive FBL. Easy registration via
complaint-feedback-loop@cc.yahoo-inc.com - Microsoft (Outlook.com, Hotmail, Live). JMRP and SNDS programs. Registration required.
- Fastmail. FBL available upon request
- Some ISPs. Comcast, Cox, Charter (mostly for bulk senders)
Providers without FBL
- Gmail. No public FBL. Google Postmaster Tools provides aggregate complaint data but no individual reports.
- Apple Mail (iCloud). No FBL. No public complaint data.
- ProtonMail. No FBL
How to register for FBL (step-by-step)
FBL registration varies by provider. Here's how to set up the big ones.
Yahoo / AOL FBL
Yahoo offers one of the most comprehensive FBLs. Registration is straightforward:
- Email Yahoo. Send a request to
complaint-feedback-loop@cc.yahoo-inc.com - Include these details:
- Your sending domain (e.g., mail.yourapp.com)
- Your sending IP address(es)
- FBL destination email address (where reports should go)
- Brief description of your sending program
- Wait for approval. Usually 3-5 business days
- Start receiving reports. Yahoo will email complaint reports in ARF (Abuse Reporting Format)
To: complaint-feedback-loop@cc.yahoo-inc.com
Subject: FBL Registration Request for yourapp.com
Hello,
I would like to register for Yahoo's Feedback Loop for our transactional email sending.
Domain: mail.yourapp.com
Sending IPs: 192.0.2.10, 192.0.2.11
FBL Reports Email: fbl@yourapp.com
Description: We send transactional emails (password resets, receipts, notifications) to our SaaS users.
Thank you,
[Your Name]
[Your Email]Microsoft (Outlook.com / Hotmail) FBL
Microsoft uses two programs: JMRP (Junk Mail Reporting Program) and SNDS (Smart Network Data Services).
- Create a Microsoft account. Sign up at postmaster.live.com
- Register your domain. Add your sending domain
- Enroll in JMRP. Navigate to JMRP section and provide FBL destination email
- Verify domain ownership. Add a TXT record to your DNS
- Start receiving reports. Microsoft sends ARF reports when users mark emails as junk
Google Postmaster Tools
Gmail doesn't offer individual FBL reports, but you can track aggregate complaint rates:
- Sign up. postmaster.google.com
- Add your domain. Enter your sending domain (e.g., mail.yourapp.com)
- Verify with DNS. Add the provided TXT record
- Monitor dashboard. Track spam rate, domain reputation, and delivery errors
Google Postmaster Tools shows:
- Spam rate. Percentage of emails marked as spam (by authenticated sending domain)
- Domain reputation. High, Medium, Low, or Bad
- IP reputation. Reputation score for your sending IPs
- Delivery errors. Rate limit errors, SPF/DKIM failures
Processing FBL reports (ARF format)
FBL reports arrive in ARF format (Abuse Reporting Format, RFC 5965). It's a structured email with three MIME parts:
- Human-readable explanation. Text description of the complaint
- Machine-readable report. Structured data (feedback-type, user-agent, etc.)
- Original message. Copy of the email that was marked as spam (headers + optionally body)
From: staff@hotmail.com
To: fbl@yourapp.com
Subject: FW: Complaint about message from yourapp.com
Content-Type: multipart/report; report-type=feedback-report;
boundary="----=_Part_12345"
------=_Part_12345
Content-Type: text/plain; charset="us-ascii"
This is an email abuse report for an email message received from IP
192.0.2.10 on Mon, 03 Mar 2026 14:35:28 +0000.
------=_Part_12345
Content-Type: message/feedback-report
Feedback-Type: abuse
User-Agent: Hotmail FBL/1.0
Version: 1.0
Original-Mail-From: noreply@mail.yourapp.com
Arrival-Date: Mon, 03 Mar 2026 14:35:28 +0000
Reported-Domain: mail.yourapp.com
Source-IP: 192.0.2.10
------=_Part_12345
Content-Type: message/rfc822
From: noreply@mail.yourapp.com
To: user@hotmail.com
Subject: Your password reset link
Date: Mon, 03 Mar 2026 14:30:00 +0000
Message-ID: <abc123@mail.yourapp.com>
[... original email headers and body ...]
------=_Part_12345--The key data points:
Feedback-Type: abuse, fraud, virus, or otherOriginal-Mail-From: Sender addressArrival-Date: When provider received the emailSource-IP: Your sending IPMessage-ID: From the original email (critical for matching to your send logs)
Message-ID header is your correlation key. Use it to look up which user received the email, what template was used, and when it was sent.Parsing ARF reports (code example)
Here's a Node.js example using mailparser to extract complaint data from ARF reports:
import { simpleParser } from "mailparser";
export interface FBLReport {
feedbackType: string;
userAgent: string;
originalMailFrom: string;
arrivalDate: string;
sourceIP: string;
messageId: string | null;
recipientEmail: string | null;
}
export async function parseARFReport(rawEmail: string): Promise<FBLReport | null> {
try {
const parsed = await simpleParser(rawEmail);
// ARF reports are multipart/report
if (!parsed.headers.get("content-type")?.includes("multipart/report")) {
console.log("[FBL] Not an ARF report");
return null;
}
// Extract machine-readable part (message/feedback-report)
let feedbackPart: string | null = null;
let originalMessageId: string | null = null;
let recipientEmail: string | null = null;
// Parse MIME parts
if (parsed.attachments) {
for (const attachment of parsed.attachments) {
if (attachment.contentType === "message/feedback-report") {
feedbackPart = attachment.content.toString();
}
// Original message part
if (attachment.contentType === "message/rfc822") {
const originalMsg = await simpleParser(attachment.content);
originalMessageId = originalMsg.messageId || null;
recipientEmail = originalMsg.to?.text || null;
}
}
}
if (!feedbackPart) {
console.log("[FBL] No feedback-report part found");
return null;
}
// Parse feedback report fields
const report: FBLReport = {
feedbackType: extractField(feedbackPart, "Feedback-Type") || "unknown",
userAgent: extractField(feedbackPart, "User-Agent") || "unknown",
originalMailFrom: extractField(feedbackPart, "Original-Mail-From") || "",
arrivalDate: extractField(feedbackPart, "Arrival-Date") || "",
sourceIP: extractField(feedbackPart, "Source-IP") || "",
messageId: originalMessageId,
recipientEmail,
};
return report;
} catch (error) {
console.error("[FBL] Error parsing ARF report:", error);
return null;
}
}
function extractField(text: string, fieldName: string): string | null {
const regex = new RegExp(`^${fieldName}:\\s*(.+)$`, "mi");
const match = text.match(regex);
return match ? match[1].trim() : null;
}Automated suppression
Once you parse the complaint, suppress the recipient immediately. Never send to them again.
import { prisma } from "@/lib/db";
import { parseARFReport } from "./arf-parser";
export async function handleComplaintReport(rawEmail: string) {
const report = await parseARFReport(rawEmail);
if (!report || !report.recipientEmail) {
console.log("[FBL] Unable to extract recipient from complaint");
return;
}
const email = report.recipientEmail;
// Suppress user permanently
await prisma.user.update({
where: { email },
data: {
emailSuppressed: true,
suppressedAt: new Date(),
suppressionReason: "spam_complaint",
},
});
// Log the complaint for analysis
await prisma.complaintLog.create({
data: {
email,
feedbackType: report.feedbackType,
userAgent: report.userAgent,
messageId: report.messageId || "",
sourceIP: report.sourceIP,
arrivalDate: new Date(report.arrivalDate),
rawReport: rawEmail,
},
});
console.log(`[FBL] Suppressed ${email} due to spam complaint`);
}Complaint rate monitoring
Track complaint rate as a core KPI. Here's a query to calculate it:
import { prisma } from "@/lib/db";
export async function calculateComplaintRate(days: number = 7) {
const since = new Date();
since.setDate(since.getDate() - days);
// Total emails sent
const totalSent = await prisma.emailLog.count({
where: {
sentAt: { gte: since },
status: "delivered",
},
});
// Total complaints received
const totalComplaints = await prisma.complaintLog.count({
where: {
createdAt: { gte: since },
},
});
const complaintRate = totalSent > 0 ? (totalComplaints / totalSent) * 100 : 0;
return {
totalSent,
totalComplaints,
complaintRate: complaintRate.toFixed(4),
period: `Last ${days} days`,
status: complaintRate < 0.1 ? "healthy" : complaintRate < 0.3 ? "warning" : "critical",
};
}
// Example usage
const stats = await calculateComplaintRate(7);
console.log(`Complaint rate: ${stats.complaintRate}%`);
console.log(`Status: ${stats.status}`);
// Alert if threshold exceeded
if (stats.complaintRate >= 0.3) {
// Send alert to ops team
await sendSlackAlert({
channel: "#ops-alerts",
message: `⚠️ CRITICAL: Complaint rate is ${stats.complaintRate}% (threshold: 0.3%).`,
});
}Segment analysis
Don't just track overall complaint rate. Segment by:
- Email template. Which emails get the most complaints? (Password resets? Marketing?)
- User cohort. New users vs long-term users? Free vs paid?
- Sending time. Do late-night sends trigger more complaints?
- Subject line. Are certain subjects flagged as spam more often?
import { prisma } from "@/lib/db";
export async function analyzeComplaintsByTemplate(days: number = 30) {
const since = new Date();
since.setDate(since.getDate() - days);
const complaints = await prisma.complaintLog.findMany({
where: { createdAt: { gte: since } },
include: {
emailLog: {
select: {
templateName: true,
subject: true,
},
},
},
});
// Group by template
const byTemplate: Record<string, number> = {};
for (const complaint of complaints) {
const template = complaint.emailLog?.templateName || "unknown";
byTemplate[template] = (byTemplate[template] || 0) + 1;
}
// Sort by complaint count
const sorted = Object.entries(byTemplate)
.sort(([, a], [, b]) => b - a)
.map(([template, count]) => ({ template, count }));
return sorted;
}
// Example output:
// [
// { template: "password-reset", count: 12 },
// { template: "marketing-newsletter", count: 45 },
// { template: "welcome", count: 3 },
// ]How to reduce complaint rate
Complaints happen when users feel your email is unwanted,unexpected, or unsafe. Here's how to fix each:
1. Make unsubscribe obvious
Hidden unsubscribe links drive spam reports. Users hit spam because it's easier than hunting for your unsubscribe link.
- Add List-Unsubscribe header. Gmail and Outlook show a native unsubscribe button. See our List-Unsubscribe guide.
- Footer unsubscribe. Clear link in every email footer (legally required for marketing).
- One-click opt-out. Don't require login or confirmation pages. Just unsubscribe them.
2. Send expected emails only
Unexpected emails = spam reports. If users didn't explicitly opt in, don't send.
- Double opt-in for marketing. Confirm subscriptions before adding to lists.
- Clear value proposition. At signup, tell users exactly what emails they'll receive.
- No "we noticed you visited" emails. Browsing your site doesn't grant consent.
3. Don't look like phishing
Security-critical emails (password resets, verifications) often get marked as spam because users assume they're phishing.
- Consistent sender name. Always send from the same recognizable name (e.g., "YourApp Security").
- Match your brand. Logo, colors, and voice should match your app.
- Clear context. "You requested a password reset" (not "Click here now").
4. Respect engagement signals
If users never open your emails, stop sending. Low engagement = future spam folder placement.
- Monitor open rates. Track per-user engagement.
- Re-engagement campaigns. "Still want to hear from us?" before suppressing.
- Suppress inactive users. No opens in 6 months? Stop sending.
5. Frequency caps
Sending 5 emails in one day? Users will hit spam to make it stop.
- Daily send limits. Max 1-2 marketing emails per day.
- User preferences. Let users choose frequency (daily vs weekly digest).
- Batch transactional emails. Combine notifications into a single summary email.
FBL email pipeline (full workflow)
Here's how to wire FBL reports into your email system end-to-end:
- Register for FBL with Yahoo, Microsoft, etc.
- Create dedicated email. fbl@yourapp.com (or similar)
- Set up email forwarding. Forward FBL reports to a webhook or email parser
- Parse ARF reports. Extract recipient email and message ID
- Suppress complainers. Update database to never send again
- Log complaints. Track for analytics and debugging
- Alert on thresholds. Slack/email when complaint rate exceeds 0.1%
import { NextRequest, NextResponse } from "next/server";
import { handleComplaintReport } from "@/lib/fbl/complaint-handler";
export async function POST(req: NextRequest) {
const rawEmail = await req.text();
try {
await handleComplaintReport(rawEmail);
return NextResponse.json({ success: true });
} catch (error) {
console.error("[FBL Webhook] Error processing complaint:", error);
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
}
}Production checklist
Before you launch transactional email, verify:
- ✅ FBL registration with Yahoo, Microsoft
- ✅ Google Postmaster Tools set up for aggregate spam tracking
- ✅ ARF report parser (automated processing)
- ✅ Automatic suppression on complaints
- ✅ Complaint rate monitoring (alert if > 0.1%)
- ✅ List-Unsubscribe header (reduce complaints)
- ✅ Clear unsubscribe link in email footer
- ✅ Engagement-based pruning (suppress inactive users)