Skip to content

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:

  1. Reads the recipient’s notification_preferences (which channels are active for which template category),
  2. Renders the templates per channel (HTML + plain for email, title + body for push, Markdown for in-app),
  3. Writes an entry into delivery_attempts, hands off to the channel implementation,
  4. The channel reports success/failure → delivery_attempts is 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-agnostic
await 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 vars
const 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 needed
const 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:

// Server
import { createChannelInAppFeature } from "@kumiko/bundled-features/channel-in-app";
features: [createChannelInAppFeature(), /* ... */];
// Frontend: bell icon with live updates
import { 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 push
const 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