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.

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:

  1. Resolves recipients (to as user id, list of ids, or { tenant }),
  2. Applies per-user notification-preference rows (which channels are on),
  3. Hands each channel a rendered payload (via the registered renderer),
  4. 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 handler
await ctx.notify("incident-created", {
to: userId,
data: { title: incident.title, link: `/incidents/${incident.id}` },
});
// Declarative: fires after a write handler's postSave
export 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"]) }),
},
});
});

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 }), /* ... */];

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 dispatcher
await ctx.query(InAppQueries.inbox, {});
await ctx.write(InAppHandlers.markRead, { id: messageId });

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;
},
}),
/* ... */
],
});