Docs / Admin

Event email automation and templates

Summary: When someone registers for a consultation, an Inngest workflow sends an instant confirmation email (with an iCal attachment) and schedules 7-day, 1-day, and 1-hour reminders plus a post-event thank-you and follow-up — all time-driven, per-attendee, and toggleable per consultation. Emails are built from a visual block-based template system, and every send is tracked in an Email Log with Resend-driven delivery status.

Purpose: Automate the routine event-communication sequence so staff don't have to manually email every attendee at every stage of a consultation's lifecycle, while keeping full delivery visibility.

Audience: Admin and staff managing consultations and email templates.

Prerequisites:

  • A consultation with a scheduled_at date and at least one registered attendee with an email address.
  • Resend configured for outbound send and delivery webhooks (RESEND_API_KEY).

Overview

Key concepts:

  • Event email sequence — a chain of Inngest functions triggered by attendee registration, each firing at a specific offset from the consultation's scheduled time.
  • Per-consultation email settings (consultation_email_settings) — toggles to enable/disable each email type, a pause switch, custom text overrides, logistics fields, and a virtual-meeting URL, scoped to one consultation.
  • Email template — a named, versioned, block-based HTML template (email_templates table) used for the visual builder; separate from the hard-coded HTML renderers that actually generate event emails today.
  • Email log (admin/emails) — a record of every email sent through the platform, with delivery status updated by Resend webhooks.

Screenshot: The Email Templates list showing template name, subject, a category badge (Event/Reminder/Follow-up/Notification/Custom), version number, System/Custom badge, and Active/Inactive status.


The event email sequence

Triggered by the consultation/attendee.registered event (src/lib/platform/workflows/functions/event-email-confirmation.ts):

  1. Confirmation (immediate) — sent unless settings.sendConfirmation === false. Subject: Registration Confirmed — <title>. Includes the consultation title, type, formatted date/time in Philippine Time, a venue block (venue name + Google Maps link + virtual meeting link if set), a logistics block, the agenda grouped by day (if agenda items exist, with a link to view the full agenda online), a check-in QR code block (image + text link, only if the attendee has an ID), and a note that an iCal invite is attached. The .ics file is generated via generateICalString and attached as invite.ics.
  2. 7-day reminder — scheduled for scheduled_at − 7 days, unless settings.sendReminder7d === false or that time has already passed. Subject: Next Week: <title>. Includes the agenda.
  3. 1-day reminder — scheduled for scheduled_at − 1 day, unless settings.sendReminder1d === false. Subject: Tomorrow: <title>. Includes the agenda and the logistics block.
  4. 1-hour reminder — scheduled for scheduled_at − 1 hour, unless settings.sendReminder1h === false. Subject: Starting Soon: <title>. No agenda block (kept short for last-minute delivery).
  5. Thank-you — scheduled for scheduled_at + 2 hours, unless settings.sendThankYou === false. Subject: Thank You — <title>.
  6. Follow-up — scheduled for scheduled_at + 3 days, unless settings.sendFollowUp === false. Subject: Follow-up — <title>. Includes up to 10 action items (from consultation_notes where note_type = 'action_item') and up to 3 upcoming events. A "View Meeting Minutes" button appears only when a minutesUrl is supplied — the follow-up sender currently passes null for this, so the button does not render in practice yet.

Each reminder is dispatched via a separately-scheduled Inngest event (consultation/email.send-reminder for 7d/1d/1h, consultation/email.send-post-event for thank-you/follow-up), fired with a future ts timestamp so Inngest's own scheduler handles the delay — no cron polling loop is involved.

Safety checks before every send

  • Reminders (event-email-reminder.ts): skipped if the attendee's rsvp_status is cancelled, if the consultation's email settings are paused, or if that specific reminder type was disabled after scheduling.
  • Thank-you / follow-up (event-email-post-event.ts): only sent if the attendee's rsvp_status is attended or confirmed. Also deduplicated — since this trigger can fire from both the time-scheduled path and an on-scan check-in trigger, the function checks email_log for an existing non-failed send of the same emailType to the same attendee before sending again.

Per-consultation controls

consultation_email_settings (one row per consultation, src/lib/modules/consultations/email-settings.ts) holds:

FieldPurpose
sendConfirmation, sendReminder7d, sendReminder1d, sendReminder1h, sendThankYou, sendFollowUpPer-type on/off toggles (default enabled — only an explicit false disables)
pausedMaster kill switch — when true, reminders stop sending even if individually enabled
logisticsFree-form key/value pairs rendered in the logistics block (parking, dress code, WiFi, emergency contact, etc.)
virtualMeetingUrlOverrides the consultation's own virtual meeting link if set
customConfirmationText, customThankYouTextOverride the default confirmation/thank-you body copy

Updating settings requires the staff role and is logged to the audit trail as consultation.email_settings_updated.


Email template builder

/admin/email-templates lists every template with its subject, category (Event, Reminder, Follow-up, Notification, Custom), version number, a System badge for the 12 seeded defaults, an Active/Inactive badge, block count, usage count, and last-edited date.

The 12 default system templates

Seeded via migration (is_system = true, not deletable through the templates admin page in the current UI): Event Confirmation, 7-Day Reminder, 1-Day Reminder, 1-Hour Reminder, Thank You, Follow-Up, Waitlist Notification, Cancellation Confirmation, Investor Qualification, B2B Meeting Scheduled, Action Item Reminder, Monthly Report Ready. These use variable placeholders like {{event.title}}, {{attendee.name}}, {{count}}, {{period}}, {{report.url}}.

