# Bounce categorization beyond hard and soft

- **Pattern:** `ab-000942` (`deliverability-engineering.bounce-fbl.bounce-categorization`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000942
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000942)

## Why it matters

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.

## Severity rationale

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.

## Remediation

Parse diagnostic codes at webhook ingestion time and store the sub-category alongside the raw bounce event. Route each category to the appropriate handler:

```ts
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`.

## Detection

- **ID:** `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:

  ```ts
  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'
  }
  ```

## External references

- external RFC3463 — https://www.rfc-editor.org/rfc/rfc3463
- iso-25010 reliability.fault-tolerance

Taxons: error-resilience

HTML version: https://auditbuffet.com/patterns/ab-000942
