emailverifier.dev
Posts

Email Suppression Lists: A Safe Event-Driven Model

| 7 min read | Usama Ejaz
Four email events enter a suppression ledger; one stream continues, one is blocked, and a destination failure closes both.

An email suppression list should be a send-control layer over an event log. It should not be a deleted-contact table, and it should not be one Boolean shared by marketing, receipts, password resets, and every other message.

I would store each reason separately, give it an explicit scope, and calculate the send decision when a message enters the queue. An unsubscribe might stop marketing while leaving a purchase receipt available. A bad destination mailbox should stop both.

The key rule is simple: preserve the evidence behind the block. Without it, a later import, verification result, or CRM edit will eventually switch the address back on for the wrong reason.

Make the send gate a projection

A contact record answers who the person is. A subscription record answers what they requested. A suppression event answers why a specific class of mail should stop. Combining those jobs in a field such as email_disabled = true loses too much context.

For each event, keep at least:

  • a stable event ID for idempotency;
  • the recipient key and tenant or sender ID;
  • the affected stream, list, or channel;
  • the reason, source, and event time;
  • an evidence reference such as a DSN, feedback report, or consent record;
  • the policy version used to calculate the decision.

The queue should ask the projection one narrow question: is this recipient allowed for this sender and message stream? It should not inspect a stale CRM flag or rebuild the history during delivery.

Scope the reason before blocking mail

Event Useful default scope What it does not prove
Unsubscribe The selected list or marketing streams The mailbox is invalid
Complaint Marketing from the responsible sender Every transactional message is unwanted
5.1.1 destination failure All mail to the failed address The whole domain is unusable
4.2.2 mailbox full Retry or temporary pause policy The address is permanently dead
5.7.1 policy refusal The affected message or sending path The recipient mailbox does not exist

RFC 3463, section 3.2 defines X.1.1 as a bad destination mailbox address and reserves it for permanent failures. The same RFC describes 4.X.X as persistent transient failure where a later send might succeed.

A first digit of 5 still does not mean “delete this contact.” RFC 5321, section 4.2.5 says a 5yz reply stops retries for the same message. A 5.7.1 policy rejection might reflect sender authorization or filtering, not a missing recipient.

M3AAWG makes the operational distinction explicit. Its Sender Best Common Practices version 4.0, updated August 27, 2026, says temporary failures must not be treated as permanent. It also says a clear “user unknown” response should suppress the address from future mailings. For recurring failures, it recommends a documented multi-campaign policy rather than one generic bounce rule.

Reduce events instead of overwriting a Boolean

I ran the following dependency-free Node.js fixture. It keeps marketing and transactional decisions separate, ignores a duplicate event ID, clears an unsubscribe only after a recorded opt-in, and refuses to treat a policy bounce as an address-wide failure.

const destinationFailures = new Set([
  '5.1.1', '5.1.2', '5.1.3', '5.1.6'
])

const initial = {
  seenEventIds: [],
  reasons: [],
  review: [],
  marketing: 'allow',
  transactional: 'allow'
}

function project(state) {
  const blocksMarketing = state.reasons.some(reason =>
    ['unsubscribe', 'complaint', 'destination_failure']
      .includes(reason.type)
  )
  const blocksTransactional = state.reasons.some(
    reason => reason.type === 'destination_failure'
  )

  return {
    ...state,
    marketing: blocksMarketing ? 'suppress' : 'allow',
    transactional: blocksTransactional ? 'suppress' : 'allow'
  }
}

function applyEvent(state, event) {
  if (state.seenEventIds.includes(event.id)) return state

  const next = {
    ...state,
    seenEventIds: [...state.seenEventIds, event.id],
    reasons: [...state.reasons],
    review: [...state.review]
  }

  if (event.type === 'unsubscribe') {
    next.reasons.push({ type: 'unsubscribe', eventId: event.id })
  }

  if (event.type === 'complaint') {
    next.reasons.push({ type: 'complaint', eventId: event.id })
  }

  if (event.type === 'bounce') {
    if (destinationFailures.has(event.enhancedStatus)) {
      next.reasons.push({
        type: 'destination_failure',
        eventId: event.id,
        enhancedStatus: event.enhancedStatus
      })
    } else {
      next.review.push({
        eventId: event.id,
        enhancedStatus: event.enhancedStatus
      })
    }
  }

  if (event.type === 'confirmed_opt_in' && event.consentId) {
    next.reasons = next.reasons.filter(
      reason => reason.type !== 'unsubscribe'
    )
  }

  return project(next)
}

