# Suppression lists in Mailgun: stop maintaining your own bounce table

> Mailgun stores bounces, complaints and unsubscribes per domain and refuses to send to them. Read that list instead of building a table you have to keep in sync.

Your sending reputation is fine for months. Then a batch job starts retrying a
failed notification, the same dead address gets hammered every fifteen minutes,
your hard bounce rate crosses two per cent, and Gmail starts putting your
password resets in spam.

Or the quieter version: someone clicks "report spam", you keep emailing them,
they report you again, and every message you send to that provider gets a little
worse.

The fix is a suppression list: a record of addresses you must not email again.
The interesting question is where it lives.

## The wrong way: your own bounce table

The instinct is a table:

```sql
create table email_suppression (
  address    text primary key,
  reason     text not null,
  created_at timestamptz not null default now()
);
```

with a webhook handler that inserts into it and a check before every send. It
works, and it is a surprising amount of ongoing work:

- **It starts empty.** Every address that bounced before you shipped the table
  is unknown to you, and you will discover them one bounce at a time.
- **It drifts.** A missed webhook (a deploy, a 500, an expired signing key) is
  an address you keep mailing. There is no reconciliation unless you write one.
- **It does not survive a restore.** Roll the database back to last night and
  you have un-suppressed everything that bounced today.
- **It is per-environment.** Staging does not know what production learned.
- **It is a second source of truth.** Mailgun already refuses these addresses;
  now two systems disagree about who is blocked and support has to check both.

## The right way: Mailgun already has one

Mailgun keeps three lists per domain and exposes them over the API:

- **bounces**: hard failures. Mailgun adds these itself.
- **complaints**: spam reports fed back by the mailbox provider.
- **unsubscribes**: people who used an unsubscribe link.

It refuses to deliver to a listed address on its own, so this is not a feature
you have to implement: it is one you should stop duplicating. The lists live
with the domain, survive your database, are identical in every environment
pointed at that domain, and are populated by events you never saw.

Check them before you send:

```ts
// src/lib/email/suppression.ts
export async function isSuppressed(address: string): Promise<boolean> {
  const key = normalizeAddress(address);
  const hit = cached(key);
  if (hit !== null) return hit;

  const domain = sendingDomain();
  const lists = ["bounces", "complaints", "unsubscribes"] as const;

  try {
    const results = await Promise.all(
      lists.map(async (list) => {
        try {
          return Boolean(await client().suppressions.get(domain, list, key));
        } catch (error) {
          // 404 is the normal answer for an address that is not listed.
          if ((error as { status?: number }).status === 404) return false;
          throw error;
        }
      }),
    );

    const suppressed = results.some(Boolean);
    remember(key, suppressed);
    return suppressed;
  } catch (error) {
    // Anything else: report it and answer "not suppressed". See below.
    reportEmailIncident({ kind: "suppression-unavailable", cause: error });
    return false;
  }
}
```

Three details that matter.

The API answers per list, so a real check asks all three, in parallel, not in
sequence. A `404` means "not on this list", which is the common case; treating it
as an error turns every healthy send into a thrown exception.

And the outer `catch` is the one to understand before you change it. This check
is an optimisation, not the enforcement (Mailgun refuses a listed address on its
own) so **it fails open**. Rethrowing a Mailgun 5xx here would block every
outbound message, including magic links, which turns a thirty-second provider
hiccup into a total sign-in outage. The cost of failing open is one delivery
attempt that Mailgun rejects. The cost of failing closed is every customer locked
out, from a check that was never load-bearing. Nothing is cached on that path, so
the next send re-asks rather than inheriting a guess.

Then refuse loudly rather than silently:

```ts
export class EmailSuppressedError extends Error {
  constructor(readonly address: string) {
    super(`${address} is suppressed (bounce, complaint or unsubscribe)`);
    this.name = "EmailSuppressedError";
  }
}

// in sendEmail()
for (const address of to) {
  if (await isSuppressed(address)) throw new EmailSuppressedError(address);
}
```

A thrown error is a decision the caller has to handle. Silently dropping the
message is how you end up with a user who never got their invoice and a support
agent who cannot tell whether it was sent.

## Why cache, and why only for a minute

Three API calls before every send is real latency and real rate-limit budget,
so a short in-process cache is worth having. The TTL is the interesting choice,
and it is asymmetric:

- A stale **"not suppressed"** costs one send that Mailgun rejects anyway.
- A stale **"suppressed"** blocks a real customer who has fixed their mailbox.

Sixty seconds is short enough that the second case resolves itself before anyone
files a ticket. And because the webhook knows the moment something changes, it
invalidates the entry immediately:

```ts
// src/app/api/webhooks/mailgun/route.ts
case "complained":
  invalidateSuppressionCache(recipient);
  console.warn(`[email] spam complaint from ${recipient}`);
  break;
```

Note what the webhook does *not* do: it does not maintain the list, and it does
not write to your database, there is no table here to write to. Mailgun already
listed the address. The handler exists so your process notices within seconds
instead of within a minute, and so the bounce is reported somewhere a human can
see the trend.

## Removing an address

There is exactly one legitimate reason: the person who owns the mailbox asked.

```bash
bun run email:suppressions                 # list everything
bun run email:suppressions check a@b.com   # is this address blocked?
bun run email:suppressions remove a@b.com  # on their request only
```

`suppress()` exists for the other direction (a manual block) and writes a
sentence into Mailgun's `error` field rather than the app's internal reason code.
That field is what the dashboard and the listing above show, next to entries
Mailgun wrote from real SMTP replies, so `hard_bounce` there would be
indistinguishable from a dead mailbox.

Never clear the lists in bulk to "clean up" before a send. Those addresses
bounced for a reason, most of them will bounce again, and a spike in hard
bounces is precisely the signal mailbox providers use to decide you are sending
to a purchased list. It is one of the fastest ways to lose a domain.

If your bounces list is long, that is a list-quality problem and the fix is at
the other end: validate addresses at signup, use a confirmation email before
you trust one, and stop importing addresses you did not collect yourself.

## What you still have to store

Mailgun's lists cover "must not email". They do not cover your own product
preferences: a user who wants receipts but not weekly digests. That belongs in
your database, because it is a product decision Mailgun knows nothing about.

The division is clean: **Mailgun owns deliverability suppression, you own
consent and preferences.** Check both: one because you must, one because you
promised.

---

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
