A channel delivers a notification somewhere outside the app. Email through a mailer is one. Any object with a deliver method can be a channel. Register it in channels under a name, then route notification types to it by that name in routes.
The InboxChannel interface
type InboxChannel = {
deliver: (ctx: DeliveryContext) => Promise<void>;
};
type DeliveryContext = {
notification: Notification; // the stored row: title, body, href, data, and so on
user: User; // the better-auth user it's addressed to
appUrl: string | undefined; // the `appUrl` from the inbox options
};deliver sends one notification. Its rules:
- Throw to retry. A thrown error makes the delivery retry with backoff. It is marked
failedaftermaxAttempts, with the error indetail. - Join relative links yourself.
appUrlis there so you can turn a relativehrefinto a full link. - Expect repeats. Deliveries are at-least-once, so a send can repeat after a crash.
A channel’s deliver gets the same { notification, user, appUrl } shape that mailerChannel uses for its render function. See Email if unread for routing a type to a channel.
Throw PermanentDeliveryError (or any error with permanent: true) for a send that will never succeed, such as an invalid address, a hard bounce or a revoked token. The delivery is then marked failed without retrying.
deliver must not change ctx.
Testing a channel
Writing your own channel? Run the InboxChannel contract against it with vitest. The ./testing entry is ESM only, since vitest can’t be require()d:
import { testChannel } from "@byo-oss/notifications/testing";
testChannel(() => slackChannel({ fetch: fakeFetch }), {
failing: () => slackChannel({ fetch: failingFetch }), // checks that a failed send throws
});It checks that deliver resolves for full and minimal notifications and for relative and absolute links. It also checks that deliver doesn’t change its input, and that it throws when the send fails, which is what makes a delivery retry. If your channel reports sends that can never succeed, pass permanentlyFailing too. It checks that those throw a PermanentDeliveryError.
deliveryContext(overrides) builds a sample context for your own tests. The built-in email channels run the same suite.
Email channels
Email is a channel too. mailerChannel({ mailer }), from @byo-oss/notifications/mailer, sends through any Mailer from @byo-oss/mailer:
import { mailerChannel } from "@byo-oss/notifications/mailer";
import { wrapsMailer } from "@byo-oss/mailer/wraps";
// inside inbox({ channels })
channels: {
email: mailerChannel({ mailer: wrapsMailer({ from: "Acme <notify@acme.com>" }) }),
},It takes no from, because the mailer owns the sender. A PermanentMailError from the mailer becomes a PermanentDeliveryError, and any other error retries, even one marked permanent: true. The wrapsEmail channel is deprecated; see Email if unread.