const events = [
  { id: 'evt-1', type: 'bounce', enhancedStatus: '4.2.2' },
  { id: 'evt-2', type: 'unsubscribe' },
  { id: 'evt-2', type: 'unsubscribe' },
  { id: 'evt-3', type: 'confirmed_opt_in', consentId: 'consent-42' },
  { id: 'evt-4', type: 'complaint' },
  { id: 'evt-5', type: 'bounce', enhancedStatus: '5.7.1' },
  { id: 'evt-6', type: 'bounce', enhancedStatus: '5.1.1' }
]

let state = structuredClone(initial)

for (const event of events) {
  state = applyEvent(state, event)
  console.log(JSON.stringify([
    event.enhancedStatus ?? event.type,
    state.marketing,
    state.transactional
  ]))
}

The executed event sequence was:

[
  ["4.2.2", "allow", "allow"],
  ["unsubscribe", "suppress", "allow"],
  ["unsubscribe", "suppress", "allow"],
  ["confirmed_opt_in", "allow", "allow"],
  ["complaint", "suppress", "allow"],
  ["5.7.1", "suppress", "allow"],
  ["5.1.1", "suppress", "suppress"]
]

Each row contains the incoming event followed by the marketing and transactional decisions. The fixture is intentionally conservative. A production reducer also needs tenant scope, stream IDs, event timestamps, authorization checks, and a policy for repeated temporary failures.

Process complaints and unsubscribes as durable events

RFC 6449, section 4.3.1 recommends treating complaint feedback like an unsubscribe and preventing more mail of the same type to the same recipient. It also warns against suppressing one address across an entire email service provider when the responsible sender or list is the proper scope.

For one-click unsubscribe, RFC 8058, section 3.1 requires enough information in the HTTPS URI to identify both recipient and list. Use an opaque, hard-to-forge token. Store the resulting unsubscribe as an event with its list scope instead of mutating a contact row with no audit trail.

Events also solve ordering problems. If a webhook is delivered twice, the event ID makes the second copy harmless. If an older CRM export arrives tomorrow, its contact state cannot erase a newer complaint. If two systems disagree, the evidence and timestamps remain available for review.

Do not let verification erase suppression

Email verification and suppression answer different questions. A current deliverable result is positive address evidence. It does not restore consent, cancel a complaint, or prove the person requesting re-entry controls the account.

For a new or changed address, I would run the send gate in this order:

  1. Build the recipient key using the application’s documented email storage policy.
  2. Load the suppression projection for the sender and message stream.
  3. Stop when a matching suppression reason blocks the stream.
  4. When the stream remains allowed, verify a new or changed address before the first costly send.
  5. Store the verification evidence beside the suppression decision, not on top of it.

The emailverifier.dev API returns deliverable, undeliverable, risky, or unknown plus an action and signals. The production result guide keeps those outcomes separate from network errors. An unknown result should stay unknown, and a risk signal should stay evidence rather than proof of abuse.

What about an accidental complaint?

RFC 6449 acknowledges accidental complaints. Its practical default is still to stop the matching commercial mail. Transactional messages tied to a service need a narrower policy, because a billing notice and a newsletter do not serve the same purpose.

A new confirmed opt-in may clear an unsubscribe for a named stream. It should carry fresh consent evidence and create a new event. My reducer does not let it silently clear a complaint or destination failure. Those need separate review or a corrected address.

This is why evidence beats one global flag. The system remains strict about unwanted mail without turning every bounce, complaint, or old import into an irreversible account decision.

Test the gate from ingestion to queue

Before launch, I would replay four cases through the real event pipeline:

  1. Deliver the same unsubscribe event twice and confirm one stored reason.
  2. Send a 4.2.2 event and confirm no permanent address block.
  3. Send a 5.7.1 policy event and confirm it enters review without suppressing every stream.
  4. Race a complaint against a queued campaign and confirm the queue checks suppression immediately before delivery.

The finished design should answer every blocked send with a reason, scope, source event, and policy version. If it only answers “suppressed: true,” the hard part has been hidden rather than solved.

Continue reading