Skip to content
Chapter 70Lesson 2

Email and inbox notification channels

Build the email and inbox notification channels behind one shared signature, so the dispatcher fans out without branching.

The last lesson built the dispatcher’s decision: a promoted member should hear about it on both email and the in-app inbox. But deciding isn’t delivering. This lesson builds the two functions that send the message once the channels are chosen.

Almost nothing here is new. The email channel is a thin call into the sendEmail wrapper from the welcome email, which already owns suppression, the from address, and idempotency. The inbox channel is a single INSERT into the notifications table you designed last lesson. Three ideas hold it together: every channel is a function with the same signature, so the dispatcher loops over them with no if email … else inbox; each channel is a thin adapter over a lower layer, not a reimplementation; and one channel failing never takes down the other.

Both channels export a single function with the identical signature, taking the resolved recipient, the registry entry for the event, the typed payload, and the already-rendered content.

lib/notifications/channels.ts
type ChannelFn = (args: {
recipient: Recipient;
event: NotificationEvent;
payload: Record<string, unknown>;
rendered: RenderedContent;
}) => Promise<void>;
export const sendEmailChannel: ChannelFn = async (args) => {
// …calls the sendEmail wrapper
};
export const writeInboxChannel: ChannelFn = async (args) => {
// …inserts one notifications row
};

The comments name what each stub will do without narrating syntax. NotificationEvent is the dispatch input from last lesson, { type, recipientUserIds, subjectId, payload }, so a channel reads event.type to look up its registry entry (notifiableEvents[event.type]) and event.subjectId for the idempotency key. Recipient and RenderedContent are introduced in the prose below.

Two parts of the signature are worth a closer look. The return type is Promise<void>, not Promise<Result<T>>: a channel doesn’t hand failure back as a value. The dispatcher wraps each call in its own try/catch and owns what happens when one throws, the same choice DispatchResult made last lesson, because the caller has no fail path to branch on. The other is rendered: the dispatcher computes the display content once and passes it down, so neither channel re-derives it. Why that field exists comes further down; for now, take the work as done before the channel function runs.

The uniform signature pays off here. The dispatcher keeps a lookup object keyed by channel name and loops over whichever channels this recipient gets:

lib/notifications/dispatch.ts
const channelFns = {
email: sendEmailChannel,
inbox: writeInboxChannel,
} satisfies Record<ChannelName, ChannelFn>;
for (const channel of channels) {
await channelFns[channel](args);
}

ChannelName is the union of channel keys ('email' | 'inbox'); channels is the recipient’s resolved list.

The loop never branches on channel type. It looks the function up by name and awaits it. To add push notifications later, you write one new file with the same ChannelFn shape and add one entry to channelFns; the loop never changes. A channel becomes a new file, not a new branch threaded through working code.

The signature carries one more implication. The dispatcher hands each channel a resolved recipient, but resolved means resolved to a user, at minimum a userId, not to an email address. The dispatcher has no business knowing which channel wants which identifier, so each channel resolves the rest itself: email turns a userId into an address, while the inbox needs nothing beyond the id. That split is what the next two sections build.

Before you write either function, picture where it sits. Most bugs here come from a channel redoing a job the layer below already owns: re-checking suppression, re-resolving the from address, re-rendering content.

The stack has three layers: the dispatcher on top, the two channel functions below it, and at the bottom the sinks that perform the I/O, the sendEmail wrapper that calls Resend and the raw db.insert. Watch one dispatch call descend a layer at a time.

The dispatcher owns the loop and the try/catch. It has already resolved this recipient's channels and rendered the content, and knows nothing about Resend or SQL, only that it calls one function per channel.
Each channel function is a thin adapter: it translates registry entry, payload, and recipient into the shape its sink expects, adding no policy of its own.
The sinks own the hard parts. The email sink is the wrapper you built; the inbox sink is one indexed insert. A channel calls these, never duplicates them.
The whole stack: two parallel paths, each channel the thin seam between what happened and the shape its sink wants.

A channel’s only job is to call its sink with the right shape. About to write a suppression check inside one? Stop, it lives a layer down.

The email channel: from user id to a transactional send

Section titled “The email channel: from user id to a transactional send”

Read the whole email channel below as a sequence of decisions.

import 'server-only';
export const sendEmailChannel: ChannelFn = async ({
recipient,
event,
payload,
rendered,
}) => {
const to = await getUserEmail(recipient.userId);
const { templates, subject } = notifiableEvents[event.type];
const result = await sendEmail({
to,
subject: subject(payload),
react: templates.email(rendered.emailProps),
idempotencyKey: `${event.type}:${event.subjectId}:${recipient.userId}`,
});
if (!result.ok) {
logger.warn({ eventType: event.type, code: result.error.code }, 'email channel failed');
}
};

The dispatcher passed a user id, but email needs an address. getUserEmail resolves one from Better Auth’s user table. This is the channel-specific resolution the signature hinted at; the inbox channel works in user ids and skips it.

