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.

Chat channels (Slack, Discord, Microsoft Teams, Telegram) are separate features with the same contract; see Delivery chat channels.

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.

Attempt log: the log keeps enough of a recipient address to show where a message went. It stores the address masked:

AddressStored as
Emaila***@example.com
http/https URL (webhooks)https://hooks.example.com/***
Anything else, 8 characters or longer*** plus the last 4 characters
Shorter values and encrypted values***

A daily job (attempt-log-retention, 03:15) deletes attempt events and log rows older than 90 days. Change the window with createDeliveryFeature({ attemptLogRetentionDays: 30 }), or pass false to keep the log forever. Rows written before 0.352.0 still hold full addresses; the retention job removes them after the window, and forgetting a user erases that user’s rows earlier.

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 { composeRendererStack } from "@cosmicdrift/kumiko-dev-server";
import { defineFeature, templateVariable } from "@cosmicdrift/kumiko-framework/engine";
await runDevApp({
features: [
// template-resolver + renderer-foundation + renderer-mail-html
...composeRendererStack(),
createChannelEmailFeature({ transport: createSmtpTransport({ /* ... */ }) }),
createChannelInAppFeature(),
createDeliveryFeature(),
supportFeature,
],
});
export const supportFeature = defineFeature("support", (r) => {
r.requires("delivery");
// The mail is a stored template; the boot seed writes this default text.
r.notification("ticket-assigned", {
variables: { title: templateVariable.text({ required: true }) },
template: { en: { body: '<p>Assigned to you: <span data-variable="title"></span></p>' } },
meta: { en: { subject: "Ticket: {{title}}" } },
templates: {
inApp: (data) => ({ title: `Ticket: ${data["title"]}`, body: "Assigned to you." }),
},
});
});
// Send from a write handler; delivery runs after the commit.
await ctx.notify(TICKET_ASSIGNED, { to: assigneeId, data: { title: ticket.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,
from: "noreply@example.com",
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;
},
}),
/* ... */
],
});