Skip to content

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.

Public page with calendar link and optional email field

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,
];
}
From 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);
From src/features/show-pony/feature.ts.

The in-memory transport buffers per tenant; integration tests read via getInbox.

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>
From src/public/EventPage.tsx.

Two details the unit test pins: UTC timestamp format (YYYYMMDDTHHMMSSZ) and escaping commas/semicolons in titles.

Terminal window
bun test src/public/__tests__/ics.test.ts

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 → 20260718T180000Z
function 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.ts
export 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