A bounce is a mail server telling you why it wouldn't accept your message, in a three-digit code. Classifying those codes correctly is the difference between suppressing a genuinely dead address and throwing away one that would have delivered on a retry. This guide builds a bounce classifier, and shows how to run the same classification before you send instead of after.
The short answer#
The first digit of the SMTP code decides the action: 5xx is a permanent
failure — suppress it — and 4xx is temporary — retry it. The refinement that
matters is the enhanced status code: a 5.7.x is a policy or reputation block
(the server refusing you, not judging the mailbox), which you must not treat as
a dead address. A verifier applies the same logic to a mailbox probe, so you can
classify an address before a real bounce ever happens.
The two dimensions of a bounce#
Every bounce has two properties worth separating:
- Permanence — is this final (
5xx, a hard bounce) or temporary (4xx, a soft bounce)? This decides suppress vs retry. - Subject — is the server judging the mailbox ("no such user") or your sender ("your IP is blocklisted")? A permanent rejection of your sender looks like a hard bounce but must not be handled like one — suppressing the recipient fixes nothing, because the problem is your reputation.
The SMTP response codes guide covers the full grammar; classification is about turning it into an action.
A bounce classifier#
Here is a classifier that reads the SMTP code and the optional enhanced code and
returns an action. It handles the case most naive classifiers miss — the policy
block dressed up as a 5xx:
/**
* Classify a bounce into an action.
* @param {number} code basic SMTP code, e.g. 550
* @param {string} [enhanced] enhanced status code, e.g. "5.1.1"
*/
export function classifyBounce(code, enhanced) {
const cls = Math.floor(code / 100); // 2, 4, or 5
// Policy / reputation block: the server is refusing YOU, not the mailbox.
// Suppressing the recipient would be treating the wrong problem.
if (enhanced?.startsWith("5.7") || enhanced?.startsWith("4.7")) {
return { type: "policy", action: "review", note: "sender reputation, not the mailbox" };
}
if (cls === 5) {
// Permanent: no such user, disabled, or the domain won't relay.
return { type: "hard", action: "suppress", reason: "rejected_email" };
}
if (cls === 4) {
// Temporary: full mailbox, greylisting, or a rate limit.
const fullMailbox = code === 452 || enhanced === "4.2.2";
return {
type: "soft",
action: "retry",
reason: fullMailbox ? "full_mailbox" : "timeout",
};
}
return { type: "unknown", action: "retry" };
}
classifyBounce(550, "5.1.1"); // { type: "hard", action: "suppress" }
classifyBounce(452); // { type: "soft", action: "retry", reason: "full_mailbox" }
classifyBounce(554, "5.7.1"); // { type: "policy", action: "review" } ← don't suppress
The rule embedded here: suppress only clean mailbox rejections. A 5.7.x
means fix your sending reputation; suppressing the recipient hides the signal
without solving anything.
Classify before the bounce, not after#
The catch with bounce classification is that it's reactive — you only learn an address is dead by sending to it and damaging your reputation in the process. Verification runs the same classification on an SMTP probe instead of a real send, so you get the verdict without the bounce. The verdict and reason codes map straight onto the same actions:
const ACTION_BY_REASON = {
accepted_email: "keep", // deliverable
rejected_email: "suppress", // hard bounce confirmed by probe
invalid_email: "suppress", // bad syntax
invalid_domain: "suppress", // no mail route
low_quality: "suppress", // disposable
low_deliverability: "segment", // catch-all or full mailbox — send carefully
timeout: "retry", // greylisted / no answer yet
unavailable_smtp: "retry", // couldn't reach the server
unknown: "retry",
};
// From a verification result row:
const action = ACTION_BY_REASON[row.reason] ?? "retry";
Same decision tree, moved earlier in time. The addresses that would have
hard-bounced come back undeliverable and get suppressed before the send; the
unknown ones get a retry instead of a guess.
Wiring it into the loop#
Whichever direction you run it, the output feeds one place — your suppression list:
- Reactive: parse bounce webhooks from your ESP, run
classifyBounce, and suppress the hard ones while scheduling retries for the soft ones. - Proactive: run a bulk verification job, pull the results
with
include=results, map eachreasonthroughACTION_BY_REASON, and apply the actions — the export-verify-suppress loop the ESP cleaning guides walk through per platform.
Keeping both is ideal: verify proactively to prevent most bounces, and classify the residual bounces reactively to catch the addresses that decayed since.
Frequently asked questions#
What's the difference between a hard and soft bounce, in code?#
The SMTP class: 5xx is a hard bounce (permanent — suppress), 4xx is a soft
bounce (temporary — retry). The one trap is a 5.7.x enhanced code, which is a
permanent policy block against your sender rather than a dead mailbox — classify
it separately and fix reputation instead of suppressing the recipient.
How many times should I retry a soft bounce before suppressing?#
There's no universal number, but a common pattern is to retry a soft-bouncing address across a few consecutive sends and escalate it to suppressed if it never recovers — which is roughly what mailbox providers and ESPs do internally. Full mailboxes often clear on their own, so soft bounces deserve patience a hard bounce doesn't.
Can I classify addresses before sending to them?#
Yes — that's what verification is. An SMTP mailbox probe produces the same
signals a bounce would, mapped to a deliverable | risky | undeliverable | unknown verdict with a reason code, so you can suppress the dead addresses before
they ever bounce. The ACTION_BY_REASON map above turns those verdicts into the
same suppress/retry/keep actions.
Want the verdicts to classify against? The free plan includes 100
credits, and the API reference documents the reason codes and the
/jobs results endpoint the proactive loop reads from.