Notifications
Four features that together form a channel-agnostic notification
stack. delivery sits in the middle; the channel-* features are
swappable backends.
delivery
Status: ✅ Stable
What: Notification delivery pipeline with retries, user preferences (“email yes, push no”), per-recipient rate limits, kill switches. Channel-agnostic — the same call ships through email, in-app, push, and (coming) SMS.
How it works: in code:
await ctx.delivery.send({ to: userId, template: "incident-created", data: { ... } }). The delivery service:
- Reads the recipient’s
notification_preferences(which channels are active for which template category), - Renders the templates per channel (HTML + plain for email, title + body for push, Markdown for in-app),
- Writes an entry into
delivery_attempts, hands off to the channel implementation, - The channel reports success/failure →
delivery_attemptsis updated. Failed attempts go into a retry queue with exponential backoff.
Recipe:
samples/recipes/delivery-notifications
shows a full setup with email + in-app + subscriber-confirm flow.
Example:
import { createDeliveryFeature } from "@kumiko/bundled-features/delivery";import { createChannelEmailFeature, createSmtpTransport } from "@kumiko/bundled-features/channel-email";import { createChannelInAppFeature } from "@kumiko/bundled-features/channel-in-app";
await runDevApp({ features: [ createChannelEmailFeature({ transport: createSmtpTransport({ /* ... */ }) }), createChannelInAppFeature(), createDeliveryFeature(), myFeature, ],});
// Send from a handler — channel-agnosticawait ctx.delivery.send({ to: userId, template: "incident-created", data: { title: incident.title, link: `/incidents/${incident.id}` },});channel-email
Status: ✅ Stable
What: Email backend for delivery. Takes an EmailMessage and
ships it via an SMTP provider (Sendgrid, Mailgun, your own SMTP
server). In-memory variant for tests + dev.
How it works: createSmtpTransport({ host, port, user, pass, secure }) builds a transport that you pass into
createEmailChannel({ transport }) and then into delivery. SMTP
credentials usually come from the
config feature (platform
default or per-tenant override with encrypted: true for the
password) — the example there shows the pattern.
In-memory mode for tests: createInMemoryTransport() collects
all sent mails in an array — integration tests can assert “user X
received an email with template Y” without a real SMTP connection.
Example:
import { createChannelEmailFeature, createSmtpTransport, createInMemoryTransport,} from "@kumiko/bundled-features/channel-email";
// Production: SMTP via env varsconst prodTransport = createSmtpTransport({ host: process.env["SMTP_HOST"]!, port: 587, user: process.env["SMTP_USER"]!, pass: process.env["SMTP_PASS"]!, secure: false, // STARTTLS on 587});
// Test/dev: in-memory — no real SMTP neededconst testTransport = createInMemoryTransport();// testTransport.messages = Array<EmailMessage> after each send
features: [createChannelEmailFeature({ transport: prodTransport }), /* ... */];channel-in-app
Status: ✅ Stable
What: In-app notifications — the bell icon with the red unread dot in the top right. Writes into a table; the frontend polls or pulls via SSE.
How it works: on send a row is written into in_app_messages
with userId, title, body, link, readAt: null. The frontend uses
useInAppMessages() for live updates (SSE subscription on the user
stream). Click on the notification = set readAt + navigate to the
link URL.
API: r.queryHandler for listing, r.writeHandler for “mark
read” / “mark all read”. A custom UI is optional — the default
renderer ships a ready-made <NotificationBell /> component.
Example:
// Serverimport { createChannelInAppFeature } from "@kumiko/bundled-features/channel-in-app";features: [createChannelInAppFeature(), /* ... */];// Frontend: bell icon with live updatesimport { useInAppMessages } from "@kumiko/bundled-features/channel-in-app/web";
function NotificationBell() { const { messages, unread, markRead } = useInAppMessages(); return ( <Popover trigger={<BellIcon badge={unread} />}> {messages.map((m) => ( <a key={m.id} href={m.link} onClick={() => markRead(m.id)}> {m.title} </a> ))} </Popover> );}channel-push
Status: 🚧 Beta
What: Web-push notifications via VAPID keys. Works on Chrome, Firefox, Safari (PWA). Useful for mobile use cases without a native app.
How it works: the browser subscription is stored on the user’s first opt-in (endpoint + keys); a push server pushes JSON notifications to the browser worker. Battery-friendly: no polling.
Beta caveat: the API shape may still change — subscription
storage today is a simple table; could be rebuilt as
r.entity-managed for better audit integration.
Example:
import { createChannelPushFeature } from "@kumiko/bundled-features/channel-push";
await runDevApp({ features: [ createChannelPushFeature({ vapidPublicKey: process.env["VAPID_PUBLIC_KEY"]!, vapidPrivateKey: process.env["VAPID_PRIVATE_KEY"]!, subject: "mailto:ops@example.com", }), /* ... */ ],});// Frontend: user opt-in for browser pushconst reg = await navigator.serviceWorker.ready;const subscription = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY,});await fetch("/api/push/subscribe", { method: "POST", body: JSON.stringify(subscription),});See also
- Bundled-features overview
- Recipe
delivery-notifications - Files & renderer — render email templates