Skip to main content

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:

  1. Email Sent - EmailEngine submits the email to the account's email server (Gmail, Outlook, etc.)
  2. messageSent Event - The account's server accepts the email and EmailEngine triggers messageSent
  3. MTA Delivery Attempt - The account's Mail Transfer Agent (MTA) attempts to deliver to the recipient's mail server (MX)
  4. Recipient MX Rejects - If the recipient server rejects the email (user unknown, mailbox full, etc.)
  5. 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
  6. EmailEngine Detects Bounce - EmailEngine monitors the inbox and detects the bounce email by recognizing common bounce message patterns
  7. Bounce Parsed - EmailEngine parses the bounce email to extract the bounced recipient address, error message, and original message details
  8. messageBounce Event - If EmailEngine can identify which original message bounced (via Message-ID or other headers), it triggers the messageBounce webhook

When a bounce is detected, EmailEngine:

  1. Parse Bounce Email - Extract bounce information from the human-readable bounce message
  2. Match Original Message - Link bounce to sent message via Message-ID (when available)
  3. Send Webhook - Deliver messageBounce webhook to your application
  4. 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

FieldDescription
bounceMessageEmailEngine ID of the bounce notification message
recipientEmail address that bounced
actionBounce action, one of the codes above; failed for a rejected delivery
response.messageError message from receiving server
response.statusEnhanced status code (e.g., 5.1.1)
response.sourceThe diagnostic type from the report's Diagnostic-Code: field, usually smtp. Absent when the bounce was parsed from message text
response.categoryML-classified bounce category (see below)
response.recommendedActionSuggested action to take
response.blocklistBlocklist details if applicable
response.retryAfterSuggested retry delay in seconds
mtaHostname of the server that reported the failure, lowercased: Remote-MTA, or Reporting-MTA when the report carries no remote one
queueIdQueue ID from the sending MTA (X-Postfix-Queue-Id)
messageIdMessage-ID of the original sent email
messageHeadersHeaders 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:

  1. When you send, store the messageId returned by the submit endpoint against whatever your application calls a recipient - a contact row, a campaign entry, a support ticket.
  2. When a messageBounce webhook arrives, look up that same value in data.messageId and 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)

CodeDescription
5.1.1Bad destination mailbox address (user unknown)
5.1.2Bad destination system address (domain not found)
5.2.1Mailbox disabled, not accepting messages
5.2.2Mailbox full
5.4.4Unable to route (no DNS records)
5.7.1Delivery not authorized, message refused

4.x.x - Temporary Failures (Soft Bounces)

CodeDescription
4.2.1Mailbox temporarily unavailable
4.2.2Mailbox full (temporary - might clear space)
4.4.1Connection timed out
4.7.1Delivery 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:

CategoryDescriptionRecommended Action
user_unknownRecipient email address does not existRemove from mailing list
invalid_addressBad email syntax or domain not foundRemove from mailing list
mailbox_disabledAccount suspended or disabledRemove from mailing list
mailbox_fullOver quota, storage exceededRetry later
greylistingTemporary rejection, retry laterRetry after delay
rate_limitedToo many connections or messagesRetry after delay
server_errorTimeout or connection failedRetry later
ip_blacklistedSender IP on a blocklist (RBL)Use different sending IP
domain_blacklistedSender domain on a blocklistFix DNS/authentication
auth_failureDMARC, SPF, or DKIM failureFix email authentication
relay_deniedRelaying not permittedFix mail server config
spam_blockedMessage detected as spamReview email content
policy_blockedLocal policy rejectionReview and contact admin
virus_detectedInfected content detectedRemove malicious content
geo_blockedGeographic/country-based rejectionUse different sending IP
unknownUnclassified bounce typeReview manually

The response.recommendedAction field tells you how to handle the bounce:

ActionDescriptionWhen Used
removeRemove email from all mailing listsInvalid addresses, disabled accounts
retryRetry delivery after a delayTemporary issues like greylisting, rate limits
reviewManual review requiredSpam blocks, policy rejections
fix_configurationFix sender configurationAuthentication failures, relay issues
retry_different_ipRetry from another IP addressIP blocklist issues
remove_contentRemove problematic contentVirus 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.

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);
}
}
Classification is advisory

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