Route a notification type to email, and it is emailed only if nobody has read or seen it in the app first. The in-app row is always written. Email is a follow-up to it.
Install and configure
Email goes through a mailer from @byo-oss/mailer. Install it yourself, since your app imports its adapter, along with the SDK for your provider. For Wraps, that’s @wraps.dev/email:
npm install @byo-oss/mailer @wraps.dev/emailimport { inbox } from "@byo-oss/notifications";
import { mailerChannel } from "@byo-oss/notifications/mailer";
import { wrapsMailer } from "@byo-oss/mailer/wraps";
inbox({
appUrl: "https://app.acme.com", // turns href "/settings/billing" into a link
channels: {
// the mailer owns the sender address, so the channel takes no `from`
email: mailerChannel({ mailer: wrapsMailer({ from: "Acme <notify@acme.com>" }) }),
},
routes: {
// in-app now; email after 15 minutes if still unread, or right away if the user isn't in the app
"billing.payment_failed": [{ channel: "email", ifUnreadAfter: { minutes: 15 } }],
// in-app and email, both right away; the email goes out even if it's read first
"security.new_login": [{ channel: "email", cancelIfRead: false }],
// at most one email an hour per user, however often it fires
"run.failed": [{ channel: "email", ifUnreadAfter: { minutes: 15 }, throttle: { hours: 1 } }],
},
});wrapsMailer sends through your own AWS SES account. See the Wraps page for from, region, credentials and client. Other providers use the same mailerChannel:
- Your own SES client.
sesMailerfrom@byo-oss/mailer/sestakes anSESv2Clientyou already have. Install@aws-sdk/client-sesv2too. See your own SES client. - Resend, Postmark, SendGrid and others.
emailSdkMailerfrom@byo-oss/mailer/email-sdktakes a client you build with@opencoredev/email-sdk(npm install @opencoredev/email-sdk). Passtelemetry: falsetocreateEmailClient, as the email-sdk page does.emailSdkMailerthrows aTypeErrorwhen it is created if the client holds an adapter that is not on that page’s list.
In tests, memoryMailer from @byo-oss/mailer records messages instead of sending them.
Routes
routes maps each notification type to a list of delivery rules. Each rule names a channel from channels. A type with no rule stays in-app. Use "*" as a fallback for every type that has no entry of its own. Every field of a rule is listed on the Options page.
The email channel
mailerChannel({ mailer, replyTo?, render? }), from @byo-oss/notifications/mailer, sends through any Mailer. It takes no from, because the mailer owns the sender. replyTo sets a reply-to address. render replaces the default email (title, body and a View button) with ({ notification, user, appUrl }) => ({ subject, html, text }).
A PermanentMailError from the mailer fails the delivery at once, as PermanentDeliveryError. Any other error retries with backoff. An error marked permanent: true that isn’t a PermanentMailError retries too: the mailer’s contract says only a PermanentMailError means fail. These are the mailer’s rules in Delivery semantics.
A channel is any object with a deliver({ notification, user, appUrl }) method that throws on failure, so other providers are a few lines. See Custom channels.
A throw retries. Throw PermanentDeliveryError (or any error with permanent: true) for a send that can never succeed, such as an invalid address, and the delivery fails at once.
SES doesn’t report a bounce or a suppressed address as a send error. It accepts the email, and the bounce arrives later as an event. Those deliveries show as sent. To stop emailing an address that bounces, check the SES suppression list in your own code.
wrapsEmail (deprecated)
wrapsEmail from @byo-oss/notifications/wraps still works, but it is deprecated and will be removed in a later breaking release. For new code, use mailerChannel with wrapsMailer, as above. See Upgrading.
wrapsEmail sends through @wraps.dev/email from your own AWS SES account, resolving credentials from the environment. Pass client: new WrapsEmail({ region, ... }) to configure it. Pass render: ({ notification, user, appUrl }) => ({ subject, html, text }) to replace the default email, which shows the title, the body and a View button.
wrapsEmail does this for a recipient it can’t send to. When @wraps.dev/email rejects to as missing or malformed, the delivery is marked failed at once, with the SDK’s message in detail. Throttling, network errors and SES errors still retry, and so do account and sender problems (sending paused, an unverified sender domain, missing credentials), because fixing them clears the backlog without touching the recipient.
Read and seen both cancel it
Clicking a notification marks it read. An unread row in the open panel is marked seen (seenAt) once at least half of it has been on screen for a second while the tab is visible. Rows below the scroll, or a panel flicked open and shut, don’t count.
Seen doesn’t touch the badge or read, but it does cancel a pending email. For alerts whose email is the record, such as security events, set cancelIfRead: false and it goes out regardless.
Throttle alerts that repeat
With throttle: { hours: 1 }, a user gets at most one email of that type per hour. Later ones are skipped (with detail: "throttled") and stay in the inbox. The window counts from the last email actually sent. It holds within one process. Two sweeps running at once in separate processes can each send one.
“In the app” is a heartbeat
<InboxButton> polls the unread count every 30 seconds while the tab is visible, and each poll records the user as present. An admin impersonating the user (better-auth’s admin plugin) doesn’t count.
A user with no poll in the last 2 minutes (presenceWindow) is away. An ifUnreadAfter email goes out right away for an away user instead of waiting. Set sendNowIfAway: false on a rule to always wait. With pollInterval: 0, or on pages without the inbox mounted, users count as away.
Run the sweep on a schedule
A plugin can’t run jobs. Delayed emails and retries go out when you call processDeliveries from your own scheduler, every minute or so:
// Vercel Cron, a worker, a queue: anything that calls this
export async function GET() {
const result = await auth.api.processDeliveries({});
return Response.json(result); // { sent, skipped, retrying, failed, hasMore }
}Emails that are due right away (an away user, a rule with no delay) are sent inside notify when better-auth has a background task handler (advanced: { backgroundTasks: { handler: waitUntil } }) and the batch is 25 or fewer. Otherwise, including large org fan-outs, they go out on the next sweep.
notify called inside a better-auth transaction (runWithTransaction) never sends inside the call. Its delivery rows commit with the transaction, and the next sweep sends them once it has committed. An email is never sent for a notification whose transaction rolls back, so the sweep must be running for these emails to go out.
Each sweep handles batchSize deliveries (100 by default) and returns hasMore when more are due.
hasMore can also be true with nothing due to send: the sweep stopped at limit stale sends with no attempts left after failing rows there. The rest stay sending until a later sweep fails them. Rows the sweep can’t update (row-level security, say) still count against limit, so once limit of them (up to 180) come first in scan order, later exhausted rows wait.
Delivery guarantee
Delivery is at-least-once. Each delivery is claimed atomically before it’s sent, so overlapping sweeps don’t double-send.
A failed send retries with backoff: 1 minute, doubling, up to an hour. It is marked failed after maxAttempts (5 by default).
A worker that dies mid-send has its delivery retried five minutes later. A crash after SES accepted the email can therefore send it twice.
Opting out
There are no built-in preferences yet. shouldDeliver: ({ notification, user, channel }) => boolean | Promise<boolean> lets you check your own before each send. Return false to skip the delivery.
Checking what went out
Every delivery is a row in notificationDelivery, with its status and the reason it was skipped or the last error. See Schema.