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:
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:
// 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:
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:
// 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.
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.