Notifications
Four features that together form a channel-agnostic notification
stack. delivery sits in the middle; the channel-* features are
swappable backends.
delivery
Section titled “delivery”Status: ✅ Stable
What: Notification delivery pipeline with retries, per-user channel preferences, attempt logging, and optional idempotency. Channel-agnostic : the same call fans out through email, in-app, and push once those channels are registered.
How it works: From any handler call
await ctx.notify(notificationType, { to, data, … }). Prefer declarative
templates via r.notification(...) when a write handler should notify
automatically after save. Delivery:
- Resolves recipients (
toas user id, list of ids, or{ tenant }), - Applies per-user
notification-preferencerows (which channels are on), - Hands each channel a rendered payload (via the registered renderer),
- Logs every attempt to
store_delivery_attempts.
Recipe:
samples/recipes/delivery-notifications
shows r.notification with in-app + email + push templates.
Example:
import { createDeliveryFeature } from "@cosmicdrift/kumiko-bundled-features/delivery";import { createChannelEmailFeature, createSmtpTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";import { createChannelInAppFeature } from "@cosmicdrift/kumiko-bundled-features/channel-in-app";import { createRendererSimpleFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
await runDevApp({ features: [ createRendererSimpleFeature(), createChannelEmailFeature({ transport: createSmtpTransport({ /* ... */ }) }), createChannelInAppFeature(), createDeliveryFeature(), myFeature, ],});
// Imperative send from a handlerawait ctx.notify("incident-created", { to: userId, data: { title: incident.title, link: `/incidents/${incident.id}` },});
// Declarative: fires after a write handler's postSaveexport const supportFeature = defineFeature("support", (r) => { r.requires("delivery"); const createTicket = r.writeHandler(/* ... */);
r.notification("ticket-assigned", { trigger: { on: createTicket }, recipient: (result) => (result.data["assigneeId"] as string | undefined) ?? null, data: (result) => ({ title: result.data["title"] as string }), templates: { inApp: (data) => ({ title: `Ticket: ${data["title"]}`, body: "Assigned to you." }), email: (data) => ({ subject: `Ticket: ${data["title"]}`, header: String(data["title"]) }), }, });});channel-email
Section titled “channel-email”Status: ✅ Stable
What: Email backend for delivery. Takes a rendered email message and
ships it via SMTP (or an in-memory transport for tests/dev).
How it works: createSmtpTransport({ host, from, port?, secure?, auth? })
builds a transport you pass into createChannelEmailFeature({ transport }).
For env-based wiring use createSmtpTransportFromEnv(). Tenant-specific SMTP
settings usually live in config;
credentials with rotation/audit belong in secrets.
In-memory mode for tests: createInMemoryTransport() collects sent
mails so integration tests can assert delivery without a real SMTP server.
Example:
import { createChannelEmailFeature, createSmtpTransport, createInMemoryTransport,} from "@cosmicdrift/kumiko-bundled-features/channel-email";
const prodTransport = createSmtpTransport({ host: process.env["SMTP_HOST"]!, port: 587, secure: false, auth: { user: process.env["SMTP_USER"]!, pass: process.env["SMTP_PASS"]!, },});
const testTransport = createInMemoryTransport();
features: [createChannelEmailFeature({ transport: prodTransport }), /* ... */];channel-in-app
Section titled “channel-in-app”Status: ✅ Stable
What: In-app inbox, rows the UI can list as a notification bell /
message list. Writes into in_app_messages; clients query via the
feature’s handlers.
How it works: On send, delivery writes a row (userId, title, body,
link, readAt: null). Clients use channel-in-app:query:inbox /
unread-count and mark read via channel-in-app:write:mark-read /
mark-all-read (see InAppQueries / InAppHandlers).
Example:
import { createChannelInAppFeature, InAppHandlers, InAppQueries,} from "@cosmicdrift/kumiko-bundled-features/channel-in-app";
features: [createChannelInAppFeature(), /* ... */];
// Client / custom screen: list + mark read through the dispatcherawait ctx.query(InAppQueries.inbox, {});await ctx.write(InAppHandlers.markRead, { id: messageId });channel-push
Section titled “channel-push”Status: 🚧 Beta
What: Push delivery channel. Bring your own PushTransport (FCM, APNs,
web-push adapter, …) plus a resolver that maps a user id to a device token.
createInMemoryPushTransport() is available for tests.
How it works: createChannelPushFeature({ transport, resolveToken })
registers the push delivery channel. Delivery calls resolveToken per
recipient, then transport.send({ token, title, body, data }).
Beta caveat: token storage and vendor adapters are app-owned, this feature only wires the delivery channel contract.
Example:
import { createChannelPushFeature, createInMemoryPushTransport,} from "@cosmicdrift/kumiko-bundled-features/channel-push";
await runDevApp({ features: [ createChannelPushFeature({ transport: createInMemoryPushTransport(), // or your FCM/APNs adapter resolveToken: async (userId, { db, tenantId }) => { // look up the device token for this user in your store return null; }, }), /* ... */ ],});See also
Section titled “See also”- Bundled-features overview
- Recipe
delivery-notifications - Files & renderer,
renderer-simplefor email HTML