DEV Community

Kumar Deepanshu
Kumar Deepanshu

Posted on Originally published at lumbox.co

Recording a Bounce Is Not the Same as Honouring It

Originally published at lumbox.co

Almost every email integration gets the first half right. The provider posts a bounce or a complaint to a webhook, you parse it, you write a row. There is a nice chart in the dashboard showing bounces over time.

Then someone sends to that address again next week, and it bounces again, and you record a second row.

The table is not the defense

A suppression list only does anything if something reads it at send time. If your bounce data exists purely to render a graph, you have built observability, not protection. Every hard bounce you have ever recorded is an address you are still willing to mail.

The fix is not complicated, but it has to go in the right place:

async function sendEmail(input) {
  const blocked = await isSuppressed(input.orgId, input.to);
  if (blocked) {
    throw new SendBlocked("recipient is suppressed: " + blocked.reason);
  }
  return provider.send(input);
}
Enter fullscreen mode Exit fullscreen mode

The important word is every. Find the function that every send path funnels through and put the check there. If you have a single-send route, a batch route, a reply route and an auto-responder, and you add the check to three of them, you have not added the check.

Batch sends need their own path

A batch of 100 recipients where 4 are suppressed should send 96 messages, not throw. That means the batch path needs a filter rather than a guard:

const { allowed, dropped } = await filterSuppressed(orgId, recipients);
if (allowed.length === 0) throw new AllRecipientsSuppressed();
await provider.sendBatch(allowed);
return { sent: allowed.length, suppressed: dropped };

Enter fullscreen mode Exit fullscreen mode

Report the dropped ones back to the caller. Silently sending to fewer people than they asked for is how you lose their trust in the numbers.

What to suppress, and what not to

Suppress on permanent failures and on complaints. Do not suppress on transient ones.

  • Hard bounce (mailbox does not exist, domain does not exist): suppress permanently.
  • Complaint (recipient marked it as spam): suppress permanently, and treat it as more serious than a bounce. Complaint thresholds are twenty times tighter than bounce thresholds.
  • Soft bounce (mailbox full, greylisted, temporary failure): do not suppress. Retry. Suppressing these throws away deliverable addresses.
  • Blocked by reputation: this is about you, not the recipient. Suppressing the address hides your own problem and loses a real contact.

The race nobody plans for

Provider webhooks are fast. Sometimes they arrive before your own database has finished writing the row for the message you just sent. If your handler looks up the message by provider ID and drops the event when there is no match, you will silently lose exactly the bounces that happened quickest, which are disproportionately the hard ones.

Do not drop unmatched events. Fall back to attributing them by the sending address, or queue them for a retry. An unmatched bounce is still a bounce.

One check worth writing

The regression that matters is not "does suppression work", it is "does every path use it". A test that enumerates your send routes and asserts each one refuses a suppressed recipient will outlive any individual bug fix.

Top comments (0)