Bounce Detection and Handling
EmailEngine automatically detects and tracks email bounces, providing detailed bounce information through webhooks and message listings. Learn how to handle bounce notifications and maintain email list hygiene.
Overview
Email bounces occur when a sent message cannot be delivered to the recipient. EmailEngine monitors incoming emails for bounce responses and extracts detailed bounce information, including:
- Recipient address that bounced
- Bounce type (hard bounce, soft bounce)
- Error message from the receiving server
- Original message headers and content
- SMTP status codes and diagnostic information
Note: EmailEngine does not use VERP addresses. It detects bounces by parsing standard bounce message formats sent by mail servers.
How Bounce Detection Works
When you send an email through EmailEngine:
- Email Sent - EmailEngine submits the email to the account's email server (Gmail, Outlook, etc.)
- messageSent Event - The account's server accepts the email and EmailEngine triggers
messageSent - MTA Delivery Attempt - The account's Mail Transfer Agent (MTA) attempts to deliver to the recipient's mail server (MX)
- Recipient MX Rejects - If the recipient server rejects the email (user unknown, mailbox full, etc.)
- Bounce Email Generated - The sender's MTA generates a bounce response email (a human-readable informational message explaining the delivery failure) and sends it to the sender's inbox
- EmailEngine Detects Bounce - EmailEngine monitors the inbox and detects the bounce email by recognizing common bounce message patterns
- Bounce Parsed - EmailEngine parses the bounce email to extract the bounced recipient address, error message, and original message details
- messageBounce Event - If EmailEngine can identify which original message bounced (via Message-ID or other headers), it triggers the
messageBouncewebhook
When a bounce is detected, EmailEngine:
- Parse Bounce Email - Extract bounce information from the human-readable bounce message
- Match Original Message - Link bounce to sent message via Message-ID (when available)
- Send Webhook - Deliver
messageBouncewebhook to your application - Add to Message - Attach bounce data to sent message in listings
Bounce Detection Flow
Bounce Types
Hard Bounces
Permanent delivery failures that will not succeed on retry:
- User unknown - Email address doesn't exist
- Domain not found - Domain doesn't exist or has no MX records
- Account disabled - Recipient account has been closed
Example error messages:
550 No such user here
550 5.1.1 User unknown
550 Requested action not taken: mailbox unavailable
Soft Bounces
Temporary delivery failures that might succeed on retry:
- Mailbox temporarily unavailable - Server issues
- Mailbox full - Recipient's mailbox is over quota; the classifier below files this under
retry - Message too large - Exceeds recipient's size limit
- Spam filter rejection - Message blocked by content filter
- Rate limiting - Too many messages sent too quickly
Example error messages:
450 4.2.1 The user you are trying to contact is receiving mail too quickly
452 4.2.2 The email account that you tried to reach is over quota
Bounce Action Codes
The action value comes from the Action: field of an RFC 3464 delivery status report, or is set to failed when a bounce is recognized from the message text alone:
failed- Permanent failure (hard bounce)delayed- Temporary failure (soft bounce)delivered,relayed,expanded- reports that are not failures
A multipart/report; report-type=delivery-status message that arrives in the inbox and reports delivered or delayed is attached to the message as a deliveryReport instead of being processed as a bounce, so no messageBounce webhook is sent for it.
Sending Email and Tracking Bounces
Send an Email
Send an email and capture the Message-ID:
curl -XPOST "https://emailengine.example.com/v1/account/john@example.com/submit" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": {
"address": "unknown@ethereal.email"
},
"subject": "Test message",
"text": "This email should bounce!"
}'
Response includes the Message-ID needed to track bounces:
{
"response": "Queued for delivery",
"messageId": "<3e013ba5-3bd2-a5f6-b102-5997c7d4d843@example.com>",
"sendAt": "2024-10-13T12:10:34.845Z",
"queueId": "183cc1a89ddfe365bbb"
}
Save this messageId value - you'll need it to correlate bounce notifications.
Receive Bounce Webhook
When the email bounces, EmailEngine sends a messageBounce webhook:
{
"serviceUrl": "https://emailengine.example.com",
"account": "john@example.com",
"date": "2024-10-13T12:10:40.980Z",
"event": "messageBounce",
"data": {
"bounceMessage": "AAAADAAAByc",
"recipient": "unknown@ethereal.email",
"action": "failed",
"response": {
"source": "smtp",
"message": "550 No such user here",
"status": "5.0.0"
},
"mta": "mx.ethereal.email",
"queueId": "B7D3F8220C",
"messageId": "<3e013ba5-3bd2-a5f6-b102-5997c7d4d843@example.com>",
"messageHeaders": {
"return-path": ["<john@example.com>"],
"content-type": ["text/plain; charset=utf-8"],
"from": ["John Doe <john@example.com>"],
"to": ["unknown@ethereal.email"],
"subject": ["Test message"],
"message-id": ["<3e013ba5-3bd2-a5f6-b102-5997c7d4d843@example.com>"],
"date": ["Wed, 12 Oct 2022 12:10:34 +0000"]
}
}
}
Webhook Payload Fields
| Field | Description |
|---|---|
bounceMessage | EmailEngine ID of the bounce notification message |
recipient | Email address that bounced |
action | Bounce action, one of the codes above; failed for a rejected delivery |
response.message | Error message from receiving server |
response.status | Enhanced status code (e.g., 5.1.1) |
response.source | The diagnostic type from the report's Diagnostic-Code: field, usually smtp. Absent when the bounce was parsed from message text |
response.category | ML-classified bounce category (see below) |
response.recommendedAction | Suggested action to take |
response.blocklist | Blocklist details if applicable |
response.retryAfter | Suggested retry delay in seconds |
mta | Hostname of the server that reported the failure, lowercased: Remote-MTA, or Reporting-MTA when the report carries no remote one |
queueId | Queue ID from the sending MTA (X-Postfix-Queue-Id) |
messageId | Message-ID of the original sent email |
messageHeaders | Headers of the original message when the report quoted them back, otherwise null |
The webhook is sent only when the report yields all three of action, recipient and messageId; a bounce EmailEngine cannot tie to a sent message is logged but not reported. The category, recommendedAction, blocklist and retryAfter fields are added by the classifier described below and are absent when classification fails. The messageBounce webhook reference is the complete field list.
Checking Bounce Information
Via Message Listing
Bounce information is also attached to sent messages in folder listings.
List sent messages:
curl "https://emailengine.example.com/v1/account/john@example.com/messages?path=Sent" \
-H "Authorization: Bearer YOUR_TOKEN"
Messages with bounces include a bounces array:
{
"total": 472,
"page": 0,
"pages": 24,
"messages": [
{
"id": "AAAABgAAAdk",
"uid": 473,
"date": "2024-10-13T12:10:34.000Z",
"subject": "Test message",
"from": {
"name": "John Doe",
"address": "john@example.com"
},
"to": [
{
"address": "unknown@ethereal.email"
}
],
"bounces": [
{
"message": "AAAADAAAByc",
"recipient": "unknown@ethereal.email",
"action": "failed",
"response": {
"message": "550 No such user here",
"status": "5.0.0"
},
"date": "2024-10-13T12:10:40.003Z"
}
]
}
]
}
Why an array? Each email can have multiple recipients, and each can bounce with different errors.
The bounces array is attached for IMAP accounts only, and carries the bounce's message ID, recipient, action, response.message, response.status and the date the bounce was detected. The classifier fields are only in the webhook.
Via API Query
Get bounce information for a specific message:
curl "https://emailengine.example.com/v1/account/john@example.com/message/AAAABgAAAdk" \
-H "Authorization: Bearer YOUR_TOKEN"
Response includes full bounce details in the bounces array.
Handling Bounces in Your Application
Bounce handling comes down to correlating two events that can arrive minutes or hours apart:
- When you send, store the
messageIdreturned by the submit endpoint against whatever your application calls a recipient - a contact row, a campaign entry, a support ticket. - When a
messageBouncewebhook arrives, look up that same value indata.messageIdand act on the record you find.
EmailEngine does not use VERP return paths, so the Message-ID is the join key. It survives the round trip because the bouncing MTA quotes the original headers back, and EmailEngine reads them out of the bounce report.
// 1. On send: remember which recipient this Message-ID belongs to
const res = await fetch(`${EE_URL}/v1/account/${account}/submit`, {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: { address: recipient }, subject, text })
});
const { messageId } = await res.json();
await db.sentMessages.insert({ messageId, recipient });
// 2. On webhook: resolve it back
app.post('/webhooks', async (req, res) => {
res.sendStatus(200); // acknowledge first, process afterwards
if (req.body.event !== 'messageBounce') return;
const { messageId, recipient, response } = req.body.data;
const sent = await db.sentMessages.findOne({ messageId });
await recordBounce(sent?.recipient || recipient, response);
});
Acknowledge the webhook before doing the work. A delivery that fails or exceeds the per-attempt timeout is retried up to 10 times with exponential backoff, so a slow handler turns one bounce into several deliveries of the same event. Make recordBounce() idempotent.
What recordBounce() should do depends on why the message bounced, which is what the classification below tells you.
SMTP Status Codes
Understanding SMTP status codes helps interpret bounces:
5.x.x - Permanent Failures (Hard Bounces)
| Code | Description |
|---|---|
| 5.1.1 | Bad destination mailbox address (user unknown) |
| 5.1.2 | Bad destination system address (domain not found) |
| 5.2.1 | Mailbox disabled, not accepting messages |
| 5.2.2 | Mailbox full |
| 5.4.4 | Unable to route (no DNS records) |
| 5.7.1 | Delivery not authorized, message refused |
4.x.x - Temporary Failures (Soft Bounces)
| Code | Description |
|---|---|
| 4.2.1 | Mailbox temporarily unavailable |
| 4.2.2 | Mailbox full (temporary - might clear space) |
| 4.4.1 | Connection timed out |
| 4.7.1 | Delivery temporarily suspended (greylisting) |
Common Bounce Messages
# Hard bounces
550 5.1.1 User unknown
550 5.1.2 Host or domain name not found
550 5.2.1 Mailbox disabled
550 5.2.2 Mailbox full
550 5.7.1 Message rejected due to content
# Soft bounces
450 4.2.1 Mailbox temporarily unavailable
452 4.2.2 Mailbox full
451 4.4.1 Connection timeout
450 4.7.1 Greylisting in effect
ML-Powered Bounce Classification
Since v2.60.0, EmailEngine classifies the server's error text with a bundled machine learning model (@postalsys/bounce-classifier), going beyond the hard/soft distinction. The classification runs in the main EmailEngine process, and the worker that found the bounce waits at most two minutes for it; if it fails or times out, the bounce is reported without the classifier fields.
Classification Categories
The response.category field provides one of these classifications:
| Category | Description | Recommended Action |
|---|---|---|
user_unknown | Recipient email address does not exist | Remove from mailing list |
invalid_address | Bad email syntax or domain not found | Remove from mailing list |
mailbox_disabled | Account suspended or disabled | Remove from mailing list |
mailbox_full | Over quota, storage exceeded | Retry later |
greylisting | Temporary rejection, retry later | Retry after delay |
rate_limited | Too many connections or messages | Retry after delay |
server_error | Timeout or connection failed | Retry later |
ip_blacklisted | Sender IP on a blocklist (RBL) | Use different sending IP |
domain_blacklisted | Sender domain on a blocklist | Fix DNS/authentication |
auth_failure | DMARC, SPF, or DKIM failure | Fix email authentication |
relay_denied | Relaying not permitted | Fix mail server config |
spam_blocked | Message detected as spam | Review email content |
policy_blocked | Local policy rejection | Review and contact admin |
virus_detected | Infected content detected | Remove malicious content |
geo_blocked | Geographic/country-based rejection | Use different sending IP |
unknown | Unclassified bounce type | Review manually |
Recommended Actions
The response.recommendedAction field tells you how to handle the bounce:
| Action | Description | When Used |
|---|---|---|
remove | Remove email from all mailing lists | Invalid addresses, disabled accounts |
retry | Retry delivery after a delay | Temporary issues like greylisting, rate limits |
review | Manual review required | Spam blocks, policy rejections |
fix_configuration | Fix sender configuration | Authentication failures, relay issues |
retry_different_ip | Retry from another IP address | IP blocklist issues |
remove_content | Remove problematic content | Virus detection |
Blocklist Detection
When a bounce indicates a blocklist issue, the response.blocklist object provides details:
{
"response": {
"message": "550 Service unavailable; Client host [1.2.3.4] blocked using zen.spamhaus.org",
"category": "ip_blacklisted",
"recommendedAction": "retry_different_ip",
"blocklist": {
"name": "Spamhaus ZEN",
"type": "ip"
}
}
}
The blocklist.type indicates whether the issue is with your IP address (ip), your domain (domain), or a URI mentioned in the message content (uri). If the bounce message references multiple blocklists, the response contains a lists array instead, where each entry has name and type fields: {"lists": [{"name": "...", "type": "..."}, ...]}.
Retry Timing
When bounce messages contain timing hints (e.g., "try again in 5 minutes"), the response.retryAfter field provides the suggested delay in seconds:
{
"response": {
"message": "450 4.7.1 Greylisted, please try again in 300 seconds",
"category": "greylisting",
"recommendedAction": "retry",
"retryAfter": 300
}
}
Acting on the Classification
Branch on recommendedAction rather than on category. The action set is small and stable, while categories are added as the classifier learns new bounce shapes, and an unrecognized category would otherwise fall through your logic silently.
- Node.js
- Python
async function recordBounce(recipient, response = {}) {
const category = response.category || 'unknown';
switch (response.recommendedAction || 'review') {
case 'remove':
// Permanent: the address will never accept mail
await db.contacts.update({ email: recipient }, { status: 'bounced', category });
break;
case 'retry':
// Temporary: greylisting, rate limits, a full mailbox
await scheduleRetry(recipient, response.retryAfter || 3600);
break;
case 'retry_different_ip':
// The sending IP is blocklisted, the address is fine
await queueForAlternateIP(recipient, response.blocklist);
break;
case 'fix_configuration':
// SPF/DKIM/DMARC or relay problem, no per-recipient action helps
await alertAdmin(category, response.message);
break;
case 'remove_content':
await quarantineCampaign(recipient, response.message);
break;
default:
await flagForReview(recipient, category, response.message);
}
}
def record_bounce(recipient, response=None):
response = response or {}
category = response.get('category', 'unknown')
action = response.get('recommendedAction', 'review')
if action == 'remove':
# Permanent: the address will never accept mail
contacts.update(recipient, status='bounced', category=category)
elif action == 'retry':
# Temporary: greylisting, rate limits, a full mailbox
schedule_retry(recipient, response.get('retryAfter', 3600))
elif action == 'retry_different_ip':
# The sending IP is blocklisted, the address is fine
queue_for_alternate_ip(recipient, response.get('blocklist'))
elif action == 'fix_configuration':
# SPF/DKIM/DMARC or relay problem, no per-recipient action helps
alert_admin(category, response.get('message'))
elif action == 'remove_content':
quarantine_campaign(recipient, response.get('message'))
else:
flag_for_review(recipient, category, response.get('message'))
category and recommendedAction come from a machine learning model reading the server's error text, and they are absent entirely if classification fails. Always default to a review path, and never delete a contact on a single remove without also checking response.status for a 5.x.x code if the record matters.
See Also
- messageBounce webhook - Full payload reference for the bounce event
- messageDeliveryError and messageFailed - Failures EmailEngine sees while submitting, before a message ever reaches the recipient's server
- Suppression Lists - Stop sending to addresses that have already bounced
- Email Authentication Testing - Diagnose the SPF, DKIM, and DMARC problems behind
fix_configurationbounces - Webhook Overview - Delivery, retries, and routing