Skip to content
Chapter 48Lesson 4

Reading the suppression list before sending

A Drizzle suppression table and a read-before-send check in the sendEmail wrapper that keep bounced and complained addresses from wrecking your sender reputation.

Picture three sends that all return ok. 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. Each looks successful: the Server Action runs, Resend accepts the call, the dashboard goes green. Each also erodes your ability to reach the inbox, for this user and everyone else you mail.

This lesson installs the check that stops those sends. In the first lesson of this chapter you left a comment in sendEmail: // The suppression check lands here. Today you fill it: the emailSuppressions table, plus a single chokepoint that reads it before every send. The webhook that writes rows when a bounce or complaint arrives comes later; today is the schema and the read.

A bounce is a stop signal from the mailbox provider

Section titled “A bounce is a stop signal from the mailbox provider”

Suppression looks like a politeness feature, a way to avoid pestering people who don’t want your mail. That framing under-builds it.

When an address bounces or someone files a complaint , that is not feedback about your content. It’s the receiving provider, Gmail or Outlook or Yahoo, telling you to stop. The provider remembers whether you listened. Send to that address again and you’ve told them you ignore their signals.

What they down-rank isn’t that one message. Sender reputation is shared across everything you send from a domain, so re-mailing one complained address nudges an unrelated new signup’s welcome email toward the spam folder. You spend everyone’s deliverability to mail someone who can’t receive it.

So the app’s job is mechanical: the instant a stop signal arrives, drop that address from the set you may send to. The question is where that check lives, but first the structure that holds the list.

That structure is a persistent database table, not an in-memory cache or a per-send call to Resend. The list must survive a restart and answer in one fast lookup on every send. A table indexed on the address gives you both.

You’ll build only half of this system today, so start by seeing both halves and the table they share.

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

The write path (built later) inserts rows; every send reads the table and branches on what it finds.

This pattern recurs on every webhook-fed table in the course, so it earns a name: single writer, many readers. Exactly one thing writes to email_suppressions: a webhook handler that receives bounce and complaint events from Resend, verifies them, and inserts rows. Everything else only reads and branches on what it finds. That handler ships in a later chapter; it’s named now so you know where rows come from.

You’re building the reader, but the schema comes first: the reader can’t query columns that don’t exist, and the writer inserts into the shape you define here. That’s where the real decisions live.

Not every negative-sounding email event means “suppress this address,” and that distinction is why each row stores a reason, not just an address. There are three kinds of signal, each with its own rule.

A hard bounce is permanent: the mailbox doesn’t exist, the domain refuses mail, or the recipient is blocked. The address won’t recover on its own, and re-sending to a known-dead address is what providers punish hardest. Suppress on the first occurrence.

A soft bounce is temporary: the mailbox is full, the server hiccupped, or the message got greylisted . The next attempt might land, so suppressing on the first one would lock out reachable users. Suppress only after about five consecutive soft signals to the same address, once it’s behaving like a dead mailbox.

A complaint is the sharpest signal: the recipient got your mail and clicked “report spam.” It can only happen after a successful delivery, and a single complaint costs more reputation than a single bounce, because a human is declaring your mail unwanted. Suppress immediately and permanently.

Three other Resend events sound relevant but never suppress: email.delivered, email.opened, and email.clicked. They’re engagement telemetry, never a reason to stop sending. Don’t expect them in this table.

Sort the events below by how the system should treat them.

Each row is something a mailbox provider can report 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 is why the schema stores a reason, not just an email. A recipient who unsubscribed from marketing is not the same as a dead mailbox, and a password reset must treat them differently.

The email_suppressions table, column by column

Section titled “The email_suppressions table, column by column”

This Drizzle table’s column choices carry the lesson, so step through them one at a time. Each is a decision with a reason.

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: a UUIDv7 from $defaultFn, the course convention for user-facing entities.

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. Its unique constraint answers “is this address suppressed?” in one index lookup on the hot send path, and lets the webhook’s INSERT use ON CONFLICT (email) DO NOTHING to stay idempotent on a redelivered event. The stored value is always normalized, lowercased and trimmed, at write and read. Skip that and User@x.com and user@x.com become two rows the 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(),
});

