Skip to main content

Bounce and Complaint Handling for Transactional Senders

Build a suppression-list service, classify hard vs soft bounces by SMTP code, and process ARF complaints from SES, SendGrid, Postmark, and Mailgun to protect sender reputation.

Every transactional message that bounces or generates a spam complaint is a signal that mailbox providers measure against your sending identity. Sending to a known-dead mailbox a second time, or repeatedly mailing someone who already pressed the spam button, is the fastest way to lose inbox placement. This guide builds the core defense for any sender on Transactional Email Delivery Infrastructure: a suppression-list service that records every bounce and complaint and is consulted before every outbound send, plus the classification logic that decides whether a failure means retry later or never send here again.

Why unhandled bounces and complaints destroy reputation

Mailbox providers — Gmail, Outlook.com, Yahoo, Apple iCloud — track per-domain and per-IP metrics: the proportion of mail that bounces, the proportion that recipients mark as spam, and the proportion sent to spam-trap addresses. When those ratios cross internal thresholds, the provider throttles you (deferring messages with 4.x.x temporary failures), routes you to the spam folder, or blocks you outright. Gmail's 2024 bulk-sender rules made one number explicit: keep the user-reported spam rate below 0.3%, and ideally under 0.1%, measured in Postmaster Tools. A bounce rate creeping toward 5% draws the same scrutiny on most receiving systems.

The trap is that these failures are invisible at send time. Your ESP API returns 202 Accepted, the message leaves your queue, and the actual rejection arrives seconds to hours later as an asynchronous bounce or, days later, as a complaint. If your system does not capture and act on those asynchronous signals, it will cheerfully keep mailing addresses that no longer exist and recipients who have already reported you — driving both metrics up with every campaign.

Hard bounces vs soft bounces, and the SMTP codes behind them

A hard bounce is a permanent failure: the address does not exist, the domain has no mail exchanger, or the recipient's server has explicitly rejected the identity. These map to SMTP 5.x.x reply codes (a 550 5.1.1 "user unknown" is the canonical example). A hard bounce means stop sending to this address forever — it must go on the suppression list immediately.

A soft bounce is a transient failure: the mailbox is full, the receiving server is temporarily deferring, or a greylisting policy is in effect. These map to 4.x.x reply codes (e.g. 452 4.2.2 "mailbox full", 421 4.7.0 "try again later"). A soft bounce means retry with backoff — but only a bounded number of times, after which a persistently soft-bouncing address should be suppressed too.

The asynchronous failure itself is delivered to you as a DSN (Delivery Status Notification, RFC 3464) — the machine-readable bounce message, sometimes called an NDR (Non-Delivery Report). A DSN carries a message/delivery-status part containing Status: (the 5.1.1-style enhanced code) and Diagnostic-Code: (the raw SMTP text from the receiving server). Complaints arrive in a parallel format: the ARF report (Abuse Reporting Format, RFC 5965), a multipart/report with report-type="feedback-report" that names the complained-about message via feedback loops.

Bounce and complaint state machine A sent message transitions to delivered, hard bounce, soft bounce, or complained; hard bounces and complaints move to suppressed, soft bounces retry with backoff until exhausted. Message Lifecycle & Suppression Decisions Sent 202 Accepted Hard Bounce 5.x.x permanent Soft Bounce 4.x.x transient Complained ARF / spamreport Delivered no action Suppressed checked before every send Retry (backoff) bounded attempts attempts left attempts exhausted → suppress
The decision graph every transactional sender needs: hard bounces and complaints suppress immediately, soft bounces retry with backoff until attempts run out.

Core implementation: a suppression-list service

The suppression list is the single source of truth for "do not send." Its contract is small: record a suppression with an address, a reason, and a timestamp; check an address before every send. Keep the schema deliberately boring so the check is a single indexed lookup on the hot send path.

