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 — use mail-foundation directly:

export const APP_FEATURES = [
mailFoundationFeature,
mailTransportInMemoryFeature,
// ...
showPonyFeature,
] as const;
— from 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);
— 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-polyfill/global";
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{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.ts
export 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