Hard and soft are a coarse-grained taxonomy that obscures the correct remediation for each failure type. A mailbox-full bounce (452, 552) is a temporary capacity issue — retry in 24 hours. A content-blocked bounce (550 containing 'spam') means a campaign is triggering spam filters — retry will not help; the campaign content must change. A domain-not-found bounce means the sending domain itself is rejected — DMARC or IP reputation requires review. RFC3463 defines enhanced status codes specifically to enable this differentiation. Collapsing all bounces to hard/soft forces engineering to run manual queries against diagnostic messages to recover information that should be structured in the data model.
Medium because routing all hard bounces to the same suppression flow masks content-blocked and policy-rejected failures that require campaign or infrastructure review rather than address suppression.
Parse diagnostic codes at webhook ingestion time and store the sub-category alongside the raw bounce event. Route each category to the appropriate handler:
type BounceSubCategory =
| 'address_not_found' | 'mailbox_full'
| 'content_blocked' | 'policy_rejected'
| 'domain_not_found' | 'unknown'
export function categorizeBounce(
code: number,
diagnostic: string
): BounceSubCategory {
const msg = diagnostic.toLowerCase()
if (code === 452 || code === 552 || msg.includes('mailbox full') || msg.includes('over quota'))
return 'mailbox_full'
if (msg.includes('does not exist') || msg.includes('no such user'))
return msg.includes('domain') ? 'domain_not_found' : 'address_not_found'
if (msg.includes('spam') || msg.includes('blocked'))
return msg.includes('policy') ? 'policy_rejected' : 'content_blocked'
return 'unknown'
}
Store subCategory on the BounceEvent model. Suppress on address_not_found and domain_not_found; retry with delay on mailbox_full; alert the deliverability team and pause the campaign on content_blocked or policy_rejected.
ID: deliverability-engineering.bounce-fbl.bounce-categorization
Severity: medium
What to look for: Count all bounce sub-categories defined in the system. Check how bounce events are stored and categorized. Look beyond a simple hard/soft boolean — does the system distinguish sub-categories: mailbox full (452, 552), domain not found (NXDOMAIN, 550 no such domain), content-blocked (550 spam content), policy-rejected (550 policy violation, 521)? Sub-categories inform different remediation actions: mailbox full warrants retry, content-blocked warrants campaign review, domain not found warrants suppression.
Pass criteria: Bounce events are stored with at least 4 sub-categories: address-not-found, mailbox-full, content-blocked, and policy-rejected. Different sub-categories trigger different handling logic.
Fail criteria: Bounce events are categorized only as hard or soft with no sub-type. All hard bounces trigger the same action regardless of cause.
Skip (N/A) when: The ESP provides only hard/soft categorization with no sub-category data in webhook payloads.
Detail on fail: "Bounces categorized only as hard/soft — content-blocked bounces (which require campaign review) treated the same as address-not-found bounces (which require suppression)" or "No bounce sub-categorization in database schema"
Remediation: Store bounce sub-categories and route handling accordingly:
type BounceSubCategory =
| 'address_not_found'
| 'mailbox_full'
| 'content_blocked'
| 'policy_rejected'
| 'domain_not_found'
| 'unknown'
export function categorizeBounce(
bounceCode: number,
diagnosticCode: string
): BounceSubCategory {
const msg = diagnosticCode.toLowerCase()
if (bounceCode === 452 || bounceCode === 552 || msg.includes('mailbox full') || msg.includes('over quota')) {
return 'mailbox_full'
}
if (msg.includes('does not exist') || msg.includes('no such user') || bounceCode === 550) {
if (msg.includes('domain') || msg.includes('host')) return 'domain_not_found'
return 'address_not_found'
}
if (msg.includes('spam') || msg.includes('blocked') || msg.includes('rejected')) {
return msg.includes('policy') ? 'policy_rejected' : 'content_blocked'
}
return 'unknown'
}