Skip to content
Chapter 48Lesson 4

The suppression list as a send-time chokepoint

The suppression-list pattern, a database table of addresses you must never email plus a single read-before-send check in the sendEmail wrapper, that stops bounced and complained addresses from quietly destroying your sender reputation.

Picture three sends that all return ok from your app’s point of view. A welcome email to a mailbox that hard-bounced last week. A newsletter to someone who hit “report spam” yesterday. A password reset to an address that bounced six months ago and never came back. Every one of them looks successful: the Server Action runs, Resend accepts the call, the dashboard shows a green checkmark. And every one of them is damaging your ability to land in the inbox, both for this user and for everyone else you send to.

This lesson installs the one thing that stops those sends: a data structure that remembers “must not send here,” and a single chokepoint that consults it before every send. Back in the first lesson of this chapter you left a comment in sendEmail: // The suppression check lands here. Today you fill that gap. You’ll build the emailSuppressions table and the read-before-send check inside the wrapper. The other half of the system is the webhook that writes rows into that table when a bounce or complaint arrives, and that comes in a later chapter. Today is the schema and the read.

A bounce is the mailbox provider saying “stop”

Section titled “A bounce is the mailbox provider saying “stop””

Start with the mental model, because this is the part beginners get wrong. The reflex is to treat suppression as a politeness feature, a way to avoid pestering people who don’t want your mail. That framing leads you to under-build it, so set it aside.

When an address bounces or someone files a complaint , that is not feedback about your content. It’s a signal from the receiving provider, whether that’s Gmail, Outlook, or Yahoo, telling you in the only language they have to stop sending. And the provider remembers whether you listened. If you send to that address again, no matter the content or the reason, you’ve told them you ignore their signals. What they down-rank in response isn’t just that one message. It’s your next message, to every recipient.

That last part is what should change how seriously you take this. Sender reputation is shared across everything you send from a domain. So re-mailing one complained address isn’t a problem isolated to that one user. It nudges the welcome email of a brand-new, unrelated signup a little closer to the spam folder. You are spending everyone’s deliverability to send mail nobody can receive. The discipline isn’t about being polite; it’s about staying in the inbox for everyone else.

The good news is that the app’s job here is mechanical. The instant a stop signal arrives, remove that address from the set of addresses you’re allowed to send to, permanently for the signals that mean permanent. The whole feature is this: keep a list of addresses you must not send to, and check it before you send. The interesting question is where that check lives, and we’ll get there. First, the structure that holds the list.

This list has to be a persistent database table rather than an in-memory cache or a call to Resend’s API on every send, and that follows straight from the requirement. The list has to survive a server restart, and it has to be answerable in a single fast lookup on the hot path of every email you send. A table with an index on the address gives you both.

The two halves of the loop: who writes, who reads

Section titled “The two halves of the loop: who writes, who reads”

There’s a shape to this system, and you’ll build it more confidently if you can see the whole thing first, even though you’re only going to build one half of it today.

The following diagram shows both halves converging on one table. Look at the two flows separately.

Write path — built later, out of scope today email_suppressionsemailtextUNQreasonenumServer ActionsendEmail()Resend.emails.sendreturn ok: falseMailbox providerResendemail.bounced /email.complainedwebhook INSERT SELECT allowed suppressed

One writer, many readers. The webhook is the single source of new rows; every send is a reader that branches on what it finds. This lesson builds the read path; the write path arrives in a later chapter on webhooks.

This pattern is worth naming, because you’ll meet it again on every webhook-fed table in the course: single writer, many readers. Exactly one thing is allowed to put rows into email_suppressions, and that’s the webhook handler that receives bounce and complaint events from Resend, verifies they’re genuine, and inserts them. Everything else only reads the table and branches on what it finds. That handler is built in a later chapter on webhook ingestion, which reuses the same machinery to write this table. It’s named here and deferred so you’re never left wondering how rows appear: they appear because a bounce happened and the webhook recorded it.

Your job today is the reader. But notice that the schema has to be decided now, even though the writer ships later, because the reader can’t query columns that don’t exist and the writer will insert into the shape you define here. So the schema is the natural starting point, and it’s where the real decisions live.

The bounce taxonomy that decides what gets suppressed

Section titled “The bounce taxonomy that decides what gets suppressed”

Not every negative-sounding email event means “suppress this address.” Getting this distinction right is what justifies storing a reason on each row rather than just an address, so it’s worth a moment before the schema. There are three kinds of signal, and the rule is different for each.

