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 — use
mail-foundation directly:
export const APP_FEATURES = [ mailFoundationFeature, mailTransportInMemoryFeature, // ... showPonyFeature,] as const;src/run-config.ts
In rsvp:submit, send best-effort after 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{3}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 { eventTable } 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 events = await ctx.db.selectMany(eventTable); const found = events.find((e) => e.id === payload.eventId)?.title; const title = typeof found === "string" ? found : "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 { createInviteBrandingQuery } 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";
const hostAccess = { access: { openToAll: true } } 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(createInviteBrandingQuery());
r.entity("rsvp", rsvpEntity); r.writeHandler(rsvpSubmitHandler);
r.queryHandler(defineEntityListHandler("rsvp", rsvpEntity, hostAccess)); r.queryHandler(defineEntityDetailHandler("rsvp", rsvpEntity, hostAccess));
r.queryHandler(billingInfoQuery); r.queryHandler(usageQuery);
registerShowPonyScreens(r); registerShowPonyNav(r);});📄 On GitHub: rsvp-confirmation-mail.ts · feature.ts