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-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-polyfill/global";
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 { createTransportForTenant } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";import { escapeHtml } from "@cosmicdrift/kumiko-headless";import type { z } from "zod";import type { rsvpSubmitSchema } from "../handlers/rsvp-submit.write";import { findEvent } from "../schema/event";import type { RsvpStatus } from "../schema/rsvp";
const RSVP_STATUS_LABELS: Record<RsvpStatus, string> = { yes: "Coming", no: "Not coming", maybe: "Maybe",};
// Best-effort confirmation mail to the guest (only when they gave an email).// mail-foundation DIRECTLY, not delivery: delivery is user-centric// (recipient = userId → email lookup), and our guest is anonymous, addressable// only by the email they just typed — the low-level transport is the fit.// kumiko-lint-ignore lib-test-coverage integration-covered via rsvp-anonymous.integration.test.tsexport async function sendRsvpConfirmation( ctx: HandlerContext, tenantId: TenantId, payload: z.infer<typeof rsvpSubmitSchema>,): Promise<void> { // skip: guest left email empty — nothing to send if (!payload.email) return; const found = await findEvent(ctx, (row) => row.id === payload.eventId); const title = found?.title ?? "your event"; const transport = await createTransportForTenant(ctx, tenantId, "showpony:write:rsvp:submit"); await transport.send({ to: payload.email, subject: `Your RSVP for "${title}"`, html: `<p>Hi ${escapeHtml(payload.name)},</p>` + `<p>your reply (<strong>${escapeHtml(RSVP_STATUS_LABELS[payload.status])}</strong>) for ` + `"${escapeHtml(title)}" is in. Thanks!</p>`, });}Full feature (handlers + screens):
// show-pony — event + RSVP feature (server registration only).
import { mailFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";import { defineEntityDetailHandler, defineEntityListHandler, defineFeature,} from "@cosmicdrift/kumiko-framework/engine";import { billingInfoQuery } from "./handlers/billing-info.query";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 { 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 Admin rather than openToAll: show-pony has no dedicated// organizer role yet, and Admin is the same privileged role billing-info// and usage already gate on.const rsvpReadAccess = { access: { roles: ["Admin"] } } as const;
export { eventEntity, rsvpEntity, rsvpTable } from "./schema";
export const showPonyFeature = defineFeature("showpony", (r) => { r.requires(mailFoundationFeature.name, "config", "managed-pages");
r.config({ keys: INVITE_BRANDING_KEYS });
r.translations({ keys: showPonyTranslations });
r.entity("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("rsvp", rsvpEntity); r.writeHandler(rsvpSubmitHandler);
r.queryHandler(defineEntityListHandler("rsvp", rsvpEntity, rsvpReadAccess)); r.queryHandler(defineEntityDetailHandler("rsvp", rsvpEntity, rsvpReadAccess));
r.queryHandler(billingInfoQuery); r.queryHandler(usageQuery);
registerShowPonyScreens(r); registerShowPonyNav(r);});📄 On GitHub: rsvp-confirmation-mail.ts · feature.ts