A hard bounce is permanent: the mailbox doesn’t exist, the domain doesn’t accept mail, or the recipient is blocked. There’s no future in which this address starts working again on its own, so re-sending is pure downside, and re-sending to a known-dead address is exactly what providers punish hardest. Suppress on the first occurrence.

A soft bounce is temporary: the mailbox is full, the receiving server had a hiccup, or the message got greylisted . This address might be perfectly fine on the next attempt, so suppressing on the first soft bounce would lock out real, reachable users. The rule of thumb is to suppress only after roughly five consecutive soft signals to the same address. At that point it’s behaving like a dead mailbox, even if no single event said so.

A complaint is the sharpest signal of all. The recipient received your mail and clicked “report spam.” It can only happen after a successful delivery, which is what makes it so costly: a single complaint weighs more against your reputation than a single bounce, because it’s an actual human declaring your mail unwanted. Suppress immediately and permanently.

A few other Resend events sound relevant but aren’t suppression signals at all: email.delivered, email.opened, and email.clicked. They’re telemetry, useful for measuring engagement but never a reason to stop sending. Don’t expect them in this table.

Sort the events below into how the system should treat them. The trap is assuming everything that isn’t a clean delivery should suppress.

Each row is something a mailbox provider can report back about a send. Sort it by how the suppression list should react. Drag each item into the bucket it belongs to, then press Check.

Suppress on first occurrence One signal is enough — never send again
Suppress only after a threshold Could be temporary; wait for a pattern
Never suppress — telemetry only Useful to measure, not a stop signal
Hard bounce — mailbox doesn’t exist
Complaint — recipient clicked “report spam”
Soft bounce — mailbox is full
Soft bounce — server temporarily unavailable
email.delivered
email.opened

This taxonomy matters for the schema for a direct reason: the write side has to record why an address was suppressed, and the read side will sometimes treat reasons differently. A row that exists because a marketing recipient unsubscribed is not the same as a row that exists because the mailbox is dead, and a password reset needs to behave differently toward each. That’s why the table stores a reason, not just an email.

Modeling the suppression list: the email_suppressions table

Section titled “Modeling the suppression list: the email_suppressions table”

Now the first code. This is a Drizzle table, and the column choices carry most of the lesson, so rather than dump the whole thing and move on, walk through it one column at a time. Each column is a small decision with a reason behind it.

Step through the table below.

