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

flowchart LR A[Business logic\ne.g. handleEnquiry] --> B[sendBookingInvite\ntemplates.ts] B --> C[getEmailTemplateByKey\nD1 lookup] C --> D[renderTemplate\nvariable substitution] D --> E[sendEmailWithRetry\nclient.ts] E --> F[Resend API] F --> G[Delivered ✓] E -->|Failure| H[Log error\nreturn success:false]

The six email types

Template keyTriggered byKey variables
booking_inviteNew taster enquirycontact_name, booking_url, available_dates_list
booking_confirmationBooking createdcontact_name, session_date, session_time, age_group_label, venue, add_to_calendar_url
booking_reminderCron: 7 days before sessioncontact_name, session_date, session_time, age_group_label, venue
academy_waitlistAcademy enquiryparent_name, child_name, response_url
academy_inviteAdmin sends academy place offer(admin-managed)
membership_inviteAttendance marked as attendedcontact_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_VARS from src/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 warningsvalidateTemplateVariables() flags any {{token}} not in the known-variables list before saving
warning
Setting a template's 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.