Last section’s taxonomy, as a pgEnum . Note the names: soft_bounce_threshold, not soft_bounce, since only bounces past the threshold land here; and manual_unsubscribe, kept separate because transactional sends may 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 that created this row. It links the row to the provider event and serves as the webhook’s dedup key, so the event isn’t written twice. Nullable, since a manually-added row has no provider event.

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 window during which this address may be mailed despite being listed. The reason only lands once you’ve seen the read logic; for now, note it exists.

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 as jsonb, so you can debug a suppression without re-querying Resend.

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 defaultNow(), per the course’s convention. created_at marks the first suppression; updated_at moves if the reason changes.

1 / 1

The table lives in db/schema.ts, the single source of truth for the data shape. When the webhook chapter needs a Zod validator, it derives one from this table with drizzle-zod’s createSelectSchema rather than hand-writing it, so parser and schema can’t drift.

Now build the load-bearing part. The exercise has id, reason, and createdAt; add the email column with the right constraints, then watch the unique constraint hold against a real database.

Two edits. Add the email column to email_suppressions as text, not null. Then make it unique with unique().on(table.email) in the table-level constraint list. That single-column unique is the point: one lookup answers 'is this address suppressed?' and the same address can't be stored twice. Two divergences fit the in-browser Postgres: reason is plain text, not a pgEnum, and id uses a simple default, not uuidv7(). The unique constraint is identical to production.

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

The read-before-send check inside sendEmail

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

The table is the memory; the check is the behavior that reads it, filling in the // suppression check lands here comment from the first lesson. Compare the two tabs: the sendEmail skeleton you shipped, then the same function with the gate 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 funnels through this one function, yet right now any address passes straight through to Resend.

The gate is four steps, in order:

  1. Normalize the recipient with input.to.toLowerCase().trim(). It comes first because the table stores addresses normalized: look up raw User@Example.com against a stored user@example.com and you find nothing, then send to a suppressed address. Normalizing first lets 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 bypassing, return a failure Result without calling Resend.
  4. Otherwise, send, exactly as before.

The short-circuit reuses the Result contract your action layer already speaks. A suppressed send is one you’re forbidden to make, so it maps onto the existing forbidden code in lib/result.ts: err('forbidden', 'This address is on the suppression list.').

Why the check lives in the wrapper, not the call site

Section titled “Why the check lives in the wrapper, not the call site”

The alternative is to make every Server Action check the suppression list itself before calling sendEmail. Don’t.

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. A check repeated at every send eventually gets missed at the next one someone adds, and that omission mails a suppressed address in production. “Remember to check” is a guarantee waiting to break.

This is the reasoning behind tenantDb: isolation lives in a factory, not a where org_id = ? clause you add to each query. Once a guarantee depends on someone remembering it at N call sites, it’s broken at site N+1.

One note: the suppression SELECT is a standalone db read, never run inside a db.transaction. The rule is no external calls in a transaction, and the Resend send is one; holding a connection open across a third-party call starves the pool.

Put the four steps of the suppression gate inside `sendEmail` 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

Failing closed when the suppression check throws

Section titled “Failing closed when the suppression check throws”

The suppression check is a database read, and reads fail: a timeout, a connection blip, an exhausted pool. What should sendEmail do if the suppression query itself throws?

The tempting answer is to let the email through: a real user is waiting and the failure is probably transient. This is failing open , and it quietly destroys the discipline. Under any database wobble the check evaporates and suppressed addresses get mailed, exactly when you’re least likely to notice because everything still looks like it works.

The correct call is to fail closed : if the check can’t run, refuse the send. Return err('internal', ...) and surface the error. The reason is an asymmetry of consequences. A missed transactional email is recoverable: an operator replays the send once the database is healthy. A send to a suppressed address is not, because the bounce or complaint counts against your reputation the instant it happens. When two failure modes cost wildly different amounts, default to the cheap one.

Every gate that controls access follows this rule — an exception inside the check counts as a refusal — and suppression joins authorization, tenancy, and signature verification. In the wrapper, that means a try/catch around the read whose catch returns a failure instead of falling 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.');
}

