Email System
All email is delivered via Resend. Templates are stored in D1 and are admin-editable from the /admin/templates panel without code changes. Every email type is a named function in src/lib/email/templates.ts — each fetches its template from D1, renders it with the correct variables, and dispatches via the Resend client.
Architecture
The six email types
| Template key | Triggered by | Key variables |
|---|---|---|
booking_invite | New taster enquiry | contact_name, booking_url, available_dates_list |
booking_confirmation | Booking created | contact_name, session_date, session_time, age_group_label, venue, add_to_calendar_url |
booking_reminder | Cron: 7 days before session | contact_name, session_date, session_time, age_group_label, venue |
academy_waitlist | Academy enquiry | parent_name, child_name, response_url |
academy_invite | Admin sends academy place offer | (admin-managed) |
membership_invite | Attendance marked as attended | contact_name, membership_url |
Variable substitution
Templates use {{variable_name}} syntax. The renderTemplate() function in src/lib/business/templates.ts handles substitution with strict validation:
// Throws if any {{token}} in the template lacks a value
// Ignores extra vars not used in the template
renderTemplate(template.html, {
contact_name: enquiry.name || 'there',
booking_url: bookingUrl,
available_dates_list: datesList,
});
Strict output, permissive input: if a template contains {{unknown_var}} but no value is supplied, renderTemplate throws — preventing emails with unfilled placeholders from reaching parents. Extra variables passed in are silently ignored.
Retry logic
sendEmailWithRetry() wraps sendEmail() with exponential backoff:
Attempt 1 → immediate
Attempt 2 → wait 200ms
Attempt 3 → wait 400ms
After 3 failures, it returns { success: false, error: "..." }. The caller logs the error and continues — email failure never throws or crashes the request.
Never-throw contract
Every email sender function in templates.ts follows the same pattern:
export async function sendBookingInvite(...): Promise<SendEmailResult> {
const template = await getEmailTemplateByKey('booking_invite', env);
if (!template) {
return { success: false, error: 'Template not found' }; // ← never throws
}
try {
const html = renderTemplate(template.html, vars);
return sendEmailWithRetry({ to, subject, html }, env);
} catch (err) {
return { success: false, error: err.message }; // ← catches renderTemplate throws
}
}
This means:
- A missing or inactive template → logged, returns failure
- A template with bad
{{variables}}→ caught, returns failure - A Resend API error → retried 3×, then returns failure
- None of the above crash a user-facing request
From address
The from address is always taken from env.EMAIL_FROM (set in wrangler.toml or as a secret). The caller cannot override it — any from passed in the options object is ignored with a logged warning. This prevents accidental sender spoofing.
Admin template editor
Templates are editable at /admin/templates. The editor provides:
- Live preview — rendered with
SAMPLE_TEMPLATE_VARSfromsrc/lib/business/templates.ts - Variable reference — lists all
{{variables}}in the current template - Test send — sends to a specified address using sample data
- Unknown variable warnings —
validateTemplateVariables()flags any{{token}}not in the known-variables list before saving
active flag to 0 disables it. Any send attempt for that template key will return { success: false } and log an error. The system will not fall back to a hardcoded template.Logging
Every send attempt logs a structured JSON object to the Cloudflare Workers console:
// Success
{ "level": "info", "event": "email_sent", "resend_id": "re_...", "to": "...", "subject": "..." }
// Failure
{ "level": "error", "event": "email_send_failed", "status": 422, "to": "...", "error": "..." }
// Retry
{ "level": "warn", "event": "email_retry", "attempt": 2, "maxAttempts": 3, "to": "...", "error": "..." }
Logs are viewable in the Cloudflare Pages dashboard under Workers & Pages → Functions → Logs.