Note: these system templates are a separate, block-based data model from the hard-coded HTML renderers described in the event email sequence above (renderConfirmation, renderReminder, renderThankYou, renderFollowUp in src/lib/platform/email/event-templates.ts). The two are not currently wired together — editing the "7-Day Reminder" template in the builder does not change what event-email-reminder.ts actually sends. Confirm this integration gap before relying on the builder to customize live event emails.

Creating a template (/admin/email-templates/new)

  1. Enter Template Name and Subject Line (supports {{variables}}).
  2. Choose a Category: Custom, Event, Reminder, Follow-up, Notification.
  3. Click Create Template. It's created with a starter block set: header, greeting ("Hi {{attendee.name}},"), text ("Enter your email content here..."), footer.

Editing a template

The edit page (/admin/email-templates/<id>) opens the TemplateEditor — a visual block editor with a live preview generated by renderTemplate(blocks). Available block types: header, greeting, text, image, button, divider, spacer, event_card, calendar_buttons, logistics, qr_code, agenda, action_items, next_events, footer.

Templates also carry a version number and usageCount; the page title reads "Visual template editor — drag blocks, edit content, preview output."

Using a template in a campaign

The campaign composer (/communications/campaigns/new) can pull a template into the email content: selecting it from the Template dropdown fetches the template, renders it via renderTemplate(tpl.blocks, {}), and pre-fills the Subject and Body fields (toast: Applied "<name>"). See Running campaigns and building audiences for the full campaign flow.


Delivery tracking (Email Log)

/admin/emails lists recent sends from email_log, with:

  • Filters: Status (sent, delivered, bounced, failed, opened, clicked) and Source (automation, consultations, outputs/Documents).
  • Columns: To, Subject, Status (color-coded badge), Source, Sent timestamp.
  • Failed Email Details section — shown only when at least one logged email has status = 'failed' and an error message, listing the recipient and error text.

The page notes: "Recent email activity. Delivery status updated via Resend webhooks." — opens, clicks, bounces, and delivery confirmations arrive asynchronously from Resend and update the corresponding email_log row's status after the initial send.


Roles and permissions

ActionMinimum role
View/filter the Email Logauthenticated staff (page has no explicit role gate beyond the authenticated layout)
Create/edit email templatesauthenticated staff (no distinct admin-only gate found on the template actions)
Update per-consultation email settingsstaff

Best practices

Set logistics and virtual meeting details before the first registration. The confirmation email is sent immediately on registration — settings changed afterward won't retroactively update emails already delivered, only subsequent ones (reminders re-read settings at send time).

Use paused for a consultation-wide hold, not individual toggles. If an event is being rescheduled, paused stops all reminders at once without losing the individual on/off configuration.

Don't expect template-builder edits to change live event emails yet. Until the builder and the hard-coded renderers are unified, edit src/lib/platform/email/event-templates.ts directly (a code change) to alter actual event email content.


Warnings

Reminders scheduled in the past are silently skipped. If a consultation is created or an attendee registers less than 7 days (or 1 day / 1 hour) before the event, that reminder is never scheduled — there's no "send immediately" fallback.

Thank-you and follow-up only fire for attended or confirmed attendees. A no_show or registered-but-never-confirmed attendee will not receive either email, even if the time-based trigger fires.


Troubleshooting

An attendee never received their confirmation email

Cause: Either attendeeEmail was empty at registration (the workflow returns { status: "skipped", reason: "no email" }), the consultation has no scheduled_at, or sendConfirmation is disabled/paused in that consultation's email settings. Fix: Confirm the attendee record has an email, the consultation has a scheduled date, and check /admin/emails filtered by source consultations for a log entry with an error.

A reminder didn't go out

Cause: The attendee's RSVP status is cancelled, the consultation's settings are paused, that specific reminder type was disabled, or the scheduled send time had already passed when the confirmation workflow ran (e.g. event created less than 7 days out). Fix: Check the consultation's email settings for that reminder type and the paused flag; check the attendee's RSVP status.

Follow-up or thank-you sent twice

Cause: Should not happen — the workflow explicitly checks email_log for a prior non-failed send of the same emailType to the same attendee before sending. If duplicates appear, check whether email_log rows for that send are marked failed (which would allow a legitimate retry). Fix: Inspect the email_log rows for that attendee/emailType combination in /admin/emails.

Editing a system template in the builder doesn't change what attendees receive

Cause: The block-based email_templates used by the visual builder are not currently connected to the hard-coded renderers (renderConfirmation, renderReminder, etc.) that generate real event emails. Fix: For now, event email content changes require editing src/lib/platform/email/event-templates.ts directly. Treat the builder's "System" templates as a preview/reference set, not the live source.


FAQ

Can I customize the reminder schedule (e.g. add a 2-day reminder)? No. Only 7-day, 1-day, and 1-hour offsets exist in the current workflow; adding another cadence requires a code change.

Does the iCal invite update if the consultation is rescheduled? The .ics is generated fresh each time the confirmation email is sent (registration time), using the consultation's scheduled_at and ended_at (or a 2-hour default duration). There is no re-send of a fresh calendar invite triggered automatically by a later reschedule — check for a manual resend action if the date changes after registration.

Are RSVP-based invites (Yes/No buttons) part of this sequence? A separate template (renderRsvpInvite) exists for pre-registration RSVP invitations with Yes/No confirmation links, distinct from the post-registration confirmation flow above — it is not triggered by the same consultation/attendee.registered event.

Where do I see how many times a template has been used? The Email Templates list shows a Used <N> times counter per template — but that only reflects manual use in the block-based campaign flow, not automatically-sent event emails (which don't go through this template model).


Related articles

  • Running campaigns and building audiences — how templates plug into manual campaign sends and the human-review send gate
  • A dedicated guide to per-consultation email settings and check-in QR flow is planned.