Beyond returning, the catch logs the failure, so an operator notices the database wobble, not just the user.

When you must send to a suppressed address

Section titled “When you must send to a suppressed address”

Sometimes you must send to an address on the suppression list. Two mechanisms cover it: 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. Pass true and the gate skips the short-circuit. Two flows justify it:

  • Verifying a new email address. The code must go through even if that address bounced last month: the user may have just fixed the mailbox, and blocking verification on an old bounce leaves the account unrecoverable.
  • A security-critical alert, say a new-device sign-in on an admin account. The cost of not delivering outweighs the reputation cost of one send to a shaky address.

Treat bypass as a privilege: granted per flow, explicit in code, auditable in review, never a default. Only a handful of call sites set it, each with a comment a reviewer can weigh:

// 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. The boolean asks “is this caller allowed to try?”; the window asks “is this address exempt for now, whoever sends to it?” The webhook chapter can stamp bypassUntil = now() + 5 minutes on a verification path, so an immediate re-send goes through but the exemption expires fast. Keep it to minutes: a 24-hour window lets a bulk marketing path through and quietly defeats the suppression list.

The gate also branches on reason. When a marketing recipient unsubscribes, their address lands here with reason = 'manual_unsubscribe'. Marketing sends must honor that row; transactional sends must override it, since you can’t opt out of your own password reset and keep a working account. So the gate checks not whether a row exists, but whether a row applies to this kind of send, reading the bypass flag and the reason together.

The complaint rate is a budget with a redline

Section titled “The complaint rate is a budget with a redline”

Mailbox providers report the complaint rate you generate through their postmaster tools , and that number has hard thresholds you manage against like a budget.

Healthy < 0.1%
Warning 0.1% – 0.3%
Throttling > 0.3%
Three reputation zones, split by Gmail's two documented thresholds.

The warning zone hides its damage: providers steer your newly added recipients toward spam while engaged, long-standing recipients still see the inbox. Past the redline you’re throttled , and that doesn’t lift the moment you fix the cause. Recovery requires staying under 0.3% for roughly seven consecutive days, with your domain losing delivery-support eligibility throughout. A spike is expensive to climb out of.

Ownership is shared on a clean split. Complaints come almost entirely from marketing sends, since transactional mail is rarely reported as spam, so when the rate climbs two investigations open in parallel. Engineering owns the suppression discipline and the data: is suppression running, is the table’s write rate rising? The team owns the content and reach: what changed in the segment or the copy?

This is what separates “we found out from Gmail” from “we caught it ourselves.” The postmaster dashboard lags: by the time the rate moves there, the damage is days old. But a leading indicator already sits in your database, the rate at which rows land in email_suppressions. A sudden climb in rows-per-day is the first tremor of a reputation problem, days before the postmaster number catches up. Watch the rate of change, not just the lagging number a provider hands you.

The cheapest suppression entry is the one you never create. A typo like user@gnail.com hard-bounces and burns a permanent suppression row for an address that was never real, so the best defense is to never send to it.

Two layers, in order of cost. First, validate the address shape at the form with Zod’s z.email(), from the forms chapters; it catches the missing @, obvious garbage, and the empty field on every signup. Second, for high-stakes flows like new-customer signup, an optional MX-record probe at the action layer (dns.resolveMx) checks whether the domain can receive mail at all, catching the gnail.com typo that is syntactically valid but undeliverable.

This shrinks the denominator: fewer garbage addresses, fewer bounces. It is not a substitute for the suppression list, which handles addresses that were real and went bad. Third-party verifiers like Kickbox or NeverBounce go further still, but that’s senior reach, not something you build here.

You built one deliverable: the emailSuppressions table and the read-before-send check inside lib/email.ts. The read fails closed, because a missed send is recoverable and a send to a suppressed address is not, and bypass stays a privilege: explicit per call, time-boxed in the row, never a default.

Two threads pick up later. The webhook handler that writes this table, by verifying the signature, deduplicating events, and inserting rows, is built in the later chapter on Stripe and webhook ingestion, reusing this exact pattern on this exact schema. And the placeholder WelcomeEmail becomes a real React Email template in the next chapter.