import 'server-only';
export const sendEmailChannel: ChannelFn = async ({
recipient,
event,
payload,
rendered,
}) => {
const to = await getUserEmail(recipient.userId);
const { templates, subject } = notifiableEvents[event.type];
const result = await sendEmail({
to,
subject: subject(payload),
react: templates.email(rendered.emailProps),
idempotencyKey: `${event.type}:${event.subjectId}:${recipient.userId}`,
});
if (!result.ok) {
logger.warn({ eventType: event.type, code: result.error.code }, 'email channel failed');
}
};

The template and subject come from the registry entry, which makes a rephrase or a translation a one-file edit instead of a hunt through channel code.

import 'server-only';
export const sendEmailChannel: ChannelFn = async ({
recipient,
event,
payload,
rendered,
}) => {
const to = await getUserEmail(recipient.userId);
const { templates, subject } = notifiableEvents[event.type];
const result = await sendEmail({
to,
subject: subject(payload),
react: templates.email(rendered.emailProps),
idempotencyKey: `${event.type}:${event.subjectId}:${recipient.userId}`,
});
if (!result.ok) {
logger.warn({ eventType: event.type, code: result.error.code }, 'email channel failed');
}
};

Notice what’s absent from the send: no from, no suppression check, no deliverability handling. The wrapper owns those, defaulting from from the env and reading the suppression list internally. The react prop passes the email component element for it to render.

import 'server-only';
export const sendEmailChannel: ChannelFn = async ({
recipient,
event,
payload,
rendered,
}) => {
const to = await getUserEmail(recipient.userId);
const { templates, subject } = notifiableEvents[event.type];
const result = await sendEmail({
to,
subject: subject(payload),
react: templates.email(rendered.emailProps),
idempotencyKey: `${event.type}:${event.subjectId}:${recipient.userId}`,
});
if (!result.ok) {
logger.warn({ eventType: event.type, code: result.error.code }, 'email channel failed');
}
};

The wrapper requires an idempotency key. Derive it from the event’s identity, the type, subject, and recipient, so a retried dispatch collapses at Resend rather than mailing the same person twice.

import 'server-only';
export const sendEmailChannel: ChannelFn = async ({
recipient,
event,
payload,
rendered,
}) => {
const to = await getUserEmail(recipient.userId);
const { templates, subject } = notifiableEvents[event.type];
const result = await sendEmail({
to,
subject: subject(payload),
react: templates.email(rendered.emailProps),
idempotencyKey: `${event.type}:${event.subjectId}:${recipient.userId}`,
});
if (!result.ok) {
logger.warn({ eventType: event.type, code: result.error.code }, 'email channel failed');
}
};

The wrapper returns a Result. An expected failure (forbidden, internal) is logged and swallowed, not thrown, and the channel returns Promise<void> either way. A bad email send must not erase the inbox row, the independence the next section covers.

1 / 1

Dispatcher notifications are transactional mail, so they go out on the transactional sender the wrapper defaults from the env and carry no List-Unsubscribe header: you can’t unsubscribe from being told your password changed and still have a working account. The opt-out for notifications is the per-category preference a user toggles in settings (“mute team email”), which lives in the dispatcher and is the next lesson’s subject. The urge to add an unsubscribe link to a notification email is the signal you’ve confused the marketing channel with the transactional one.

The inbox channel is thinner still: it writes one row to notifications and returns. No joins, no fan-out, no second statement.

lib/notifications/channels.ts
import 'server-only';
export const writeInboxChannel: ChannelFn = async ({
recipient,
event,
payload,
rendered,
}) => {
await db.insert(notifications).values({
userId: recipient.userId,
orgId: rendered.orgId,
eventType: event.type,
subjectId: event.subjectId,
title: rendered.inbox.title,
body: rendered.inbox.body,
payload,
});
};

createdAt is omitted because the column defaults to now().

title and body carry the decision. They come from rendered.inbox, the output of the registry’s inbox formatter, a per-event-type (payload) => { title, body } function. This makes last lesson’s render-at-dispatch rule concrete: the formatter ran in the dispatcher, and its strings are now frozen onto this row. payload goes in as raw structured data, the live ids the UI needs when the user clicks through.

The inbox feed reads where userId = ? order by createdAt desc. On a table that only grows, that sort without an index scans everything on every load. Ship the index with the table:

lib/db/schema/notifications.ts
export const notifications = pgTable('notifications', {
// …columns from the previous lesson
}, (t) => [
index('idx_notifications_user_created').on(t.userId, t.createdAt.desc()),
]);

Name it explicitly (idx_notifications_user_created): auto-generated names drift on schema reorderings and make migration diffs noisy. The columns lead with userId, what you filter on, then createdAt.desc() to match the feed’s sort, so Postgres reads straight down the index instead of sorting afterward.

The rest of the inbox is read queries:

lib/notifications/queries.ts
// Unread badge: one count, no join
db.$count(notifications, and(eq(notifications.userId, userId), isNull(notifications.readAt)));
// Mark as read: one update, stamped by the database
db.update(notifications).set({ readAt: sql`now()` }).where(eq(notifications.id, id));
// Inbox feed: indexed scan, newest first, cursor-paginated for older pages
db.select().from(notifications).where(eq(notifications.userId, userId))
.orderBy(desc(notifications.createdAt)).limit(50);

None of these need a join, because the text was snapshotted. That is render-at-dispatch paying off: an inbox that never reaches for live data to display.

The naive version renders the message inside each channel: both the email and inbox functions resolve the actor’s name and format the role. That duplicates the logic, so the two can drift, one saying “Promoted to admin” and the other “Role changed to Admin,” and resolves the same names twice per notification.

The fix is the rendered field you’ve carried since the signature: the dispatcher renders the display content once, then passes it to every channel.

export const sendEmailChannel: ChannelFn = async ({ payload }) => {
const actorName = await getUserName(payload.changedBy);
// …builds the email from actorName
};
export const writeInboxChannel: ChannelFn = async ({ payload }) => {
const actorName = await getUserName(payload.changedBy);
// …builds the inbox row from actorName
};

Duplicated and drift-prone. Each channel re-derives actorName from its own read, so the two can disagree and the lookup runs twice.

Each channel still shapes its own output: the email body is a React Email component handed to the wrapper’s react prop, the inbox body is the formatter’s plain string. Both consume the same resolved values, so neither returns to the database for a name. This is render-at-dispatch from last lesson on a different axis: render-at-dispatch fixes the message in time, render-once fixes it across channels.

Independent channels: one failure doesn’t stop the others

Section titled “Independent channels: one failure doesn’t stop the others”

One rule keeps a notification system standing on a bad day: the dispatcher calls each channel inside its own try/catch, logs the failure, and continues. An email send that hits a Resend 5xx leaves the inbox row standing; an inbox INSERT that hits a database error leaves the email already sent. Channels are eventually consistent with each other by design, and no single channel’s failure may fail the user action that triggered it.

The loop, now with the boundary that enforces it:

lib/notifications/dispatch.ts
for (const channel of channels) {
try {
await channelFns[channel]({ ...args, rendered });
sent += 1;
} catch (error) {
logger.error({ channel, eventType: event.type, error }, 'channel send failed');
}
}

When a channel throws, the catch logs a structured line and the loop moves on; dispatch still returns the DispatchResult reporting what went out. There are no retries: a failed channel is logged and dropped. Retries that survive a process crash mean moving the channel sends behind Trigger.dev, the durable-queue upgrade deferred from last lesson; at this volume, a logged failure is enough.

Two rules from last lesson bite hardest here. First, fire after commit. A notification for an action that rolled back is worse than a missed one: it tells the user something happened when nothing did. So dispatch runs after the action’s db.transaction commits, never inside it. Second, a softer preference: insert the inbox row before sending the email, so a user clicking through from the email doesn’t land on an empty inbox. The channels are independent and best-effort, so this is an ordering nicety, not a guarantee.

Now put the pipeline in order.

Order the steps for one notification, from the triggering action to the dispatcher's report. Watch the two rules: dispatch fires after commit, and the inbox row goes in before the email. Drag the items into the correct order, then press Check.

// the action's server function
await db.transaction(async (tx) => {
await tx.update(members).set({ role }).where(/* … */);
});
// only now, outside the transaction:
await dispatch({ type: 'org.member.role_changed', subjectId, payload });
The action mutates rows inside db.transaction
The transaction commits
The dispatcher renders the display content once
writeInboxChannel inserts the inbox row
sendEmailChannel sends the email via the wrapper
The dispatcher logs and returns the DispatchResult

Worked example: a role change fires both channels

Section titled “Worked example: a role change fires both channels”

The registry entry for 'org.member.role_changed' declares both channels and now carries an inbox formatter beside the email template.

'org.member.role_changed': {
channels: ['email', 'inbox'],
templates: {
email: roleChangedEmail,
inbox: ({ newRole }) => ({
title: `Your role changed to ${newRole}`,
body: `An admin updated your role in the organization.`,
}),
},
subject: ({ newRole }) => `Your role is now ${newRole}`,
preferenceCategory: 'team',
dedup: { windowSeconds: 60, keyBy: ['subjectId'] },
description: 'A member’s role changed',
},

The source of truth. templates now groups both renderers, the email component and the inbox formatter. The subject lives here too, so rephrasing it is a one-line edit.

Trace one recipient. The dispatcher renders { newRole, changedBy } into rendered once. writeInboxChannel inserts the snapshot row, with title: 'Your role changed to admin' and the body frozen as text; sendEmailChannel calls the wrapper with the roleChangedEmail element and the registry’s subject. The result: one inbox row written, one email queued.

The next lesson adds notification preferences, read once inside the dispatcher, so a recipient can opt out where opt-outs actually live rather than in an email header. The lesson after adds 60-second dedup, collapsing a burst of duplicate events into one notification.