# Bounces and spam complaints: listen, or lose the inbox for everyone

> A hard bounce means the mailbox is gone. Keep sending and mailbox providers downgrade every message from your domain. Wire the webhook, suppress permanently, and never suppress on a soft bounce.

Every message you send is a vote on your sender reputation. A message accepted
by a real mailbox is a small positive vote. A message to an address that does
not exist is a large negative one. A spam complaint is larger still.

Providers do not judge the individual message: they judge the domain. Which
means a few hundred sends to dead addresses can push *every* email from your
domain into the spam folder, including the sign-in links your paying customers
are waiting for.

The fix is not clever. It is: listen to what the provider tells you, and stop
sending to addresses that failed.

## Hard versus soft, and why the distinction matters

**Hard bounce**: permanent. The mailbox does not exist, the domain does not
resolve, the server refuses you outright. SMTP codes in the 5xx range:

```
550 5.1.1 The email account that you tried to reach does not exist
550 5.4.1 Recipient address rejected: Access denied
```

Retrying achieves nothing except more negative votes. Suppress the address.

**Soft bounce**: temporary. Mailbox full, server down, greylisting, a rate
limit at the receiving end. 4xx codes:

```
452 4.2.2 The email account that you tried to reach is over quota
421 4.7.0 Try again later
```

These usually deliver on a later attempt. Suppressing on a soft bounce throws
away a real customer who is reachable tomorrow, and it is the mistake people
make when they wire the webhook without reading the bounce type.

The route in this repo checks it:

```ts
const permanent = event.data.bounce?.type?.toLowerCase() === "permanent";
if (permanent) {
  for (const address of to) await suppress(address, "hard_bounce");
}
```

**Complaint**: someone pressed "report spam". Always permanent, always
suppress, regardless of what consent you believe you had. Arguing with a
complaint is not a strategy; the provider has already counted it.

Keep the complaint rate under 0.1%. Google's bulk-sender guidance treats 0.3% as
the point where enforcement starts, and by then you are already being filtered.

## Wiring the webhook

In Resend: **Webhooks → Add endpoint**, pointing at
`https://<your-domain>/api/webhooks/resend`, subscribed to `email.bounced`,
`email.complained` and `email.delivered`. Copy the signing secret into
`RESEND_WEBHOOK_SECRET`.

Resend signs with Svix (the standard-webhooks scheme): `svix-id`,
`svix-timestamp` and `svix-signature` over the raw body. Two rules, the same as
every signed webhook:

- Verify the **raw bytes** with `await request.text()`. `await request.json()`
  re-serialises and every signature fails.
- Pass all three headers. The timestamp is inside the signed string and is
  checked for freshness, which is what stops a captured payload being replayed.

Return 401 on a verification failure and 200 once you have handled it. When
`RESEND_WEBHOOK_SECRET` is missing the route answers **503**, not 500: a
sustained run of 500s is what makes a provider disable an endpoint outright, and
"we forgot a variable" should not turn into "bounces stopped arriving and nobody
noticed". `bun run verify` says so in the email check rather than failing,
because local development does not need the webhook.

There is deliberately no delivery-id ledger. Svix rejects a payload whose
timestamp is outside its tolerance, which closes the replay window, and the only
side effect is `suppress()`: an upsert on the address, so a redelivery of the
same event costs one query and changes nothing.

## The suppression list

`sendEmail` checks it before every send and throws `EmailSuppressedError` rather
than mailing a known-dead address. That check is the entire point of the webhook: events you record and never read are just logs.

Where the list is kept is decided by the ORM this repo selected, and the answer
is already written: `src/lib/email/store.ts`.

- **Drizzle or Prisma.** The `email_suppressions` table (address as the primary
  key, reason, created_at) declared in `src/db/email-schema.ts` or in
  `prisma/schema.prisma`, with identical SQL either way. Run a migration and the
  webhook works across instances. The insert is an upsert, so a redelivered
  event is free and the first reason recorded wins.
- **No ORM.** An in-process Map. Genuinely fine in development and on a single
  long-lived server, genuinely useless on serverless, where every instance starts
  empty and the webhook's write lands somewhere the next send never reaches.

That last case is the launch blocker, and the fix is a battery rather than a
patch: add Drizzle or Prisma and the file is regenerated with no change to any
caller, because `suppression.ts` only ever sees the `SuppressionStore` interface.

On an ORM build the webhook route reaches `@/db`, which constructs its client at
module scope, so `DATABASE_URL` must be set wherever `bun run build` runs,
since Next.js evaluates every route module while collecting page data. The auth
and billing routes already require this, so it is a property of having a database
rather than of having email.

If the list already lives somewhere else (another service, a table you share
across apps) implement that interface and install it once at startup:

```ts
import { setSuppressionStore } from "@/lib/email/suppression";

setSuppressionStore({
  async has(address) { /* ... */ },
  async add(address, reason) { /* ... */ },
  async remove(address) { /* ... */ },
});
```

Addresses arrive normalised, so a store never has to lowercase again.

## Removing an address

Only on an explicit request from the person who owns the mailbox. "I fixed my
email, please try again" is a valid reason. "Our list shrank, let us re-add
everyone" is how a sending domain dies: those addresses bounced for a reason,
and the second run produces the same bounces plus a reputation penalty.

Never bulk-clear the suppression list. If you find yourself wanting to, the
underlying problem is list quality, not the list.

## Preventing bounces in the first place

- **Validate at signup.** Syntax, then an MX lookup on the domain. It catches
  `gmial.com` before it ever becomes a bounce.
- **Use double opt-in** for anything non-transactional. It is the single
  biggest lever on both bounce rate and complaint rate.
- **Never buy or scrape a list.** Beyond being illegal in much of the world,
  purchased lists are full of spam traps: addresses that exist only to catch
  senders like that, and hitting one can block your domain outright.
- **Re-engage or drop the inactive.** An address that has not opened anything in
  a year may have been recycled into a spam trap.
- **Watch the dashboard weekly.** Bounce rate above 2% or complaint rate above
  0.1% is a problem to fix now, not a metric to note.

## When it has already gone wrong

Stop sending bulk mail. Fix the list: suppress every hard bounce you have
recorded, remove everyone who has not engaged. Then rebuild volume slowly from a
low base over a couple of weeks. Reputation recovers, but on the provider's
timescale, not yours, and only if you stop doing the thing that caused it.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
