emailverifier.dev
Posts

Hard Bounce vs Soft Bounce: Read the Failure Evidence First

| 6 min read | Usama Ejaz
An email delivery path splits into a looping 4.x.x retry route and a stopped 5.x.x route, while the address card remains a separate decision.

A hard bounce is a permanent delivery failure. A soft bounce is a temporary one. In standards terms, that usually means a 5.x.x enhanced status for hard and 4.x.x for soft.

That definition is useful, but it is too small for production logic. A bounce describes what happened to one message. It does not always tell you that the address is dead.

I would store two decisions separately: what happens to this delivery, and what happens to the address.

Why is one bounce label not enough?

RFC 3463 defines a 4.x.x result as a persistent transient failure. The message is valid, but a temporary condition caused delay or abandonment. A future send may work.

A 5.x.x result is permanent. Resending the same message to the same destination is unlikely to work without changing the message or destination.

So what is missing? Cause.

5.1.1 means the destination mailbox does not exist. 5.7.1 means delivery was refused by a security or policy rule. Both are permanent for the current attempt, but only one directly says the mailbox address is bad. The distinction comes from the subject and detail digits in the enhanced status code.

A mailbox-full result makes the opposite point. RFC 3463 defines X.2.2 as a persistent transient failure because the recipient may free space. “Mailbox problem” is not the same as “mailbox missing.”

What does the delivery status notification actually say?

A machine-readable delivery status notification, or DSN, carries per-recipient fields. The three I care about are Action, Status, and Diagnostic-Code. RFC 3464 defines their roles.

Final-Recipient: rfc822; person@example.com
Action: failed
Status: 5.7.1
Diagnostic-Code: smtp; 550 5.7.1 Delivery not authorized

This is a hypothetical DSN. It says that the reporting mail server stopped trying and that a policy refused the message. It does not say person@example.com is nonexistent.

Action answers what the reporting MTA did. failed means it abandoned delivery, while delayed means it will keep trying. Status gives the transport-independent class and cause. Diagnostic-Code preserves the mail transport's more specific reply when available.

Well, surely Action and the first digit of Status are redundant? They are not. RFC 3464 explicitly allows Action: delayed with 4.x.x while the server is retrying, then Action: failed with the same 4.x.x after the retry window expires.

The condition stayed temporary in nature. The message still reached a terminal state.

Which state should your application change?

I use two axes because they stop a delivery event from silently becoming an account decision.

Evidence Message state Address state
delayed with 4.x.x Retrying Keep active
failed with 4.x.x Stopped Watch; a later send may work
failed with 5.1.1 Stopped Suspend sending and request correction
failed with 5.7.1 Stopped Keep separate from bad-address suppression; review policy
Missing or unrecognized fields Unknown Review without declaring the address invalid

“Suspend sending” is deliberate. It is reversible, and it is not the same as deleting the user's account or erasing the address from your audit trail.

There is a fair objection: a clean 5.1.1 is the standard code for a nonexistent mailbox. Why not remove the address immediately?

For the sending queue, suppressing it is reasonable. For the underlying customer record, one event deserves less authority. RFC 3464 warns mailing-list software not to delete a subscriber based on a single DSN, even a permanent one, because temporary conditions can still produce permanent-looking reports. Repeated, consistent failures are stronger evidence.

How can you turn those fields into code?

This small Node.js classifier takes an already parsed per-recipient DSN record. It deliberately returns a message state and an address state instead of one hardBounce boolean.

export function classifyDsn({ action, status }) {
  const normalizedAction = String(action || '').toLowerCase()
  const match = String(status || '').match(/^([245])\.(\d{1,3})\.(\d{1,3})$/)

  if (!match) {
    return { message: 'unknown', address: 'review' }
  }

  const statusClass = Number(match[1])
  const subject = Number(match[2])
  const detail = Number(match[3])

  if (normalizedAction === 'delayed') {
    return { message: 'retrying', address: 'active' }
  }

  if (normalizedAction !== 'failed') {
    return { message: 'observed', address: 'active' }
  }

  if (statusClass === 4) {
    return { message: 'stopped', address: 'watch' }
  }

  if (statusClass === 5 && subject === 1 && detail === 1) {
    return { message: 'stopped', address: 'suspend' }
  }

  if (statusClass === 5 && subject === 7) {
    return { message: 'stopped', address: 'policy_review' }
  }

  if (statusClass === 5) {
    return { message: 'stopped', address: 'cause_review' }
  }

  return { message: 'observed', address: 'active' }
}

The first branch matters. An MTA can stop retrying a message while retaining a 4.x.x status. The current delivery is over, but the address has not become a confirmed permanent failure.

The policy branch matters too. A content rule, sender-reputation rule, authentication problem, or recipient policy can reject a message that another route or corrected configuration would deliver. Do not poison your bad-address list with that event.

What evidence should you trust before suppressing?

First, correlate the event with a message you actually sent and the envelope recipient you recorded. A DSN can report an original recipient and a final recipient, and forwarding can make those different.

Second, accept bounce events only from your mail provider's authenticated webhook or from a controlled return path that you validate. RFC 3464 is blunt here: DSNs can be forged as easily as ordinary email. An unauthenticated inbound message should not be able to suppress one of your customers.

Third, retain the raw provider event or DSN fields beside your normalized state. You will need the original evidence when a provider uses an unfamiliar code or your mapping changes.

Finally, make suppression scoped. A policy failure may suppress one campaign, sender, or route. A repeated 5.1.1 may suspend all mail to the address until it is corrected or reconfirmed. Those are different controls.

Where does pre-send verification fit?

Pre-send verification and bounce handling observe different moments.

An email verification check can catch invalid syntax, unusable mail routing, a clear mailbox rejection, or risk signals before you send. It can also return unknown when the receiving system withholds a conclusive answer. The current emailverifier.dev API preserves deliverable, risky, undeliverable, and unknown as separate statuses.

A bounce is post-send evidence about a particular delivery. Even if a mailbox previously accepted an SMTP recipient probe, the later message can still fail because of policy, content, quota, routing, or a change at the destination. SMTP acceptance never guaranteed delivery.

The SMTP verification walkthrough owns the pre-send command sequence. Here, the safe rule is shorter: verify before sending when the decision is valuable, then let authenticated delivery evidence update a separate state afterward.

Stop the message. Preserve the reason. Change the address only when the reason supports it.

Continue reading