Mail and calendar
Two small guest-facing extras on the public invite page: a confirmation email when they leave an address, and an Add to calendar link with no server round-trip.

Mail, anonymous recipient
Section titled “Mail, anonymous recipient”delivery is user-centric (recipient = userId). Guests have no account, mount
mail-foundation in buildAppFeatures and call it from the RSVP handler:
export function buildAppFeatures(routing: AppFeaturesRouting): FeatureDefinition[] { return [ // … mailFoundationFeature, mailTransportInMemoryFeature, // … showPonyFeature, ];}src/run-config.ts.
In rsvp:submit, send the confirmation after the create. Transport errors must not fail
the RSVP. Guest name and event title are escaped with escapeHtml from
@cosmicdrift/kumiko-renderer/headless before HTML interpolation (same helper the
bundled mail renderers use).
await sendRsvpConfirmation(ctx, event.user.tenantId, event.payload).catch(() => undefined);src/features/show-pony/feature.ts.
The in-memory transport buffers per tenant; integration tests read via getInbox.
Add to calendar : .ics with no server
Section titled “Add to calendar : .ics with no server”An .ics file is text; the browser downloads it from a data:text/calendar URI:
<a href={icsHref(event)} download={`${event.slug}.ics`}> Add to calendar</a>src/public/EventPage.tsx.
Two details the unit test pins: UTC timestamp format (YYYYMMDDTHHMMSSZ) and
escaping commas/semicolons in titles.
bun test src/public/__tests__/ics.test.tsThe complete files
Section titled “The complete files”Calendar helper:
// "Add to calendar" — a standard VCALENDAR, generated client-side as a// data URI. No server endpoint, no signing: an .ics is plain text and// the browser downloads it directly.
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
import type { PublicEvent } from "./api";
// ISO → ICS timestamp: 2026-07-18T18:00:00.000Z → 20260718T180000Zfunction icsDate(iso: string): string { return Temporal.Instant.from(iso) .toString() .replace(/[-:]/g, "") .replace(/\.\d+Z$/, "Z");}
// Escape ICS text values: backslash, semicolon, comma, newline.function esc(value: string): string { return value .replace(/\\/g, "\\\\") .replace(/[;,]/g, (m) => `\\${m}`) .replace(/\n/g, "\\n");}
export function buildIcs(event: PublicEvent): string { // Default duration 2h — the event model only stores startsAt; an end field // would be a separate step if anyone needs it. const end = Temporal.Instant.from(event.startsAt).add({ hours: 2 }).toString(); return [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//show-pony//EN", "BEGIN:VEVENT", `UID:${event.id}@show-pony`, `DTSTAMP:${icsDate(event.startsAt)}`, `DTSTART:${icsDate(event.startsAt)}`, `DTEND:${icsDate(end)}`, `SUMMARY:${esc(event.title)}`, event.location ? `LOCATION:${esc(event.location)}` : "", event.description ? `DESCRIPTION:${esc(event.description)}` : "", "END:VEVENT", "END:VCALENDAR", ] .filter(Boolean) .join("\r\n");}
export function icsHref(event: PublicEvent): string { return `data:text/calendar;charset=utf-8,${encodeURIComponent(buildIcs(event))}`;}Full feature (mail + handlers):
import { mailHeading, mailParagraph, mailSubject,} from "@cosmicdrift/kumiko-bundled-features/shared/mail-template-body";import { type HandlerContext, type NotificationLocalizedTemplate, type TemplateVariables, templateVariable,} from "@cosmicdrift/kumiko-framework/engine";import { InternalError } from "@cosmicdrift/kumiko-framework/errors";import type { z } from "zod";import type { rsvpSubmitSchema } from "../handlers/rsvp-submit.write";import type { EventRow } from "../schema/event";import type { RsvpStatus } from "../schema/rsvp";
export const RSVP_CONFIRMATION_NOTIFICATION = "rsvp-confirmation";export const RSVP_CONFIRMATION_TYPE = "showpony:notify:rsvp-confirmation";
export const RSVP_CONFIRMATION_VARIABLES: TemplateVariables = { eventTitle: templateVariable.text({ required: true, example: "Rooftop Launch Party" }), answer: templateVariable.text({ required: true, example: "Coming" }),};
const MAIL_LOCALES = ["en", "de"] as const;type MailLocale = (typeof MAIL_LOCALES)[number];
function isMailLocale(language: string): language is MailLocale { return (MAIL_LOCALES as readonly string[]).includes(language);}
function resolveMailLocale(locale: string | undefined): MailLocale { const language = (locale ?? "").split("-")[0]?.toLowerCase() ?? ""; return isMailLocale(language) ? language : "en";}
type MailStrings = { readonly subject: string; readonly heading: string; readonly body: string; readonly answers: Readonly<Record<RsvpStatus, string>>;};
const MAIL_STRINGS: Readonly<Record<MailLocale, MailStrings>> = { en: { subject: 'Your RSVP for "{eventTitle}"', heading: "Your reply is in", body: 'Your reply ({answer}) for "{eventTitle}" is in. Thanks!', answers: { yes: "Coming", no: "Not coming", maybe: "Maybe" }, }, de: { subject: "Deine Antwort für „{eventTitle}“", heading: "Deine Antwort ist angekommen", body: "Deine Antwort ({answer}) für „{eventTitle}“ ist angekommen. Danke!", answers: { yes: "Ich komme", no: "Ich komme nicht", maybe: "Vielleicht" }, },};
export function rsvpConfirmationTemplate(): NotificationLocalizedTemplate { return Object.fromEntries( MAIL_LOCALES.map((locale) => { const strings = MAIL_STRINGS[locale]; return [ locale, { body: mailHeading(strings.heading) + mailParagraph(strings.body), meta: { subject: mailSubject(strings.subject), category: "transactional" as const }, }, ]; }), );}
// The guest's name stays out of the mail: the address is unverified, so any text// in it could be planted by whoever typed someone else's email.// Best-effort: a mail problem must not fail the RSVP.// kumiko-lint-ignore lib-test-coverage integration-covered via rsvp-anonymous.integration.test.tsexport async function sendRsvpConfirmation( ctx: HandlerContext, payload: z.infer<typeof rsvpSubmitSchema>, eventRow: EventRow,): Promise<void> { // skip: guest left email empty — nothing to send if (!payload.email) return; const locale = resolveMailLocale(ctx.locale); try { if (!ctx.notify) { throw new InternalError({ message: "ctx.notify unavailable — mount the delivery feature" }); } await ctx.notify(RSVP_CONFIRMATION_TYPE, { route: { email: payload.email }, data: { eventTitle: eventRow.title, answer: MAIL_STRINGS[locale].answers[payload.status] }, priority: "normal", locale, }); } catch (err) { ctx.log?.warn("[showpony:rsvp-submit] confirmation mail failed", { error: err instanceof Error ? err.message : String(err), }); }}Full feature (handlers + screens):
// show-pony — event + RSVP feature (server registration only).
import { access, defineEntityDetailHandler, defineEntityListHandler, defineFeature,} from "@cosmicdrift/kumiko-framework/engine";import { eventBySlugQuery } from "./handlers/event-by-slug.query";import { eventCreateHandler, eventDeleteHandler, eventDetailHandler, eventListHandler, eventUpdateHandler,} from "./handlers/event-handlers";import { inviteBrandingQuery } from "./handlers/invite-branding.query";import { rsvpSubmitHandler } from "./handlers/rsvp-submit.write";import { usageQuery } from "./handlers/usage.query";import { showPonyTranslations } from "./i18n";import { INVITE_BRANDING_KEYS } from "./invite-branding";import { RSVP_CONFIRMATION_NOTIFICATION, RSVP_CONFIRMATION_VARIABLES, rsvpConfirmationTemplate,} from "./lib/rsvp-confirmation-mail";import { registerShowPonyNav } from "./register/nav";import { registerShowPonyScreens } from "./register/screens";import { eventEntity, rsvpEntity } from "./schema";
// RSVP rows carry guest PII (name/email/note) — unlike event CRUD, this is// restricted to the admin roles (TenantAdmin/Admin/SystemAdmin) rather than// openToAll: show-pony has no dedicated organizer role yet, and role checks// have no hierarchy, so every admin role is listed explicitly.const rsvpReadAccess = { access: { roles: access.admin } } as const;
export { eventEntity, rsvpEntity, rsvpTable } from "./schema";
export const showPonyFeature = defineFeature("showpony", (r) => { r.requires({ features: ["config", "managed-pages"] });
r.config({ keys: INVITE_BRANDING_KEYS });
r.translations({ keys: showPonyTranslations });
r.notification(RSVP_CONFIRMATION_NOTIFICATION, { variables: RSVP_CONFIRMATION_VARIABLES, localizedTemplate: rsvpConfirmationTemplate, });
r.entity({ name: "event", ...eventEntity }); r.writeHandler(eventCreateHandler); r.writeHandler(eventUpdateHandler); r.writeHandler(eventDeleteHandler); r.queryHandler(eventListHandler); r.queryHandler(eventDetailHandler);
r.queryHandler(eventBySlugQuery); r.queryHandler(inviteBrandingQuery);
r.entity({ name: "rsvp", ...rsvpEntity }); r.writeHandler(rsvpSubmitHandler);
r.queryHandler( defineEntityListHandler("rsvp", rsvpEntity, { ...rsvpReadAccess, description: "Lists the tenant's guest replies with name, email, attendance status (yes, no or maybe) and number of additional guests; Admin only, because the rows carry guest personal data.", }), ); r.queryHandler( defineEntityDetailHandler("rsvp", rsvpEntity, { ...rsvpReadAccess, description: "Reads one guest reply by id with the guest's name, email, note, attendance status (yes, no or maybe) and number of additional guests; Admin only, because these fields are guest personal data.", }), );
r.queryHandler(usageQuery);
registerShowPonyScreens(r); registerShowPonyNav(r);});📄 On GitHub: rsvp-confirmation-mail.ts · feature.ts