delivery
The notification dispatch core: call ctx.notify(notificationType, { to, route, data, priority, idempotencyKey }) from any handler to fan out a notification across all registered channels (email, in-app, push). It stores per-user channel preferences in the notification-preference entity, opt-outs for no-account recipient addresses in notification-address-opt-out (keyed by a blind-index hash, never the plaintext address), logs every attempt to store_delivery_attempts, and enforces idempotency and rate-limiting — add channel-email, channel-in-app, or channel-push on top to actually send anything. Unsubscribe/resubscribe links are served by createUnsubscribeRoutes({ secret }) mounted via the app’s extraRoutes at /api/delivery/unsubscribe and /api/delivery/resubscribe: GET /unsubscribe?token= renders a confirmation page (no write), POST /unsubscribe performs the opt-out (RFC 8058 one-click, token from the form body or query), POST /resubscribe undoes it (JSON {token} or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with signUnsubscribeToken / signAddressUnsubscribeToken using the same secret. channel-email sets List-Unsubscribe / List-Unsubscribe-Post automatically when a message’s data.unsubscribeUrl points at the unsubscribe route.
Quick example
Section titled “Quick example”From recipes-delivery-notifications — the smallest working mount:
// Delivery Notifications Sample//// Shows how a feature sends notifications via multiple channels (inApp + email + push).// Uses r.notification() for declarative notifications with per-channel templates.//// Flow: Admin assigns a support ticket to a user → the user gets notified// - InApp: toast + badge in the app// - Email: full HTML with rendered content// - Push: native notification//// The feature code only declares WHAT to notify. HOW is handled by Delivery.
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";
// --- Entity ---
export const ticketEntity = createEntity({ table: "read_sample_delivery_tickets", fields: { title: createTextField({ personal: false, reason: "is_business_data", required: true, maxLength: 200, }), description: createTextField({ personal: false, reason: "is_business_data", maxLength: 2000 }), assigneeId: createTextField({ personal: "ref" }), priority: createTextField({ personal: false, reason: "is_business_data", required: true }), // "low" | "normal" | "critical" status: createTextField({ personal: false, reason: "is_business_data", required: true }), },});
export const ticketTable = buildEntityTable("ticket", ticketEntity);
function ticketExecutor() { return createEventStoreExecutor(ticketTable, ticketEntity, { entityName: "ticket" });}
// --- Feature ---
export const supportFeature = defineFeature("support", (r) => { r.requires("delivery");
r.entity("ticket", ticketEntity);
// Real CRUD handler (not stub) — returns SaveContext for lifecycle hooks const createHandler = r.writeHandler( "ticket:create", z.object({ title: z.string().min(1), description: z.string().optional(), assigneeId: z.uuid().optional(), priority: z.enum(["low", "normal", "critical"]), status: z.string().default("open"), }), async (event, ctx) => ticketExecutor().create(event.payload, event.user, ctx.db), { access: { roles: ["Admin", "Support"] } }, );
// Declarative notification: fires automatically after ticket.create postSave. // // - recipient: returns assignee ID, or null to skip (no assignee = no notification) // - data: extracts raw fields from the save result // - templates: per-channel transformations // inApp → short title/body for toast // email → structured template (header, sections, button) for renderer // push → short title/body for native notification r.notification("ticket-assigned", { trigger: { on: createHandler }, recipient: (result) => { const assigneeId = result.data["assigneeId"] as string | undefined; return assigneeId ?? null; }, data: (result) => ({ ticketId: result.id, title: result.data["title"] as string, description: (result.data["description"] as string) ?? "", priority: result.data["priority"] as string, }), templates: { inApp: (data) => ({ title: `Neues Ticket: ${data["title"]}`, body: (data["description"] as string) || "Dir wurde ein Ticket zugewiesen.", }), email: (data) => ({ subject: `Support-Ticket #${data["ticketId"]} (${data["priority"]})`, header: `Neues Ticket: ${data["title"]}`, sections: [ { text: (data["description"] as string) || "Kein Beschreibungstext." }, { text: `Prioritaet: ${data["priority"]}` }, { button: { label: "Ticket oeffnen", url: `/support/tickets/${data["ticketId"]}`, }, }, ], footer: "Automatische Benachrichtigung — nicht antworten.", }), push: (data) => ({ title: "Neues Ticket", body: `${data["title"]} (${data["priority"]})`, }), }, });});📄 On GitHub: samples/recipes/delivery-notifications/src/feature.ts
How it fits
Section titled “How it fits”What this feature needs to run (Requires, top) and the write commands this feature provides (Provides, bottom).
flowchart TB
n_delivery["delivery"]
subgraph how_reqs["Requires"]
n_tenant["tenant"]
end
subgraph how_provides["Provides"]
n_cmd_delivery_write_resubscribe_address(["resubscribe-address"])
n_cmd_delivery_write_resubscribe_user(["resubscribe-user"])
n_cmd_delivery_write_set_preference(["set-preference"])
n_cmd_delivery_write_unsubscribe_address(["unsubscribe-address"])
n_cmd_delivery_write_unsubscribe_user(["unsubscribe-user"])
end
n_tenant --> n_delivery
n_delivery --> n_cmd_delivery_write_resubscribe_address
n_delivery --> n_cmd_delivery_write_resubscribe_user
n_delivery --> n_cmd_delivery_write_set_preference
n_delivery --> n_cmd_delivery_write_unsubscribe_address
n_delivery --> n_cmd_delivery_write_unsubscribe_user
Provides — write commands this feature registers (dispatch them through the command bus):
delivery:write:resubscribe-addressdelivery:write:resubscribe-userdelivery:write:set-preferencedelivery:write:unsubscribe-addressdelivery:write:unsubscribe-user
Getting started
Section titled “Getting started”Start with recipes-delivery-notifications for a step-by-step walkthrough with runnable code and integration tests.
Dependencies
Section titled “Dependencies”- Requires:
tenant - Activation: always on (not toggleable)