Skip to content
Start with 100 free verification credits
Qualisend
All articles
Engineering / July 8, 2026

Building email bounce classification

3 minutes read

Qualisend team
A code window classifying a bounce code into a hard-bounce, suppress action

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 each reason through ACTION_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.

Your reputation, protected.

Clean your first list in minutes. 100 free credits, no card required.

Get started