Soft bounces (4xx SMTP responses) indicate temporary conditions — a full mailbox, a server outage, a transient policy block. A single soft bounce warrants a retry. But an address that soft-bounces three or more times within a week is not temporarily unavailable: it is either abandoned, over quota indefinitely, or rejecting your domain specifically. RFC5321-s4.2.1 establishes that repeated temporary failures should eventually be treated as permanent. Without escalation logic, the system will retry indefinitely, accumulating poor delivery metrics and flagging the sending IP as one that ignores bounce signals.
High because repeated soft bounces to the same address without escalation inflate failed-delivery metrics and signal poor list hygiene to mailbox providers, contributing to IP reputation degradation over time.
Track soft bounces per address in a separate event table and escalate to permanent suppression after 3 events within a 7-day rolling window:
const SOFT_BOUNCE_THRESHOLD = 3
const WINDOW_DAYS = 7
export async function handleSoftBounce(email: string, reason: string): Promise<void> {
await db.softBounceEvent.create({
data: { email: email.toLowerCase(), reason, bouncedAt: new Date() }
})
const windowStart = new Date(Date.now() - WINDOW_DAYS * 86_400_000)
const recentCount = await db.softBounceEvent.count({
where: { email: email.toLowerCase(), bouncedAt: { gte: windowStart } }
})
if (recentCount >= SOFT_BOUNCE_THRESHOLD) {
await db.suppression.upsert({
where: { email: email.toLowerCase() },
update: { reason: 'soft_bounce_escalated', updatedAt: new Date() },
create: {
email: email.toLowerCase(),
reason: 'soft_bounce_escalated',
source: 'soft_bounce_escalation',
suppressedAt: new Date()
}
})
}
}
Ensure the pre-send isAddressSuppressed check covers escalated soft bounces as well as hard bounces — they write to the same suppression table.
ID: deliverability-engineering.bounce-fbl.soft-bounce-escalation
Severity: high
What to look for: Count all soft bounce handling code paths. Examine soft bounce handling (4xx codes, type: "soft", bounceType: "Temporary" from ESP webhooks). Check whether soft bounces are counted per address and whether there is escalation logic: after 3 or more soft bounces within 7 days, the address is suppressed. Look for a softBounceCount column on a user or contact table, or a separate soft bounce event log with count queries.
Pass criteria: Soft bounces increment a counter per email address. When the counter reaches a threshold of at least 3 within a rolling window of no more than 7 days, the address is treated as a hard bounce and permanently suppressed.
Fail criteria: Soft bounces are logged but no escalation logic promotes repeated soft bounces to suppression. Or soft bounce events are silently ignored.
Skip (N/A) when: The project uses an ESP with built-in soft bounce escalation and the application does not process bounce events directly.
Detail on fail: "Soft bounces are logged but no escalation threshold exists — repeatedly bouncing addresses continue to receive sends, accumulating poor delivery metrics" or "Soft bounce webhook handler is a no-op"
Remediation: Track and escalate soft bounces:
const SOFT_BOUNCE_THRESHOLD = 3
const SOFT_BOUNCE_WINDOW_DAYS = 7
export async function handleSoftBounce(email: string, reason: string): Promise<void> {
const windowStart = new Date(Date.now() - SOFT_BOUNCE_WINDOW_DAYS * 24 * 60 * 60 * 1000)
// Record this soft bounce
await db.softBounceEvent.create({
data: { email: email.toLowerCase(), reason, bouncedAt: new Date() }
})
// Count recent soft bounces for this address
const recentCount = await db.softBounceEvent.count({
where: {
email: email.toLowerCase(),
bouncedAt: { gte: windowStart }
}
})
if (recentCount >= SOFT_BOUNCE_THRESHOLD) {
// Escalate to permanent suppression
await db.suppression.upsert({
where: { email: email.toLowerCase() },
update: { reason: 'soft_bounce_escalated', updatedAt: new Date() },
create: {
email: email.toLowerCase(),
reason: 'soft_bounce_escalated',
source: 'soft_bounce_escalation',
suppressedAt: new Date()
}
})
}
}