messageComplaint
The messageComplaint webhook event is triggered when EmailEngine detects a feedback loop (FBL) complaint in a monitored mailbox. This event helps you identify when recipients have marked your emails as spam or unwanted, allowing you to maintain sender reputation and comply with email best practices.
When This Event is Triggered
The messageComplaint event fires when:
- An ARF (Abuse Reporting Format) complaint message is received in a monitored mailbox
- EmailEngine successfully parses the complaint and extracts recipient information
- The complaint contains identifiable information about the original message
EmailEngine analyzes incoming messages for FBL complaint patterns including:
- Standard ARF (RFC 5965) abuse reports with
message/feedback-reportcontent type - Hotmail/Outlook.com complaint notifications from
staff@hotmail.com - Microsoft FBL reports via the JMRP (Junk Mail Reporting Program)
- ISP feedback loop messages containing embedded original headers
Common Use Cases
- List hygiene - Automatically unsubscribe users who report spam
- Reputation management - Track complaint rates to maintain good sender reputation
- Deliverability monitoring - Identify content or sending patterns causing complaints
- Compliance - Fulfill legal requirements to honor unsubscribe requests
- Analytics - Build dashboards showing complaint trends by campaign or domain
- Blocklist prevention - Address issues before reaching ISP complaint thresholds
Payload Schema
Top-Level Fields
| Field | Type | Required | Description |
|---|---|---|---|
serviceUrl | string | No | The configured EmailEngine service URL |
account | string | Yes | Account ID that received the complaint message |
date | string | Yes | ISO 8601 timestamp when the webhook was generated |
event | string | Yes | Event type, always "messageComplaint" for this event |
eventId | string | Yes | Unique identifier for this webhook delivery |
data | object | Yes | Complaint data object (see below) |
Complaint Data Fields (data object)
| Field | Type | Required | Description |
|---|---|---|---|
complaintMessage | string | Yes | EmailEngine message ID of the complaint notification email itself |
arf | object | Yes | ARF (Abuse Reporting Format) data extracted from the complaint |
headers | object | No | Headers from the original complained-about message |
ARF Object Structure
The arf object contains complaint metadata extracted from the ARF report:
| Field | Type | Description |
|---|---|---|
source | string | Source of the complaint (e.g., "Hotmail", ISP name) |
feedbackType | string | Type of feedback report (typically "abuse") |
abuseType | string | Specific type of abuse reported (typically "complaint") |
originalMailFrom | string | Return-Path/envelope sender of the original message |
originalRcptTo | array | Email addresses of recipients who complained |
sourceIp | string | IP address of the sending server for the original message |
arrivalDate | string | ISO 8601 timestamp when the original message arrived |
userAgent | string | User agent/software that generated the report |
version | string | ARF format version |
reportingMta | string | MTA that generated the complaint report |
Headers Object Structure
The headers object contains headers from the original complained-about message (when available):
| Field | Type | Description |
|---|---|---|
messageId | string | Message-ID header of the original message |
from | string | From address of the original message |
to | array | To addresses of the original message |
cc | array | CC addresses of the original message |
subject | string | Subject line of the original message |
date | string | ISO 8601 timestamp from the original message Date header |
Example Payload
{
"serviceUrl": "https://emailengine.example.com",
"account": "user123",
"date": "2025-10-17T07:06:11.697Z",
"event": "messageComplaint",
"data": {
"complaintMessage": "AAAAAQAABzE",
"arf": {
"source": "Hotmail",
"feedbackType": "abuse",
"abuseType": "complaint",
"originalMailFrom": "sender@example.com",
"originalRcptTo": ["recipient@hotmail.com"],
"sourceIp": "203.0.113.42",
"arrivalDate": "2025-10-17T07:06:35.021Z"
},
"headers": {
"messageId": "<57f34982-43cc-6534-40f9-0f72f1c8a158@example.com>",
"from": "sender@example.com",
"to": ["recipient@hotmail.com"],
"subject": "Your weekly newsletter",
"date": "2025-10-17T07:06:34.000Z"
}
}
}
ARF Parsing Details
EmailEngine parses Abuse Reporting Format (ARF) messages according to RFC 5965 and supports proprietary formats from major providers. This section documents the parsing logic and supported formats.
Supported ARF Formats
EmailEngine detects and parses these complaint formats:
RFC 5965 Standard ARF
The standard abuse reporting format used by most ISPs:
- Content-Type:
message/feedback-report - Contains structured fields like
Feedback-Type,User-Agent,Version - Includes embedded original message as
message/rfc822attachment
Microsoft/Hotmail JMRP
Microsoft's Junk Mail Reporting Program (JMRP) format:
- Sent from
staff@hotmail.com - Uses
X-HmXmrOriginalRecipientheader for original recipient - May include
X-Sender-IPandX-MS-Exchange-CrossTenant-OriginalArrivalTimeheaders - EmailEngine automatically sets
source: "Hotmail"for these reports
Parsed Content Types
EmailEngine examines message attachments for these content types:
| Content-Type | Purpose |
|---|---|
message/feedback-report | Standard ARF feedback report with structured fields |
message/rfc822 | Complete original message (headers and body) |
message/rfc822-headers | Original message headers only |
text/rfc822-headers | Original message headers (alternate type) |
text/rfc822-header | Original message headers (singular form) |
Extracted ARF Fields
From the feedback report (message/feedback-report), EmailEngine extracts:
| ARF Field | Webhook Field | Description |
|---|---|---|
Feedback-Type | feedbackType | Type of report (abuse, fraud, virus, other) |
User-Agent | userAgent | Software that generated the report |
Version | version | ARF format version (typically "1") |
Original-Envelope-Id | originalEnvelopeId | Original SMTP envelope ID |
Original-Mail-From | originalMailFrom | Return-Path of the original message |
Original-Rcpt-To | originalRcptTo | Recipients who complained (array) |
Abuse-Type | abuseType | Specific abuse type (complaint, spam) |
Arrival-Date | arrivalDate | When original message arrived (ISO 8601) |
Received-Date | arrivalDate | Alternative to Arrival-Date |
Reporting-MTA | reportingMta | MTA that generated the report |
Source-IP | sourceIp | IP address of the original sender |
Source | source | Name/identifier of the reporting system |
Subscription-Link | subscriptionLink | Link to subscription preferences |
Incidents | incidents | Number of abuse incidents |
Extracted Original Message Headers
From the embedded original message, EmailEngine extracts:
| Header | Webhook Field | Description |
|---|---|---|
Message-ID | headers.messageId | Message-ID of the complained message |
From | headers.from | Sender address |
To | headers.to | Recipient addresses (array) |
CC | headers.cc | CC addresses (array) |
BCC | headers.bcc | BCC addresses (array) |
Subject | headers.subject | Subject line (MIME decoded) |
Date | headers.date | Message date (ISO 8601) |
Return-Path | arf.originalMailFrom | Envelope sender (fallback) |
X-Sender-IP | arf.sourceIp | Sender IP (Microsoft-specific) |
X-HmXmrOriginalRecipient | arf.originalRcptTo | Original recipient (Hotmail) |
X-MS-Exchange-CrossTenant-OriginalArrivalTime | arf.arrivalDate | Arrival time (Microsoft) |
Example: Standard ARF Message
A standard RFC 5965 complaint message looks like this:
From: abusedesk@isp.example.com
To: abuse@sender.example.com
Subject: FBL Notification
Content-Type: multipart/report; report-type=feedback-report
--boundary
Content-Type: text/plain
This is a spam complaint from one of our users.
--boundary
Content-Type: message/feedback-report
Feedback-Type: abuse
User-Agent: ISP-FBL/1.0
Version: 1
Original-Mail-From: newsletter@sender.example.com
Original-Rcpt-To: user@isp.example.com
Arrival-Date: Thu, 17 Oct 2025 07:06:35 +0000
Source-IP: 203.0.113.42
Reporting-MTA: dns; mx.isp.example.com
--boundary
Content-Type: message/rfc822
From: newsletter@sender.example.com
To: user@isp.example.com
Subject: Your Weekly Newsletter
Date: Thu, 17 Oct 2025 07:06:34 +0000
Message-ID: <abc123@sender.example.com>
[Original message content...]
--boundary--
EmailEngine parses this into:
{
"arf": {
"feedbackType": "abuse",
"userAgent": "ISP-FBL/1.0",
"version": "1",
"originalMailFrom": "newsletter@sender.example.com",
"originalRcptTo": ["user@isp.example.com"],
"arrivalDate": "2025-10-17T07:06:35.000Z",
"sourceIp": "203.0.113.42",
"reportingMta": "dns; mx.isp.example.com"
},
"headers": {
"messageId": "<abc123@sender.example.com>",
"from": "newsletter@sender.example.com",
"to": ["user@isp.example.com"],
"subject": "Your Weekly Newsletter",
"date": "2025-10-17T07:06:34.000Z"
}
}
Example: Hotmail/JMRP Complaint
Microsoft's JMRP complaints have a specific structure:
From: staff@hotmail.com
To: abuse@sender.example.com
Subject: complaint about message from 203.0.113.42
Content-Type: multipart/report; report-type=feedback-report
--boundary
Content-Type: text/plain
This is a complaint from a Hotmail user.
--boundary
Content-Type: message/rfc822-headers
Return-Path: <newsletter@sender.example.com>
From: newsletter@sender.example.com
To: user@hotmail.com
Subject: Your Weekly Newsletter
Date: Thu, 17 Oct 2025 07:06:34 +0000
Message-ID: <abc123@sender.example.com>
X-HmXmrOriginalRecipient: user@hotmail.com
X-Sender-IP: 203.0.113.42
X-MS-Exchange-CrossTenant-OriginalArrivalTime: 17 Oct 2025 07:06:35.0210 (UTC)
--boundary--
EmailEngine automatically detects the staff@hotmail.com sender and sets:
{
"arf": {
"source": "Hotmail",
"feedbackType": "abuse",
"abuseType": "complaint",
"originalMailFrom": "newsletter@sender.example.com",
"originalRcptTo": ["user@hotmail.com"],
"sourceIp": "203.0.113.42",
"arrivalDate": "2025-10-17T07:06:35.021Z"
},
"headers": {
"messageId": "<abc123@sender.example.com>",
"from": "newsletter@sender.example.com",
"to": ["user@hotmail.com"],
"subject": "Your Weekly Newsletter",
"date": "2025-10-17T07:06:34.000Z"
}
}
Handling Incomplete ARF Data
Not all complaint messages contain complete ARF data. EmailEngine extracts whatever information is available:
- If
Original-Mail-Fromis missing, it falls back toReturn-Pathfrom the embedded message - If
Original-Rcpt-Tois missing, it checksX-HmXmrOriginalRecipientfor Hotmail complaints - Dates are parsed flexibly and converted to ISO 8601 format
- Invalid dates are omitted from the output
Your webhook handler should check for the presence of fields before using them:
async function handleComplaint(event) {
const { arf, headers } = event.data;
// Always check if fields exist
const complainants = arf?.originalRcptTo || [];
const originalMessageId = headers?.messageId;
const source = arf?.source || 'unknown';
if (complainants.length === 0) {
// Cannot identify complainant - log for manual review
console.warn('Complaint received but no recipient identified');
return;
}
// Process complaint...
}
Understanding FBL Complaints
What Causes Complaints
Recipients mark emails as spam for various reasons:
- Unwanted emails - User no longer wants to receive messages
- Forgotten subscription - User doesn't remember signing up
- Difficult unsubscribe - Easier to click "spam" than find unsubscribe link
- Misleading content - Email doesn't match user expectations
- Excessive frequency - Too many emails sent too often
Feedback Loop Sources
Major ISPs operate feedback loop programs:
| Provider | Program | Notes |
|---|---|---|
| Microsoft (Outlook, Hotmail) | JMRP/SNDS | Reports from staff@hotmail.com |
| Yahoo | CFL | Complaint Feedback Loop |
| AOL | FBL | Feedback Loop program |
| Comcast | FBL | Requires registration |
Complaint Rate Thresholds
ISPs monitor complaint rates and may block senders who exceed thresholds:
| Provider | Recommended Maximum | Risk Threshold |
|---|---|---|
| General guideline | < 0.1% | > 0.3% |
| Microsoft | < 0.1% | > 0.5% |
| < 0.1% | > 0.3% |
Handling the Event
Basic Handler
async function handleMessageComplaint(event) {
const { account, data } = event;
console.log(`Complaint detected for account ${account}:`);
console.log(` Complaint Message ID: ${data.complaintMessage}`);
if (data.arf) {
console.log(` Source: ${data.arf.source}`);
console.log(` Feedback Type: ${data.arf.feedbackType}`);
console.log(` Complainants: ${data.arf.originalRcptTo?.join(', ')}`);
}
if (data.headers) {
console.log(` Original Message-ID: ${data.headers.messageId}`);
console.log(` Original Subject: ${data.headers.subject}`);
}
// Process the complaint
await processComplaint(data);
}
Automatic Unsubscribe
async function processComplaint(complaintData) {
const { arf, headers } = complaintData;
// Get complainant email addresses
const complainants = arf?.originalRcptTo || [];
for (const email of complainants) {
// Unsubscribe the user from all mailing lists
await db.subscriptions.updateMany(
{ email: email.toLowerCase() },
{
$set: {
subscribed: false,
unsubscribeReason: 'spam_complaint',
unsubscribedAt: new Date(),
complaintSource: arf?.source
}
}
);
// Add to suppression list to prevent future sends
await db.suppressionList.upsert({
email: email.toLowerCase(),
reason: 'complaint',
source: arf?.source,
originalMessageId: headers?.messageId,
createdAt: new Date()
});
console.log(`Unsubscribed ${email} due to spam complaint`);
}
}
Tracking Complaint Metrics
async function trackComplaintMetrics(event) {
const { account, data } = event;
// Extract campaign info from original message if available
const campaignId = extractCampaignId(data.headers?.messageId);
await metrics.increment('email.complaints', {
account,
source: data.arf?.source || 'unknown',
feedbackType: data.arf?.feedbackType || 'unknown',
campaign: campaignId
});
// Calculate and alert on complaint rate
const stats = await getRecentStats(account);
const complaintRate = stats.complaints / stats.totalSent;
if (complaintRate > 0.001) { // 0.1%
await sendAlert({
type: 'high_complaint_rate',
account,
rate: complaintRate,
threshold: 0.001
});
}
}
function extractCampaignId(messageId) {
// Extract campaign ID from Message-ID if your system embeds it
const match = messageId?.match(/campaign-([a-z0-9]+)/i);
return match ? match[1] : null;
}
Correlating with Original Message
async function correlateComplaint(complaintData) {
const { headers, arf } = complaintData;
// Try to find the original sent message in your database
let originalMessage = null;
if (headers?.messageId) {
originalMessage = await db.sentMessages.findOne({
messageId: headers.messageId
});
}
if (!originalMessage && arf?.originalMailFrom) {
// Fallback: search by sender and approximate time
originalMessage = await db.sentMessages.findOne({
from: arf.originalMailFrom,
sentAt: {
$gte: new Date(Date.parse(arf.arrivalDate) - 86400000), // 1 day before
$lte: new Date(arf.arrivalDate)
}
});
}
if (originalMessage) {
// Link complaint to original message for analytics
await db.sentMessages.updateOne(
{ _id: originalMessage._id },
{
$set: { complained: true },
$push: {
complaints: {
date: new Date(),
recipients: arf?.originalRcptTo,
source: arf?.source
}
}
}
);
}
return originalMessage;
}
Best Practices
- Immediately unsubscribe complainants - Honor complaints instantly to maintain sender reputation
- Add to suppression list - Prevent sending to complainants across all campaigns
- Monitor complaint rates - Track rates per campaign and overall; investigate spikes
- Review complained content - Analyze what content generates complaints
- Improve list acquisition - Ensure clear opt-in and set expectations
- Make unsubscribe easy - Prominent, one-click unsubscribe reduces complaints
- Respect frequency preferences - Allow users to control email frequency
- Clean inactive subscribers - Remove users who haven't engaged in 6+ months
Related Events
- messageBounce - Triggered when a bounce notification is received
- messageFailed - Triggered when EmailEngine fails to deliver a queued email
- messageSent - Triggered when a message is successfully sent
- messageNew - The complaint notification also triggers this event
See Also
- Webhooks Overview - Complete webhook setup guide
- Sending Emails - How to send emails through EmailEngine
- Settings API - Configure webhook settings