mail-foundation
Defines the mailTransport extension point and a per-tenant provider config key that selects which registered transport plugin to use at runtime. Call createTransportForTenant(ctx, tenantId) to get an EmailTransport ready for sending — use this feature together with at least one mail-transport-* feature; use delivery + channel-email instead when you need the full notification pipeline with delivery attempts and user preferences.
Quick example
Section titled “Quick example”From apps-cap-billing-demo — the smallest working mount:
// kumiko-feature-version: 1//// newsletter — Demo-Feature, das tier-engine + cap-counter + die// mail-foundation Plugin-API zusammenbringt.//// **Was passiert beim send-Handler:**// 1. Pre-Call (via withCapEnforcement-Wrapper):// a) Tenant-Tier aus ctx.config lesen// b) Tier → cap-limit aus DEMO_TIER_MAP mappen// c) enforceCapAndMaybeNotify ruft enforceCap → bei// soft-hit-crossing den Notifier (sendet Warning-Mail über// dieselbe mail-foundation, an den Admin) UND dispatched// mark-soft-warned// 2. Inner Handler: createTransportForTenant → transport.send// 3. Post-Success: increment-cap-Counter um 1//// **Beobachtbar im InMemory-Inbox** (mail-transport-inmemory):// - Die "echten" Newsletter-Mails (an event.payload.to)// - Die Soft-Hit-Warning-Mails (an [email protected], beim ersten// überschreiten)//// **Tier-Switching:** primary-source ist die `subscription`-row aus// billing-foundation (= produktiver Pfad: Stripe/Mollie webhook// schreibt → tier ändert sich). Fallback ist der config-key// "newsletter:config:tier" — den nutzt die Demo-README für manuelles// Switchen ohne Provider, plus tier-engine-only-Tests behalten so ihren// existing flow.
import { BILLING_FOUNDATION_FEATURE, getSubscriptionForTenant, SubscriptionStatuses,} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";import { currentCalendarMonthStartIso, type SoftHitNotifier, withCapEnforcement,} from "@cosmicdrift/kumiko-bundled-features/cap-counter";import type { EmailMessage } from "@cosmicdrift/kumiko-bundled-features/channel-email";import { createTransportForTenant, mailFoundationFeature,} from "@cosmicdrift/kumiko-bundled-features/mail-foundation";import { access, createTenantConfig, defineFeature, type HandlerContext, type WriteHandlerDef,} from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";import { DEMO_TIER_MAP, TIER_NAMES, type TierName } from "./tier-map";
const FEATURE_NAME = "newsletter";const NEWSLETTER_CAP = "newsletters-per-month";
// =============================================================================// Inner send-handler// =============================================================================
const sendSchema = z.object({ to: z.string().email(), subject: z.string().min(1), html: z.string().min(1),});
const innerSendHandler: WriteHandlerDef = { name: "send", schema: sendSchema, access: { roles: ["TenantAdmin", "SystemAdmin"] }, handler: async (event, ctx) => { const payload = event.payload as z.infer<typeof sendSchema>; const transport = await createTransportForTenant( ctx, event.user.tenantId, "newsletter:write:send", ); await transport.send({ to: payload.to, subject: payload.subject, html: payload.html, }); return { isSuccess: true as const, data: { sent: true } }; },};
// =============================================================================// Tier-Lookup + Cap-Resolver// =============================================================================
/** * Tenant-Tier auflösen. * * **Reihenfolge:** * 1. subscription-row aus billing-foundation (= produktiver * Pfad). Wenn die row existiert, trumpft sie den config-fallback — * auch im canceled-Status (= Tenant fällt auf free zurück, NICHT * auf einen verwaisten config="pro"-override). * 2. config-key "newsletter:config:tier" (= Demo-README-Pfad ohne * Provider; auch der tier-engine-only-Pfad der ursprünglichen * Tests bleibt so grün). * 3. Default "free". * * Whitelist-Filter via TIER_NAMES verhindert Tippos ("Pro" / "Premium"), * die sonst silent zu free fallen würden. */function isValidTierName(value: string): value is TierName { return (TIER_NAMES as readonly string[]).includes(value);}
async function resolveTier(ctx: HandlerContext): Promise<TierName> { const tenantId = ctx.user?.tenantId; if (tenantId) { const sub = await getSubscriptionForTenant(ctx, tenantId); if (sub) { // Subscription existiert → trumpft config. Bei active+valid-tier // → der subscription-tier; sonst free (canceled/past_due/etc). if (sub.status === SubscriptionStatuses.active && isValidTierName(sub.tier)) { return sub.tier; } return "free"; } }
const raw = (await ctx.config?.("newsletter:config:tier")) as string | undefined; if (raw && isValidTierName(raw)) { return raw; } return "free";}
/** Notifier-Factory: bauen einen SoftHitNotifier der die Warnung über * dieselbe mail-foundation an den Tenant-Admin schickt. */function buildSoftHitNotifier(ctx: HandlerContext): SoftHitNotifier { return async (info) => { const transport = await createTransportForTenant( ctx, info.tenantId, "newsletter:soft-hit-notifier", ); const message: EmailMessage = { to: `admin@tenant-${info.tenantId.slice(-4)}.demo`, subject: `[Cap Warning] '${info.capName}' bei ${info.value}/${info.limit}`, html: `<p>Hallo Admin,</p>` + `<p>der Cap <strong>${info.capName}</strong> für deinen Tenant ist bei <strong>${info.value}</strong> von ${info.limit} angekommen.</p>` + `<p>Du bist im soft-Bereich (110% des Limits). Hard-Block kommt bei 120%. Upgrade auf einen höheren Tier oder warte auf den nächsten Monatsreset.</p>`, }; await transport.send(message); };}
// =============================================================================// Wrapped send-handler (cap-aware)// =============================================================================
const wrappedSendHandler = withCapEnforcement(innerSendHandler, async (_event, ctx) => { const tier = await resolveTier(ctx); // resolveTier returns one of TIER_NAMES; DEMO_TIER_MAP has an entry // for each. tsc's `noUncheckedIndexedAccess` doesn't carry that // narrowing through a Record-lookup — extract via const + non-null // assert (= contract: tier is always a valid key here, see TIER_NAMES // whitelist in resolveTier). const tierEntry = DEMO_TIER_MAP[tier]; if (!tierEntry) { throw new Error(`newsletter: tier "${tier}" not in DEMO_TIER_MAP — TIER_NAMES drift?`); } const limit = tierEntry.caps.newslettersPerMonth; return { capName: NEWSLETTER_CAP, periodStartIso: currentCalendarMonthStartIso(), limit, profile: "burstable", notify: buildSoftHitNotifier(ctx), };});
// =============================================================================// Feature-definition// =============================================================================
export const newsletterFeature = defineFeature(FEATURE_NAME, (r) => { // Hard-deps: mail-foundation für Plugin-API + config für tier-Wahl + // cap-counter (transitiv via withCapEnforcement, aber explicit ist // klarer für boot-Validator-Errors). r.requires("config"); r.requires("cap-counter"); r.requires(mailFoundationFeature.name); r.requires(BILLING_FOUNDATION_FEATURE);
// Tier-config-key. Tenant-Admin setzt's; default "free". r.config( "tier", createTenantConfig("select", { default: "free", options: TIER_NAMES, write: access.roles("TenantAdmin", "SystemAdmin"), read: access.roles("TenantAdmin", "SystemAdmin", "User"), }), );
r.writeHandler(wrappedSendHandler);});
/** QN für den send-Handler — exportiert damit Tests + Clients ohne * Hand-Ableitung zugreifen. */export const NEWSLETTER_SEND_QN = "newsletter:write:send";export const NEWSLETTER_TIER_CONFIG_KEY = "newsletter:config:tier";📄 On GitHub: samples/apps/cap-billing-demo/src/feature.ts
How it fits
Section titled “How it fits”What this feature needs to run (Requires, top) and the write commands it provides (Provides, bottom).
flowchart TB
n_mail_foundation["mail-foundation"]
subgraph how_reqs["Requires"]
n_config["config"]
end
n_config --> n_mail_foundation
Getting started
Section titled “Getting started”Start with apps-cap-billing-demo for a step-by-step walkthrough with runnable code and integration tests.
Dependencies
Section titled “Dependencies”- Requires:
config - Activation: always on (not toggleable)
Configuration
Section titled “Configuration”Per-tenant config keys, set via the tenant-admin UI or a seed. 🔒 = encrypted at rest.
| Key | Type | Default | Scope | Who can write | Who can read |
|---|---|---|---|---|---|
provider | text | "" | tenant | TenantAdmin, SystemAdmin | TenantAdmin, SystemAdmin, User |