Custom channels

Write your own delivery channel, such as push, SMS or Slack, next to the in-app inbox, and how the delivery retries work.

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

ts
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 failed after maxAttempts, with the error in detail.
  • Join relative links yourself. appUrl is there so you can turn a relative href into 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:

lib/slack-channel.test.ts
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:

lib/auth.ts
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.

Edit this page on GitHub ↗@byo-oss/notifications 0.11.4