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.
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
- Provision the notification channel. For SES, create a configuration set with SNS destinations for
BounceandComplaint. For SendGrid/Postmark/Mailgun, register a webhook URL and enable signature verification. - 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.
- 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. - Route to
classify_and_actfor bounces and to an immediatestore.suppress(..., reason="complaint")for complaints — complaints never retry. - Persist idempotently, keyed on the provider's event/message id, so webhook retries do not double-count soft bounces.
- Invalidate the suppression cache for the affected address so the next send sees the new state.
- Emit metrics — bounce rate and complaint rate per provider per day — and alert when complaint rate approaches 0.1% or bounce rate approaches 5%.
- 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_bounceandcomplaint: 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.xpolicy/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.
Related
- Handling Hard Bounces with Amazon SES and SNS — wire the SES configuration set → SNS → handler → suppression flow end to end
- Processing Feedback Loops and Spam Complaints — parse ARF reports and enroll in provider feedback loops
- Webhook Event Pipelines — the idempotent queue these notifications flow through
- Email Authentication: SPF, DKIM, DMARC — the trust foundation that makes your bounce and complaint metrics attributable