-- PostgreSQL schema for the suppression store
CREATE TABLE suppressions (
  email        TEXT PRIMARY KEY,             -- normalized: lowercased, trimmed
  reason       TEXT NOT NULL,                -- 'hard_bounce' | 'complaint' | 'soft_bounce_exhausted' | 'manual'
  detail       TEXT,                         -- raw SMTP diagnostic or ARF feedback-type
  source       TEXT NOT NULL,                -- 'ses' | 'sendgrid' | 'postmark' | 'mailgun'
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The PRIMARY KEY on email makes the pre-send check an index-only lookup.
import psycopg
from datetime import datetime, timezone

class SuppressionStore:
    def __init__(self, conn: psycopg.Connection):
        self.conn = conn

    @staticmethod
    def _normalize(email: str) -> str:
        # Normalize identically on write and read, or the lookup silently misses.
        # Gmail/Google Workspace ignore dots and +tags, but DO NOT strip them here:
        # the bounce arrives for the exact address you sent to, so match exactly.
        return email.strip().lower()

    def suppress(self, email: str, reason: str, source: str, detail: str = "") -> None:
        e = self._normalize(email)
        with self.conn.cursor() as cur:
            # ON CONFLICT keeps the FIRST reason but is idempotent: SES (via SNS) and
            # SendGrid (via webhook) can both report the same bounce, and providers
            # retry webhooks, so the same event may arrive many times.
            cur.execute(
                """
                INSERT INTO suppressions (email, reason, detail, source, created_at)
                VALUES (%s, %s, %s, %s, %s)
                ON CONFLICT (email) DO NOTHING
                """,
                (e, reason, detail, source, datetime.now(timezone.utc)),
            )
        self.conn.commit()

    def is_suppressed(self, email: str) -> bool:
        e = self._normalize(email)
        with self.conn.cursor() as cur:
            cur.execute("SELECT 1 FROM suppressions WHERE email = %s", (e,))
            return cur.fetchone() is not None


def send_transactional(store: SuppressionStore, esp_client, to: str, message: dict) -> str:
    # The single most important line in the whole pipeline: check BEFORE handing
    # the message to SES / SendGrid / Postmark / Mailgun.
    if store.is_suppressed(to):
        return "skipped_suppressed"
    return esp_client.send(to=to, **message)   # provider API call only if clean

In production, front this lookup with a cache (Redis or an in-process LRU) so a high-volume send loop does not hit Postgres for every recipient — but the cache must be invalidated on every new suppression, or you will send to an address that bounced thirty seconds ago. A short TTL (60 seconds) plus write-through on suppress() is the usual compromise.

Second pattern: classifying bounce type to decide retry vs suppress

The asynchronous bounce notification tells you what kind of failure occurred. The job here is to map it onto exactly one action: suppress now, retry with backoff, or count toward the soft-bounce limit and suppress when exhausted.

import re

# Tracks how many times a given address has soft-bounced (use Redis INCR with TTL in prod).
soft_bounce_counts: dict[str, int] = {}
SOFT_BOUNCE_LIMIT = 5   # after 5 consecutive soft bounces, treat as dead

def classify_and_act(store: SuppressionStore, email: str, smtp_code: str,
                     diagnostic: str, source: str) -> str:
    """smtp_code is the enhanced status like '5.1.1' or '4.2.2'."""
    klass = smtp_code[:1]   # leading digit: 5 = permanent, 4 = transient

    if klass == "5":
        # Permanent (SES bounceType=Permanent / SendGrid event=bounce / Postmark HardBounce).
        # Exception: 5.2.2 "mailbox full" is sometimes returned as permanent but is
        # really transient on Gmail/Yahoo — treat known-full codes as soft.
        if smtp_code == "5.2.2" or re.search(r"mailbox.*full|quota", diagnostic, re.I):
            return _handle_soft(store, email, source, diagnostic)
        store.suppress(email, "hard_bounce", source, detail=f"{smtp_code} {diagnostic}")
        return "suppressed_hard"

    if klass == "4":
        # Transient (SES bounceType=Transient / SendGrid event=deferred / Postmark Transient).
        return _handle_soft(store, email, source, diagnostic)

    # Unknown / blocked (5.7.1 policy rejections, spam blocks) — be conservative and suppress.
    store.suppress(email, "hard_bounce", source, detail=f"{smtp_code} {diagnostic}")
    return "suppressed_unknown"


def _handle_soft(store, email, source, diagnostic) -> str:
    n = soft_bounce_counts.get(email, 0) + 1
    soft_bounce_counts[email] = n
    if n >= SOFT_BOUNCE_LIMIT:
        # A mailbox that defers for days is effectively dead — stop wasting reputation on it.
        store.suppress(email, "soft_bounce_exhausted", source,
                       detail=f"{n} consecutive soft bounces: {diagnostic}")
        return "suppressed_soft_exhausted"
    return "retry_with_backoff"

The retry side belongs in your dispatch queue, not here: a retry_with_backoff result should re-enqueue the message with exponential backoff (e.g. 15 min, 1 h, 4 h, 12 h). This pairs naturally with an idempotent processing layer — see webhook event pipelines for the queue and idempotency machinery these notifications flow through.

Provider constraint table: how each ESP exposes bounces and complaints

Each provider surfaces these signals differently, and the parsing code in your handler must match the provider's payload shape exactly.

Provider Bounces Complaints Delivery mechanism Built-in suppression?
Amazon SES Bounce notification, bounceType Permanent/Transient, bouncedRecipients[] Complaint notification, complaintFeedbackType SNS topic via a configuration set → HTTPS or Lambda subscriber Yes — account-level suppression list (auto-adds permanent bounces & complaints)
SendGrid Event Webhook event: "bounce" / "blocked"; also Bounces API (GET /v3/suppression/bounces) Event Webhook event: "spamreport"; Spam Reports API Event webhook (signed POST) + REST suppression APIs Yes — global suppression groups
Postmark Bounce API (GET /bounces) + Bounce webhook; Type: "HardBounce" / "Transient" Type: "SpamComplaint" via the same Bounce webhook Webhook (per-server) + Bounce API Yes — auto-suppresses hard bounces & complaints
Mailgun Events API + permanent_fail / temporary_fail webhooks; Bounces API (GET /v3/<domain>/bounces) complained webhook event Webhook (signed) + Events/Bounces API Yes — bounce/complaint/unsubscribe lists per domain

Two takeaways. First, every provider already maintains its own suppression list — but you should still keep your own, because you may use more than one provider (a migration, a fallback route) and the provider's list does not protect a different sending channel. Second, SES is the outlier: it does not POST a webhook, it publishes to SNS, which you then subscribe an HTTPS endpoint or Lambda to — covered end to end in handling hard bounces with Amazon SES and SNS.

Pipeline integration steps

  1. Provision the notification channel. For SES, create a configuration set with SNS destinations for Bounce and Complaint. For SendGrid/Postmark/Mailgun, register a webhook URL and enable signature verification.
  2. Expose a single ingest endpoint (or one per provider) that validates the payload signature before parsing. A forged bounce could otherwise let an attacker suppress arbitrary addresses.
  3. Normalize every payload into a common internal event: {email, type, smtp_code, diagnostic, source, provider_message_id}. Provider shapes differ; the rest of the pipeline should not care.
  4. Route to classify_and_act for bounces and to an immediate store.suppress(..., reason="complaint") for complaints — complaints never retry.
  5. Persist idempotently, keyed on the provider's event/message id, so webhook retries do not double-count soft bounces.
  6. Invalidate the suppression cache for the affected address so the next send sees the new state.
  7. Emit metrics — bounce rate and complaint rate per provider per day — and alert when complaint rate approaches 0.1% or bounce rate approaches 5%.
  8. Reconcile periodically by polling each provider's Bounces/Complaints API (SendGrid /v3/suppression/bounces, Postmark /bounces, Mailgun /bounces) to catch any webhook your endpoint missed during an outage.

Because complaints and bounces only have teeth when the originating domain is trusted, this work assumes you have already configured email authentication with SPF, DKIM, and DMARC — an unauthenticated sender's bounces and complaints are attributed unpredictably.

Debugging: named symptoms, cause, and fix

Symptom: still sending to addresses that already hard-bounced. Cause: the pre-send check normalizes the address differently from the suppression write (one lowercases, the other does not), or the cache is stale. Fix: route every read and write through the same _normalize() function, and write-through to the cache inside suppress().

Symptom: a soft bounce got someone permanently suppressed. Cause: the classifier keyed on the leading digit of the raw 3-digit SMTP code (421) instead of the enhanced status (4.7.0), or treated a 5.2.2 mailbox-full as permanent. Fix: classify on the enhanced status' first digit and special-case full-mailbox codes as transient.

Symptom: complaint rate above the 0.1% Gmail threshold in Postmaster Tools. Cause: complaints are being logged but not suppressed, so the same recipients keep receiving mail and keep complaining. Fix: ensure the complaint path calls store.suppress immediately and stops all non-essential mail to that address; reserve only legally required messages.

Symptom: bounce notifications arrive but nothing is suppressed. Cause: signature verification rejects the payload (so you 200-OK and drop it), or the parser expects the wrong JSON shape — e.g. parsing a SendGrid array as a single object, or treating an SNS SubscriptionConfirmation as a Notification. Fix: log the raw body on parse failure and confirm the provider's exact envelope before extracting recipients.

Symptom: the same soft bounce counts five times from one event. Cause: the provider retried the webhook and you incremented the counter each time. Fix: deduplicate on the provider event id before calling classify_and_act.

The SMTP reply-code taxonomy you classify against

Every bounce decision ultimately traces back to two numbers the receiving server returned: the basic reply code (a three-digit value like 550) and the enhanced status code (RFC 3463, like 5.1.1). The basic code's first digit tells you the broad class — 2xx success, 4xx transient, 5xx permanent — but it is too coarse to classify on directly, because a 550 can mean "no such user" (suppress) or "blocked for policy" (a reputation problem, not a dead address). The enhanced code's three fields — class.subject.detail — are what you actually branch on.

Enhanced code Basic seen Meaning Class Correct action
5.1.1 550 Bad destination mailbox (user unknown) Permanent Suppress (hard_bounce)
5.1.2 550 Bad destination system / no such domain Permanent Suppress
5.1.10 550 Recipient address rejected, address does not exist (Office 365 NoSuchUser) Permanent Suppress
5.2.1 550 Mailbox disabled / not accepting messages Permanent Suppress
5.2.2 552 Mailbox full / over quota Permanent code, transient reality Treat as soft, retry
5.4.1 550 No answer from host / recipient not found (Office 365) Permanent Suppress
5.7.1 550/554 Delivery not authorized / blocked by policy or content Permanent reject Investigate reputation; suppress conservatively
5.7.26 550 Authentication (DMARC/SPF/DKIM) failure at receiver Permanent reject Fix auth, do not suppress recipient
4.2.2 452 Mailbox full (transient form) Transient Retry with backoff
4.7.0 421 Try again later / temporary policy (greylisting, rate limit) Transient Retry with backoff
4.4.2 421 Connection dropped during transmission Transient Retry with backoff

Two rows deserve emphasis. 5.2.2 (mailbox full) carries a permanent 5.x.x class but is operationally transient on Gmail, Yahoo, and iCloud — the mailbox frees up — so classifying purely on the leading digit wrongly suppresses recoverable addresses. And 5.7.x policy rejections are a reputation signal, not a dead-mailbox signal: a flood of 5.7.1 from one provider usually means your IP or domain is being blocked, which the SPF, DKIM, and DMARC reference addresses, not your suppression list. Suppressing those addresses hides the real problem.

Parsing the DSN/NDR and the ARF report

The bounce arrives as a DSN — a multipart/report message with report-type="delivery-status" (RFC 3464). Inside, the message/delivery-status part is the machine-readable core: a Status: field carrying the enhanced code, an Action: field (failed, delayed, relayed), and a Diagnostic-Code: field with the raw SMTP text. ESPs parse this for you and hand you structured JSON, but if you run your own MTA or receive raw NDRs you must read it yourself.

from email import message_from_bytes

def parse_dsn(raw_mime: bytes) -> dict:
    """Extract the enhanced status + diagnostic from an RFC 3464 DSN/NDR."""
    msg = message_from_bytes(raw_mime)
    out = {}
    for part in msg.walk():
        # The delivery-status part holds the machine-readable per-recipient fields.
        if part.get_content_type() == "message/delivery-status":
            # A DSN has a per-message block then one block per recipient.
            text = part.get_payload(decode=True).decode("utf-8", "replace")
            for line in text.splitlines():
                if line.startswith("Status:"):          # e.g. "Status: 5.1.1"
                    out["status"] = line.split(":", 1)[1].strip()
                elif line.startswith("Action:"):        # failed | delayed | relayed
                    out["action"] = line.split(":", 1)[1].strip()
                elif line.startswith("Diagnostic-Code:"):  # "smtp; 550 5.1.1 user unknown"
                    out["diagnostic"] = line.split(":", 1)[1].strip()
                elif line.startswith("Final-Recipient:"):  # "rfc822; user@example.com"
                    out["recipient"] = line.split(";", 1)[-1].strip()
    return out

The complaint equivalent is the ARF report (RFC 5965): a multipart/report with report-type="feedback-report" whose message/feedback-report part names the Feedback-Type (abuse, fraud, opt-out) and Original-Rcpt-To. The full ARF parser and per-provider feedback-loop enrollment live in the deep-dive on processing feedback loops and complaints; the key point here is that DSNs and ARF reports flow into the same suppression store through different parsers, and your normalizer must collapse both into the common {email, type, smtp_code, diagnostic, source} event.

Soft-bounce retry and backoff policy

A soft bounce is permission to try again — but only within bounds, because every retry to a chronically deferring mailbox spends reputation. The policy has three knobs: the backoff schedule, the attempt cap, and the giving-up rule that converts an exhausted soft bounce into a suppression. A common, conservative schedule for transactional mail:

Attempt Delay before retry Cumulative age Notes
1 15 minutes 15 min Covers greylisting (4.7.0), which clears on the second attempt
2 1 hour ~1.25 h Covers brief rate limits
3 4 hours ~5.25 h Mailbox-full (4.2.2) often clears within hours
4 12 hours ~17 h Last attempt for a same-day message
5 24 hours ~41 h Final attempt; on failure, suppress as soft_bounce_exhausted

For transactional mail specifically, the content often has an expiry the backoff must respect: a one-time password or magic link that takes 17 hours to deliver is worse than useless. Cap the total age at the message's semantic deadline and drop (not suppress) the message when it expires, distinct from suppressing the address. Track the consecutive-soft-bounce counter in Redis with a TTL so a recipient who soft-bounces once this week and once next month is not slowly walked toward suppression:

# Redis-backed soft-bounce counter with a sliding TTL so isolated soft bounces decay.
def increment_soft_bounce(redis, email: str, window_seconds: int = 7 * 86400) -> int:
    key = f"softbounce:{email.strip().lower()}"
    n = redis.incr(key)                 # atomic; survives across worker processes
    redis.expire(key, window_seconds)   # reset the window on each soft bounce
    return n                            # caller suppresses when n >= SOFT_BOUNCE_LIMIT

Suppression-store schema, reasons, and TTL

The minimal schema earlier is enough to stop sending; a production store needs a few more columns to audit and reverse decisions. Reversibility matters because some suppressions are temporary or wrongful — a 5.2.2 that slipped through as a hard bounce, or an address that complained, churned, and legitimately re-subscribed months later.

CREATE TABLE suppressions (
  email        TEXT PRIMARY KEY,            -- normalized: lowercased, trimmed
  reason       TEXT NOT NULL,               -- hard_bounce | complaint | soft_bounce_exhausted | manual | unsubscribe
  detail       TEXT,                        -- raw SMTP diagnostic / ARF feedback-type
  source       TEXT NOT NULL,               -- ses | sendgrid | postmark | mailgun
  smtp_code    TEXT,                        -- the enhanced status, e.g. 5.1.1, for later reclassification
  expires_at   TIMESTAMPTZ,                 -- NULL = permanent; set for reversible suppressions
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- A partial index lets a sweeper find only the expiring rows cheaply.
CREATE INDEX suppressions_expiry_idx ON suppressions (expires_at) WHERE expires_at IS NOT NULL;

The TTL rule by reason:

  • hard_bounce and complaint: never expire (expires_at = NULL). A confirmed-dead mailbox does not come back, and a complainer who returns must re-opt-in explicitly. These are the permanent rows.
  • soft_bounce_exhausted: expire after 30–90 days. A mailbox that was full or deferring for two days may be healthy next quarter; let it re-enter the sendable pool and re-prove itself rather than condemning it forever.
  • manual / unsubscribe: per policy. Unsubscribes are permanent unless the user re-subscribes; a manual hold for an investigation might expire.

The pre-send check then becomes "is there a non-expired suppression," and a scheduled sweeper deletes expired rows so the hot-path lookup stays a single indexed hit:

-- Pre-send check honoring TTL: an expired soft-bounce suppression no longer blocks.
SELECT 1 FROM suppressions
 WHERE email = $1 AND (expires_at IS NULL OR expires_at > now());

Fuller provider mapping: payload fields you actually read

The earlier table covers where each provider exposes signals; this one covers the exact field names your parser dereferences, so the normalizer can be written against each shape without guesswork.

Signal Amazon SES (SNS JSON) SendGrid (Event Webhook) Postmark (webhook) Mailgun (webhook / Events)
Event discriminator notificationType = Bounce / Complaint event = bounce / dropped / spamreport RecordType / Type = HardBounce / Transient / SpamComplaint event-data.event = failed / complained
Hard vs soft bounce.bounceType = Permanent / Transient / Undetermined event = bounce (hard) vs deferred (soft) Type = HardBounce vs Transient event-data.severity = permanent / temporary
Recipient bounce.bouncedRecipients[].emailAddress email Email event-data.recipient
Enhanced code bouncedRecipients[].status (5.1.1) status (5.1.1) Details (free text) event-data.delivery-status.code
Raw diagnostic bouncedRecipients[].diagnosticCode reason Description event-data.delivery-status.message
Complaint type complaint.complaintFeedbackType n/a (just spamreport) n/a (SpamComplaint) event-data.flags / FBL header
Correlation id mail.messageId sg_message_id MessageID event-data.message.headers.message-id
Dedup id mail.messageId (no native event id) sg_event_id composite of MessageID+Type event-data.id

Note the two structural traps the normalizer must absorb: SES delivers a batch of recipients per notification (iterate bouncedRecipients), and SendGrid delivers a batch of events per POST (iterate the top-level array). Postmark and Mailgun send one event per request. The SES wiring — configuration set, SNS envelope double-parse, signature verification — is the subject of the dedicated Amazon SES and SNS hard-bounce guide.

More debugging entries

Symptom: 5.7.26 / DMARC-failure bounces are filling the suppression list. Cause: the classifier treats every 5.7.x as a dead address and suppresses, but 5.7.26 means the receiver rejected your authentication, not that the mailbox is bad. Fix: branch 5.7.1/5.7.26 policy rejections away from suppression and into a deliverability alert; the addresses are fine, your SPF/DKIM alignment is not.

Symptom: addresses suppressed for soft_bounce_exhausted never come back even after the mailbox recovers. Cause: soft-exhaustion rows were written with expires_at = NULL like hard bounces. Fix: set a 30–90 day expires_at for soft-exhaustion reasons and run the expiry sweeper.

Symptom: a recipient soft-bounces once a month and is eventually suppressed. Cause: the soft-bounce counter has no TTL, so isolated transient failures accumulate forever. Fix: use the Redis counter with a sliding window (above) so the count decays between incidents.

Symptom: the reconciliation poller re-suppresses addresses you manually un-suppressed. Cause: the provider's own suppression list still holds the address, so the nightly backfill re-adds it. Fix: when you intentionally clear a suppression, also delete it from the provider list (SendGrid DELETE /v3/suppression/bounces/{email}, SES DeleteSuppressedDestination) so the source of truth agrees.

Symptom: bounce volume looks correct but the enhanced codes are all empty. Cause: the parser read the basic 550 from the Diagnostic-Code text but never extracted the Status: line carrying 5.1.1. Fix: classify on the DSN Status field (or the provider's structured status), falling back to a regex over the diagnostic only when Status is absent.

Validation & deployment checklist

  • Suppression schema deployed with a primary key / unique index on the normalized email.
  • Pre-send check (is_suppressed) is called for every recipient on the hot path, including retries and resends.
  • Read and write paths share one address-normalization function.
  • Suppression cache (if used) is write-through and short-TTL; invalidated on new suppression.
  • Bounce classifier keys on the enhanced status code, special-cases mailbox-full as transient, and caps soft-bounce retries.
  • Complaint path suppresses immediately and never retries.
  • Webhook/SNS signature verification runs before payload parsing.
  • Ingest is idempotent on the provider event/message id.
  • Bounce rate and complaint rate are emitted as metrics with alerts at 5% and 0.1% respectively.
  • A scheduled reconciliation job polls each provider's bounce/complaint API to backfill missed events.
  • 5.7.x policy/auth rejections are routed to a deliverability alert, not blindly suppressed.
  • Soft-bounce-exhausted suppressions carry a 30–90 day expires_at; a sweeper clears expired rows.
  • Clearing a suppression also removes it from the originating provider's own list.

Frequently asked questions

Should I suppress on the first soft bounce? No. A single 4.x.x is almost always greylisting or a brief rate limit that clears on the second attempt. Retry with backoff and only suppress after the attempt cap (typically five consecutive soft bounces within a sliding window).

Do I still need my own suppression list if the provider keeps one? Yes. The provider's list protects only that provider's channel. The moment you add a fallback route, migrate to a second ESP, or send through more than one (see ESP selection and integration), the provider-side list no longer covers all your outbound mail. Your own store is the single cross-provider source of truth.

Is a complaint worse than a bounce? Operationally, yes. A bounce is mostly a list-hygiene problem; a complaint is an explicit "this is spam" judgment that mailbox providers weight heavily and that counts against the 0.1–0.3% Gmail threshold. Suppress complainers immediately and never retry them.

Can I send a password reset to a suppressed address? Only for soft_bounce_exhausted (the mailbox may have recovered) — and even then, cautiously. For hard_bounce the mailbox does not exist, so the reset cannot arrive. For complaint, sending a user-initiated transactional message (a reset they just requested) is generally defensible, but sending anything else is not.

How do I un-suppress someone who re-subscribed? Delete the row from your store and from the provider's list (or the reconciliation job will re-add it), require an explicit re-opt-in for anyone previously suppressed for complaint, and log who cleared it and why for auditability.


← Back to Transactional Email Delivery Infrastructure