export const suppressionReason = pgEnum('suppression_reason', [
'hard_bounce',
'soft_bounce_threshold',
'complaint',
'manual_unsubscribe',
]);
export const emailSuppressions = pgTable('email_suppressions', {
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
email: text('email').notNull().unique(),
reason: suppressionReason('reason').notNull(),
providerEventId: text('provider_event_id'),
bypassUntil: timestamp('bypass_until', { withTimezone: true }),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

The primary key, following the course’s standard for user-facing entities: a UUIDv7 generated by $defaultFn. This is the same convention you’ve used on every table since the data-layer chapters, so we won’t re-derive why v7 over v4.

export const suppressionReason = pgEnum('suppression_reason', [
'hard_bounce',
'soft_bounce_threshold',
'complaint',
'manual_unsubscribe',
]);
export const emailSuppressions = pgTable('email_suppressions', {
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
email: text('email').notNull().unique(),
reason: suppressionReason('reason').notNull(),
providerEventId: text('provider_event_id'),
bypassUntil: timestamp('bypass_until', { withTimezone: true }),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

The load-bearing column. The unique constraint is what lets the read answer “is this address suppressed?” in a single index lookup on the hot send path. It’s also what lets the webhook’s INSERT use ON CONFLICT (email) DO NOTHING (insert the row, but quietly do nothing if that email is already present) to stay idempotent when an event is redelivered. One rule is non-negotiable: the value stored here is always normalized, lowercased and trimmed, at write and at read. Skip that and User@x.com and user@x.com become two rows the unique index can’t dedupe.

export const suppressionReason = pgEnum('suppression_reason', [
'hard_bounce',
'soft_bounce_threshold',
'complaint',
'manual_unsubscribe',
]);
export const emailSuppressions = pgTable('email_suppressions', {
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
email: text('email').notNull().unique(),
reason: suppressionReason('reason').notNull(),
providerEventId: text('provider_event_id'),
bypassUntil: timestamp('bypass_until', { withTimezone: true }),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

The taxonomy from the last section, stored as a pgEnum . Note two of the values. The first is soft_bounce_threshold, not soft_bounce: only the promoted soft bounces, the ones that crossed the threshold, ever land here, and a single soft bounce isn’t a row. The second is manual_unsubscribe, the value the marketing layer will write later. It’s kept distinct from the bounce reasons because, as you’ll see, transactional sends are allowed to ignore it.

export const suppressionReason = pgEnum('suppression_reason', [
'hard_bounce',
'soft_bounce_threshold',
'complaint',
'manual_unsubscribe',
]);
export const emailSuppressions = pgTable('email_suppressions', {
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
email: text('email').notNull().unique(),
reason: suppressionReason('reason').notNull(),
providerEventId: text('provider_event_id'),
bypassUntil: timestamp('bypass_until', { withTimezone: true }),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

The Resend event.id of the event that created this row. It does two jobs: traceability, so you can trace any row back to the exact provider event, and a dedup key for the webhook, so it won’t write the same event twice. It’s nullable, because a manually-added row has no provider event behind it.

export const suppressionReason = pgEnum('suppression_reason', [
'hard_bounce',
'soft_bounce_threshold',
'complaint',
'manual_unsubscribe',
]);
export const emailSuppressions = pgTable('email_suppressions', {
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
email: text('email').notNull().unique(),
reason: suppressionReason('reason').notNull(),
providerEventId: text('provider_event_id'),
bypassUntil: timestamp('bypass_until', { withTimezone: true }),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

An optional, time-boxed carve-out: a window during which this address may be mailed despite being on the list. The motivation only makes sense once you’ve seen the read logic, so for now just register that the column exists. We’ll come back to exactly who writes it and why.

export const suppressionReason = pgEnum('suppression_reason', [
'hard_bounce',
'soft_bounce_threshold',
'complaint',
'manual_unsubscribe',
]);
export const emailSuppressions = pgTable('email_suppressions', {
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
email: text('email').notNull().unique(),
reason: suppressionReason('reason').notNull(),
providerEventId: text('provider_event_id'),
bypassUntil: timestamp('bypass_until', { withTimezone: true }),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

The raw provider payload, stored as jsonb. When you’re debugging a suppression late at night, having the original event body in your own database means you don’t have to re-query Resend to find out what happened.

export const suppressionReason = pgEnum('suppression_reason', [
'hard_bounce',
'soft_bounce_threshold',
'complaint',
'manual_unsubscribe',
]);
export const emailSuppressions = pgTable('email_suppressions', {
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
email: text('email').notNull().unique(),
reason: suppressionReason('reason').notNull(),
providerEventId: text('provider_event_id'),
bypassUntil: timestamp('bypass_until', { withTimezone: true }),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

created_at and updated_at, both timestamptz with a defaultNow(), following the course’s timestamp convention. created_at tells you when the address was first suppressed, and updated_at moves if the reason ever changes.

1 / 1

A couple of notes on where this lives and how it connects. The table goes in db/schema.ts, your single source of truth for the data shape. That’s the course’s second architectural principle: one place defines the schema, and everything else derives from it. So when the webhook chapter needs a Zod validator for a row of this table, it won’t hand-write one. It derives it from this exact table with drizzle-zod’s createSelectSchema, so the parser and the schema can never drift apart. You don’t build that validator here; just know it’s a one-liner away, anchored to this definition.

Now build the load-bearing part yourself. In the exercise below, the table already has id, reason, and createdAt. Your job is to add the email column with the right constraints, then watch the unique constraint do its job against a real database.

Two edits. First, add the `email` column to `email_suppressions`: `text`, not null. Second, make it unique by adding `unique().on(table.email)` to the table-level constraint list at the bottom — that single-column unique is the whole point. It's what lets a send check 'is this address suppressed?' in one lookup, and what stops the same address being stored twice. Two divergences from the real schema, both to fit the in-browser Postgres: `reason` is a plain `text` column instead of a pgEnum, and `id` uses a simple default instead of uuidv7(). The unique constraint you're adding is identical to production.

That second probe failing is the entire guarantee in miniature: the database itself refuses to hold the same address twice. The webhook can fire email.bounced for dup@example.com ten times, and you still have exactly one suppression row.

The read-before-send check inside sendEmail

Section titled “The read-before-send check inside sendEmail”

The table is the memory. Now comes the behavior: the check that consults it. This is where that // suppression check lands here comment from the first lesson finally gets filled in.

You wrote the wrapper before, so look at the before-and-after side by side. The “before” tab is the skeleton you shipped earlier; the “after” tab is the same function with the gate inserted ahead of the Resend call.

lib/email.ts
export async function sendEmail(
input: SendEmailInput,
): Promise<Result<{ id: string }>> {
// The suppression check lands here — see the suppression-list lesson.
const { data, error } = await resend.emails.send({
from: DEFAULT_FROM,
to: [input.to],
subject: input.subject,
react: input.react,
});
if (error || !data) return err('internal', 'Could not send email.');
return ok({ id: data.id });
}

The gap, unfilled. Every send already funnels through this one function, which is exactly why it’s the right place for the check. The comment marks where the gate goes. Right now any address, suppressed or not, passes straight through to Resend.

The gate is four steps, and they’re worth naming as an algorithm because the order matters:

  1. Normalize the recipient with input.to.toLowerCase().trim(). This has to come first, for a subtle reason: the addresses in the table are stored normalized. If you look up the raw User@Example.com against a table that holds user@example.com, you find nothing and send to a suppressed address. Normalizing before the lookup is what makes the unique index catch the row.
  2. Look it up with a single indexed SELECT on the normalized email.
  3. Short-circuit if suppressed. If a row exists, and this caller isn’t explicitly bypassing, return a failure Result and do not call Resend.
  4. Otherwise, send, exactly as before.

For the short-circuit’s return shape, the wrapper reuses the Result contract your action layer already speaks. A suppressed send maps onto the existing forbidden code from lib/result.ts, as in err('forbidden', 'This address is on the suppression list.'). That fits, because a suppressed address is quite literally one you’re forbidden to send to. The caller branches on it exactly the way it branches on any other failure, so there’s no new outcome shape to learn.

Now the decision at the center of this whole lesson. You could imagine a different design: every Server Action that sends email checks the suppression list itself, right before calling sendEmail. Don’t do that. Here’s the shape of the wrong version next to the right one.

welcome.ts
if (await isSuppressed(user.email)) return;
await sendEmail({ to: user.email, subject: 'Welcome', react: <Welcome /> });
// receipt.ts
if (await isSuppressed(customer.email)) return;
await sendEmail({ to: customer.email, subject: 'Receipt', react: <Receipt /> });
// reminder.ts — the call site that forgot
await sendEmail({ to: lead.email, subject: 'Reminder', react: <Reminder /> });

One forgotten call site is a reputation incident. The check has to be repeated at every send, which means it will eventually be missed at the next one someone adds, and that single omission mails a suppressed address in production. “Remember to check” is not a guarantee; it’s a guarantee waiting to be broken.

This is the same reasoning the course applies everywhere correctness has to hold on every path, not most of them. Tenant isolation lives in a tenantDb factory rather than a where org_id = ? clause you remember to add to each query, for exactly this reason: the moment a guarantee depends on a human remembering it at N call sites, it’s already broken at call site N+1. The fix is never “be more careful.” The fix is to make the wrong thing impossible by moving the check to the chokepoint, so skipping it isn’t an option you can accidentally take.

One implementation note, so nobody wires this wrongly later. The suppression SELECT is an ordinary standalone db read. sendEmail is not a transaction, and the read must never end up inside a database transaction that also does other work. The course’s rule is no external calls inside a db.transaction, and the Resend send is one such call, because holding a connection open across a network call to a third party starves the pool. The lookup is a plain query, the send is a plain network call, and neither belongs in a transaction.

Lock in the order of the gate with the exercise below. The fixed code is the gate’s body; drag the four steps into the order they actually run.

These four steps make up the suppression gate inside `sendEmail`. Put them in the order they run. Watch step one — get it wrong and the lookup silently misses the row. Drag the items into the correct order, then press Check.

const email = input.to.toLowerCase().trim();
const [suppression] = await db
.select()
.from(emailSuppressions)
.where(eq(emailSuppressions.email, email))
.limit(1);
if (suppression && !input.bypassSuppression) {
return err('forbidden', 'This address is on the suppression list.');
}
return await resend.emails.send(/* ... */);
Normalize the address — lowercase and trim it
Look the address up in email_suppressions
If a row exists and this send isn’t bypassing, return an err(...) Result without calling Resend
Otherwise, call resend.emails.send

When a database error means “don’t send”: failing closed

Section titled “When a database error means “don’t send”: failing closed”

Here’s the sharpest judgment call in the lesson, and it inverts an instinct most people have. The suppression check is a database read, and database reads can fail: a timeout, a connection blip, the pool momentarily exhausted. What should sendEmail do if the suppression query itself throws?

The tempting answer is to let the email through. The user is waiting, the email is probably fine, and you don’t want to block a real person over a transient database hiccup. This is called failing open , and it quietly destroys the entire discipline. Under any database wobble, your suppression check evaporates and suppressed addresses get mailed, precisely when you’re least likely to notice, because everything looks like it’s working.

The correct call is to fail closed : if the check can’t run, refuse the send. Return err('internal', ...) and surface the error to whoever operates the system. The justification is an asymmetry of consequences. A missed transactional email is recoverable: the user retries the action, or an operator replays the send once the database is healthy. A send to a suppressed address is not recoverable: the bounce or complaint is counted against your reputation the instant it happens, and you can’t un-count it. When the two failure modes cost wildly different amounts, you default to the cheap one. A delayed welcome email is cheap; a complaint on a dead address is not.

You’ve met this principle before, stated generally: every gate that controls access treats an exception inside the check as a refusal. Authorization, tenancy, signature verification, and now suppression are all gates, and the rule is the same for all of them. Wrap the check so that if it throws, the answer is no. In the wrapper, that’s a try/catch around the read whose catch returns a failure rather than letting execution fall through to the send.

lib/email.ts
let suppression;
try {
[suppression] = await db
.select()
.from(emailSuppressions)
.where(eq(emailSuppressions.email, email))
.limit(1);
} catch {
// The check couldn't run, so refuse the send and let the operator see it.
return err('internal', 'Could not send email.');
}

The catch doesn’t swallow the error and continue; it returns. Execution never reaches the Resend call when the check couldn’t run, which is the entire definition of failing closed. (In the real project this catch also logs the failure through the app’s logger so an operator notices the database wobble, not just the user.)

Reason through the scenario below before revealing the answer.

Inside sendEmail, the suppression-list query throws — the database connection timed out. What should the wrapper do?

Send the email anyway — a real user is waiting and the timeout is probably transient
Retry the query three times, and send if it’s still failing
Return a failure Result and log the error for an operator to investigate
Skip the suppression check just for this one send and proceed

You’ve seen bypassSuppression appear in the code twice now. It’s time to explain it, because there are genuine cases where you must send to an address that’s on the suppression list, and handling them well is its own piece of senior judgment. There are two parts: a per-call switch at the wrapper, and a stored window on the row.

The per-call switch is the bypassSuppression?: boolean option on sendEmail. When a caller passes true, the gate skips the short-circuit. Two flows justify reaching for it:

  • A user is verifying a new email address. The verification code has to go through even if that address bounced last month, because the user may have just fixed the mailbox, and the whole point of the flow is to find out. Blocking the verification on an old bounce makes the account unrecoverable.
  • A security-critical alert fires, say a new-device sign-in on an admin account. The cost of not delivering that outweighs the reputation cost of one send to a shaky address.

One framing keeps this safe: bypass is a privilege, granted per flow, explicit in the code, and auditable in review. It is never a default and never a blanket setting. Only a handful of call sites set it, and each one justifies itself with a comment a reviewer can weigh. The verification send looks like this:

// Verification must reach the user even if this address bounced before —
// they may have just fixed the mailbox.
await sendEmail({
to: pendingEmail,
subject: 'Confirm your email',
react: <VerificationEmail code={code} />,
bypassSuppression: true,
});

The stored window is the bypassUntil column you registered in the schema. The boolean answers “is this caller allowed to try?”, while the window answers “is this address temporarily exempt, regardless of who’s sending?” When the webhook chapter ingests a verification flow, it can stamp bypassUntil = now() + 5 minutes on the relevant path, so the immediate re-send is allowed but the exemption expires fast. The reason it’s measured in minutes is a watch-out worth internalizing: a generous window of, say, 24 hours is a door a bulk marketing path can walk through. Long-lived bypasses defeat the suppression list quietly. The production default is minutes, scoped to one flow. Who writes that window is the webhook chapter’s concern; what you need here is why the column is short-lived by design.

There’s one more branch the gate eventually grows, and the manual_unsubscribe reason is why. When a marketing recipient unsubscribes, their address lands in this table with reason = 'manual_unsubscribe'. Marketing sends must honor that row, since that’s the whole point of an unsubscribe. But transactional sends bypass it: you can’t opt out of your own password reset while keeping a working account. So the gate’s real question isn’t “is there a row?” but “is there a row that applies to this kind of send?” That depends on both the explicit bypass flag and the row’s reason, weighed against whether the send is transactional or marketing (the line you drew in the previous lesson). The full marketing implementation is out of scope here; what lands today is the reason the wrapper branches on reason and not merely on a row’s existence.

The complaint-rate budget the team manages against

Section titled “The complaint-rate budget the team manages against”

Step back from the code to the number it all serves. Through their postmaster tools , mailbox providers expose the complaint rate you’re generating, and that number has hard thresholds you manage against like a budget with a redline.

The following gauge shows the three zones.

Healthy < 0.1%
Warning 0.1% – 0.3%
Throttling > 0.3%
The complaint rate is a budget with a redline. Below 0.1% the inbox is reliable; past 0.3% Gmail throttles you, and recovery takes a clean streak rather than a quick fix.

Below 0.1% you’re healthy and inbox placement is reliable. Between 0.1% and 0.3% is the warning zone: providers start steering your newly added recipients toward spam while your engaged, long-standing recipients still see the inbox, so the damage is real but partly hidden. Past 0.3%, which is Gmail’s documented line, you’re throttled . And throttling isn’t a switch that flips back the moment you fix the cause: recovery requires staying under 0.3% for roughly seven consecutive days, and your domain loses delivery-support eligibility for the duration of the violation. A spike is expensive to climb out of.

Who owns this number? It’s shared, and the split is clean. The complaint rate is driven almost entirely by marketing sends, since transactional mail rarely gets reported as spam, because people asked for it. So when the rate climbs, two parallel investigations open. Engineering asks whether suppression is running and whether the table’s write rate is climbing. The team asks what changed in the segment or the copy. Engineering owns the suppression discipline and the data; the team owns the content and who it goes to.

And here’s the senior reach that separates “we found out from Gmail” from “we caught it ourselves.” The postmaster dashboard is lagged: by the time the complaint rate visibly moves there, the damage is days old. But you have a leading indicator sitting in your own database: the rate at which rows are being written to email_suppressions. Bounces and complaints write rows, so a sudden climb in rows-per-day is the first tremor of a reputation problem, visible in the same database dashboard you already watch, days before the postmaster number catches up. Watch the rate of change of your suppression table, not just the lagging number a provider hands you.

That closes the arc of this chapter. The lesson on SPF, DKIM, and DMARC got you allowed into the inbox by authenticating who you are. The lesson on subdomains made sure marketing’s reputation can’t poison the password-reset inbox. And this lesson keeps you in the inbox over time, by obeying the stop signals the providers send back. Authentication is the door; suppression is staying welcome once you’re through it.

One more cheap win before the recap, because the cheapest suppression entry is the one you never create. A typo’d address like user@gnail.com instead of gmail.com will hard-bounce and burn a permanent suppression row for an address that was never real. The pre-emptive defense is to never send to it in the first place.

There are two layers, in order of cost. First, validate the address shape at the form with Zod’s z.email(). You already know this from the forms chapters, and it catches the missing @, the obvious garbage, and the empty field, for free on every signup. Second, for high-stakes flows only, with new-customer signup as the canonical one, an optional MX-record probe at the action layer (dns.resolveMx) checks whether the domain can receive mail at all. That catches the gnail.com class of typo, which is syntactically valid but undeliverable.

Frame this correctly: it reduces the denominator. Fewer garbage addresses means fewer bounces to begin with. But it is emphatically not a substitute for the suppression list, which handles addresses that were real and went bad. For the very highest-stakes signups, third-party verifiers like Kickbox or NeverBounce go further still, scrubbing an address against their own deliverability data before you ever send. That’s the senior reach for a sales-led signup flow, not something you build here.

The whole loop, in short: a webhook writes email_suppressions when a bounce or complaint arrives (the webhook chapter), and the sendEmail wrapper reads it before every send (here). The read fails closed on a database error, because a missed send is recoverable and a send to a suppressed address is not. Bypass is a privilege: explicit per call, time-boxed in the row, never a default. And the complaint rate is a budget you manage against, with your own suppression-table write rate as the leading indicator.

You built one deliverable: the emailSuppressions table and the read-before-send check inside lib/email.ts. That’s the suppression discipline, in code.

Two threads pick up later. The webhook handler that populates this table, by verifying the signature, deduplicating events, and inserting the rows, is built in the later chapter on Stripe and webhook ingestion, which reuses the exact pattern on this exact schema. And the placeholder WelcomeEmail component you’ve been sending all chapter becomes a real, designed template in the next chapter on React Email.