SMTP 5xx codes (550, 551, 553, 554) are permanent rejections from the receiving server, per RFC5321-s3.6 — the address does not exist, the mailbox is closed, or delivery is permanently refused. Continuing to send to permanently invalid addresses does two things: it increases your hard bounce rate (ISPs track this per IP and per domain), and it confirms to ISPs that you are not maintaining list hygiene. Google and Yahoo both publish hard bounce rate thresholds; exceeding them triggers increasingly aggressive filtering that affects deliverable addresses in the same campaign. Suppression must be immediate, not batched.
High because accumulating hard bounces from repeated sends to invalid addresses directly degrades IP and domain reputation metrics tracked by major mailbox providers, leading to inbox filtering that affects legitimate recipients.
Handle hard bounce events in your ESP webhook immediately — upsert a suppression record and check it on every subsequent send attempt. The suppression check must happen before queuing the message, not at delivery time:
export async function handleBounceEvent(event: BounceEvent): Promise<void> {
const isPermanent =
event.type === 'hard' ||
event.bounceCode >= 550 ||
event.reason?.toLowerCase().includes('does not exist')
if (isPermanent) {
await db.suppression.upsert({
where: { email: event.email.toLowerCase() },
update: { reason: 'hard_bounce', updatedAt: new Date() },
create: {
email: event.email.toLowerCase(),
reason: 'hard_bounce',
source: 'bounce_webhook',
suppressedAt: new Date()
}
})
}
}
export async function isAddressSuppressed(email: string): Promise<boolean> {
return (await db.suppression.findUnique({
where: { email: email.toLowerCase() }
})) !== null
}
Call isAddressSuppressed as part of the pre-send check, before the job enters the queue. Cross-reference deliverability-engineering.bounce-fbl.soft-bounce-escalation — soft bounces also require escalation logic to eventually reach permanent suppression.
ID: deliverability-engineering.bounce-fbl.hard-bounce-suppression
Severity: high
What to look for: Enumerate all bounce webhook handlers and count the event types processed. Search for bounce webhook handlers (SendGrid, Mailgun, SES SNS bounce notification, Postmark). Find the code that processes a hard bounce event (SMTP 5xx codes: 550, 551, 553, 554 — or ESP classifications like type: "hard", bounceType: "Permanent"). Check what happens next: does the recipient's email address get added to a permanent suppression list immediately, preventing any future sends to that address?
Pass criteria: Hard bounce events trigger immediate insertion into at least 1 permanent suppression table. Quote the actual webhook handler route found. Subsequent send attempts check this table and skip suppressed addresses.
Fail criteria: Hard bounces are logged but no suppression list is updated. Or the suppression record is created but send logic does not query it before dispatching.
Skip (N/A) when: The project uses an ESP with managed suppression (SendGrid Global Suppression, Mailgun Bounce List) and explicitly relies on the ESP to enforce suppression — not the application layer.
Cross-reference: Check deliverability-engineering.bounce-fbl.soft-bounce-escalation — soft bounces should escalate to hard-bounce-equivalent suppression after threshold.
Detail on fail: "Hard bounce events are logged to the database but no suppression list entry is created — the system will continue sending to permanently invalid addresses, damaging sending reputation" or "Suppression table exists but send logic does not check it before dispatch"
Remediation: Create a suppression record on hard bounce and check it before sending:
// Bounce webhook handler
export async function handleBounceEvent(event: BounceEvent): Promise<void> {
const isPermanent =
event.type === 'hard' ||
event.bounceCode >= 550 ||
event.reason?.toLowerCase().includes('does not exist')
if (isPermanent) {
await db.suppression.upsert({
where: { email: event.email },
update: { reason: 'hard_bounce', updatedAt: new Date() },
create: {
email: event.email.toLowerCase(),
reason: 'hard_bounce',
source: 'bounce_webhook',
suppressedAt: new Date()
}
})
console.log(`Hard bounce: ${event.email} permanently suppressed`)
}
}
// Pre-send check
export async function isAddressSuppressed(email: string): Promise<boolean> {
const record = await db.suppression.findUnique({
where: { email: email.toLowerCase() }
})
return record !== null
}