Notifications
Unified notifications — one send API across email / Slack / SMS / mobile push / web push / in-app, with per-user channel preferences, an in-app inbox, and delivery records.
@voltro/plugin-notifications is the one messaging answer instead of twenty brand wrappers: a single send across channels, per-user preferences, and an in-app inbox with unread counts — not a per-vendor SDK in every handler.
Wiring
// app.config.ts
import { notificationsPlugin, consoleChannel, webhookChannel, emailChannel } from '@voltro/plugin-notifications'
export default {
type: 'api' as const,
name: 'api',
plugins: [
notificationsPlugin({
channels: [
consoleChannel(), // dev: writes to stdout
emailChannel((m) => myMailer(m)), // bridge to @voltro/plugin-mail
webhookChannel({ url: process.env.SLACK_WEBHOOK! }),// slack/teams/discord incoming webhook
],
}),
],
}The built-in in-app channel persists to the notification store and is appended automatically; consoleChannel(), webhookChannel({ url, id?, format?, headers? }), emailChannel(send), smsChannel(send), pushChannel({ tokensFor, transport, onTokenRejected? }) (see Push), webPushChannel() (see Web Push), and customChannel(id, deliver) cover the rest. A channel is just { id, deliver: (msg) => Promise<void> } — bring your own.
notificationsPlugin({ channels?, store?, digestWindowMs?, flushIntervalMs?, name? }) — the inbox, per-subject channel preferences, and delivery log auto-persist to the framework DataStore by default, durably, on every supported dialect. You only pass an explicit store for a custom backend (see Store). digestWindowMs enables digest/batching; the scheduled flush (interval flushIntervalMs, default 30s) drains digest windows and quiet-hours deferrals.
Sending — NotificationService
import { NotificationService } from '@voltro/plugin-notifications'
export default (input: { userId: string }, _ctx) => Effect.gen(function* () {
const notify = yield* NotificationService
yield* Effect.promise(() => notify.send({
to: input.userId,
category: 'order.shipped',
title: 'Your order shipped',
body: 'Track it in your account.',
// channels?: ['email', 'inApp'] — narrow this send to specific channels
}))
return { ok: true }
})resolveChannels picks the effective channel set: an explicit channels: on the send narrows to those (intersected with the configured channels); else every configured channel — then any channel the user has turned off for that category (a stored ChannelPreference with enabled: false) is dropped. A missing preference means on. Each delivery is recorded as a DeliveryRecord (channel, status, error?).
Push (APNs / FCM)
pushChannel is a first-class mobile-push channel — the built-in alternative to hand-rolling customChannel:
import { pushChannel, PushTokenRejected } from '@voltro/plugin-notifications'
pushChannel({
tokensFor: async (subjectId) => myDeviceTokens(subjectId), // your device-token table
transport: async (payload) => {
// payload: { token, title, body, badge?, data } — the APNs / FCM shape.
const res = await sendToApns(payload) // your provider + AUTH secret live HERE
if (res.status === 410) throw new PushTokenRejected({ token: payload.token, reason: 'Unregistered' })
},
})Delivery is per token and isolated: each device token sends independently, and the delivery log records one row PER TOKEN (endpoint names it) — a stale token on one old phone no longer aborts the send to every current device, and the channel counts delivered when at least one token was reached. A PushTokenRejected thrown by your transport marks that token's record failed (naming the token — never the auth secret) and fires the optional onTokenRejected(token, reason) hook, which is where your app prunes the dead token from its own table. The push AUTH secret lives in your transport closure — it never enters the package and is never logged.
Web Push (VAPID)
webPushChannel() delivers real browser push — a user subscribes once and receives notifications with the tab closed. Subscriptions are managed by the plugin itself (per subject AND per browser endpoint, in _voltro_notification_push_subscriptions), payloads are encrypted per RFC 8291, auth is VAPID (RFC 8292), and dead endpoints are pruned automatically.
// app.config.ts
notificationsPlugin({
channels: [webPushChannel({ contact: 'mailto:ops@example.com' })],
})The key is minted, never shipped. The channel signs with VOLTRO_VAPID_PRIVATE_KEY (a base64url P-256 scalar); voltro dev mints a per-project value into the gitignored .env.local on first boot, and a production boot without one refuses by name — there is deliberately no default. The browser-facing public key is DERIVED from the private scalar, so a public/private pair can never desync.
Setup, three steps:
- Configure the channel (above). Web push requires HTTPS in production (localhost is exempt).
- Copy the service worker into your web app:
cp node_modules/@voltro/plugin-notifications/sw.js public/sw.js. Its URL decides its scope — served from the root it covers the whole app. It shows the notification, opens the declaredurlon click, reports the click, and posts the payload to open pages ({ type: 'voltro:push' }message) so an in-page inbox can refresh live. - Offer the subscribe flow with the hook:
import { useWebPush } from '@voltro/plugin-notifications/web'
const PushSettings = () => {
const push = useWebPush()
if (push.status === 'unsupported') return <p>This browser cannot receive push.</p>
return push.status === 'subscribed'
? <button onClick={() => void push.unsubscribe()}>Disable push</button>
: <button onClick={() => void push.subscribe()}>Enable push</button>
}The semantics, precisely:
- Multi-endpoint is the design, not an edge case. Three browsers = three endpoint rows for one subject. Delivery is per endpoint and isolated; the delivery log records one row per endpoint. A re-subscribe on the same endpoint takes the row over — the endpoint belongs to the browser profile, and the latest signed-in subject owns it.
- Prune is automatic and exact. A push service answering 404/410 for one endpoint deletes exactly that row; the subject's other browsers keep receiving. No app-side prune code.
- Payload cap ~4 KB. Push services cap the encrypted body; an oversized payload SHRINKS (the
databag first, then the body is truncated) rather than being dropped. - Click tracking is built in. Each delivered notification carries a one-time click token; the service worker's
notificationclickreports it and the delivery record gainsclickedAt— open rates read straight off the delivery log. - Preferences, quiet hours and digests apply unchanged — the channel is a sender like any other (its preference key is
webPush). - iOS Safari, honestly: web push works on iOS 16.4+ ONLY for installed home-screen web apps (PWA), never in the browser tab. Do not promise iOS coverage from a plain website.
- Scheduled send is a recipe, not a switch:
defineSchedule+sendcovers "notify at 9am" without a second delivery queue. Quiet hours and digests already defer within their own semantics.
Subscribe/unsubscribe are RPC mutations (notifications.webPushSubscribe / webPushUnsubscribe — subject-bound, so one subject can never detach another's browser); notifications.webPushPublicKey hands the browser its applicationServerKey; notifications.webPushStatus counts the caller's registered endpoints.
Digest / batching
Set digestWindowMs > 0 and multiple sends to the same subject within the window coalesce into one digest delivery, flushed on the window boundary:
notificationsPlugin({ digestWindowMs: 5 * 60_000 }) // 5-minute rollupThree sends to u1 inside the window produce ONE digest-category notification whose body lists all three and whose data.items carries them. A send that forces its own channels: bypasses the digest (explicit intent → deliver now). The scheduled flush (interval flushIntervalMs) delivers each window on its boundary.
Quiet hours (per-subject DND)
Each subject can set a Do-Not-Disturb window; a send during the window is held and delivered after (default) or dropped:
import { NotificationService } from '@voltro/plugin-notifications'
export default (_input, _ctx) => Effect.gen(function* () {
const notify = yield* NotificationService
// 22:00 → 08:00 in the subject's zone; minutes past local midnight.
yield* Effect.promise(() => notify.setQuietHours({
subjectId: 'u1', startMinute: 22 * 60, endMinute: 8 * 60, tz: 'Europe/Berlin', policy: 'hold',
}))
return { ok: true }
})Windows may wrap midnight. policy: 'hold' defers the send to the window close (delivered by the scheduled flush); policy: 'drop' discards it. Subjects self-manage from the browser with useQuietHours().
Broadcast / topic fan-out
send is single-recipient. To reach N subscribers of a topic in one call, subscribe subjects to a topic and broadcast:
import { NotificationService } from '@voltro/plugin-notifications'
export default (_input, _ctx) => Effect.gen(function* () {
const notify = yield* NotificationService
yield* Effect.promise(() => notify.subscribe('release-notes', 'u1'))
const result = yield* Effect.promise(() => notify.broadcast('release-notes', {
category: 'news', title: 'v2 shipped', body: 'Read the changelog.',
}))
return { recipients: result.recipients } // one call → every subscriber
})Each fan-out send still honours that subject's preferences + quiet hours. Subjects self-subscribe from the browser with useTopicSubscription().
Inbox + preferences (client)
The plugin ships routes — notifications.inbox, unreadCount, markRead, preferences, setPreference, plus the self-service subscribe / unsubscribe (topics) and setQuietHours / clearQuietHours (DND) — and matching hooks:
import {
useInbox, useUnreadCount, useMarkRead, useSetNotificationPreference,
useTopicSubscription, useQuietHours,
} from '@voltro/plugin-notifications/web'
const inbox = useInbox() // InboxItem[]
const unread = useUnreadCount() // number — drives the badge
const markRead = useMarkRead()
const setPref = useSetNotificationPreference()
const topics = useTopicSubscription() // { subscribe, unsubscribe }
const quiet = useQuietHours() // { set, clear }The plugin OWNS the
notifications.*route tags (notifications.inbox,notifications.unreadCount,notifications.markRead,notifications.preferences,notifications.setPreference,notifications.subscribe,notifications.unsubscribe,notifications.setQuietHours,notifications.clearQuietHours). An app must NOT also author its ownnotifications.*procedures — boot fails with a tag-collision error. Use the plugin OR hand-roll your own, never both.
Inbox states — read is not archived
readAt and archivedAt are two states, not one, and the distinction is the
one users care about: archiving is what EMPTIES the inbox.
notifications.markRead // one item read
notifications.markUnread // …and back again
notifications.markAllRead // → { count } — how many CHANGED, not how many exist
notifications.archive // out of the inbox
notifications.unarchive // back into itArchiving does not mark an item read. An archived-but-unread item still
counts toward unreadCount, so a UI can show what the user actually did rather
than a state the framework inferred for them.
All five are scoped to the calling subject: an inbox action never reaches another subject's row because an id happens to be guessable.
Store
Durable by default — no config. The plugin contributes six tables via its extendSchema and migrates them automatically: _voltro_notification_inbox, _voltro_notification_preferences, _voltro_notification_deliveries, _voltro_notification_topic_subscriptions (broadcast fan-out set), _voltro_notification_quiet_hours (per-subject DND window), and _voltro_notification_held (the digest/quiet-hours held queue). Once the app's store exists, the plugin auto-binds a dataStoreNotificationStore over those tables (via the plugin bindDataStore hook) and declares the store:write permission for them — so the inbox, per-subject channel preferences, and delivery log all persist to the framework DataStore on every supported dialect.
The append-only tables are bounded by the framework retention sweep: _voltro_notification_deliveries (time-TTL, 90d default), _voltro_notification_inbox (read-aware — unread items survive, 180d default), and _voltro_notification_held (safety-net on flushAt, 7d default; the scheduled flush normally drains a held row the moment it is due).
The in-memory store is just the dev/test fallback before the DataStore is bound. You only pass an explicit store for a custom backend:
import { dataStoreNotificationStore, memoryNotificationStore } from '@voltro/plugin-notifications'
// dataStoreNotificationStore(store) — the durable default the plugin auto-binds.
// memoryNotificationStore() — the in-process dev/test fallback.The store is the swap point; the service + channels are storage-agnostic.
Dashboard panel
Both dashboards ship a Notifications panel (api apps): the delivery log (channel → recipient, category, sent/failed status) with per-channel + sent/failed counts, plus a per-subject inbox lookup. Read-only. Backed by /_voltro/inspect/plugins/notifications/{deliveries,inbox} (permission inspect:read).
Whose inbox is it — resolveSubjectId
notificationsPlugin({
// Your addressing unit, not ours. An employee, a member, a contact —
// something that need not have an auth user. It almost always lives in a
// TABLE, so the resolver may be async.
resolveSubjectId: (ctx) => resolveCallerEmployeeId(ctx),
})By default an inbox belongs to subject.id. That is the framework's answer and
it is not always yours: a shift change, an absence request or a task reminder is
addressed to a PERSON, and your app may key that person by its own id.
Return your own id here and the whole surface follows — inbox, unreadCount,
markRead, archive, preferences, quiet hours.
Read this before you reach for it
The subject is whatever signs in. If your addressing unit is not that, you are addressing something nobody can read.
That sentence was written by the team that adopted this option and then reversed
it, and it is the correction to an argument this page used to make. The earlier
version justified resolveSubjectId with a measured number: rows belonging to
people who had no auth user, which employee-keying would "reach" and subject.id
would not. The number was correct and the conclusion was backwards. An inbox
belongs to whoever can OPEN it, and only an account can open one. Keying by
employee did not deliver those rows — it made them look addressed, and charged a
translation on every read path and every push for the privilege.
So the question to ask first is not "what is our addressing unit" but "can the thing I am addressing sign in?" If it cannot, this option gives you rows nobody will ever see. Translate at the SENDING seam instead — once, where the producer knows both ids — and leave the inbox keyed by the account.
resolveSubjectId remains right for the case it was built for: an app whose
sign-in identity genuinely IS its own id (a member, a contact, a tenant user)
rather than the framework's subject.id. That is a different situation from
having a second identity that some accounts happen to map to.
The resolver may be async, and usually has to be. An app that has its own
addressing unit keeps it in a table — an employee, a member and a contact are
all rows. If the mapping were in the token, this seam would not be needed at
all: subject.id would already be the right id. Return a string, a
Promise<string>, or undefined/Promise<undefined>; a sync resolver still
works unchanged.
It is deliberately not cached for you. A per-connection cache is the obvious next step and it is yours to make: the first call would decide the answer for the life of the connection, so a member created a second after connect resolves to the fallback until reconnect. You know your invalidation; the framework does not. Memoise inside your resolver if that trade is right for your app.
Absent keeps subject.id, so nothing changes for an app whose units line up.
Returning undefined — or throwing, or a rejected promise, if your resolver
reaches a database — falls back the same way rather than failing the read.
Permissions
store:write (in-app inbox + delivery records) + inspect:read (dashboard panel).