Skip to content

Sent Email Log

Problem

Staff have no record of which emails the system has sent to a member. When a customer says "I never got my receipt / confirmation / invitation," staff can't confirm what was sent or when. Emails go out via Resend (apps/api/src/services/email/EmailService.ts) fire-and-forget; nothing is persisted. The only communications data we store is consent (ConsentEvent/ConsentState), not message history.

Goal

Persist every outbound email and surface a Sent emails section on the entity detail pages where emails originate — starting with User and Reservation, extensible to others (Organization, Waitlist, Migration).

Non-goals

  • Email content archival / re-rendering the exact HTML. Store metadata + a Resend message id; staff can open the message in Resend if deep inspection is needed.
  • Delivery/open/bounce tracking (Resend webhooks). Possible phase 2; out of scope here.
  • SMS history (separate channel; could reuse the same pattern later).
  • Editing or resending from the log (read-only section).

Data model

New Prisma model SentEmail (sent_emails), soft-delete-free (append-only log):

SentEmail
  id              String   @id @default(uuid)
  type            String          // e.g. 'hourly_confirmation', 'lockout_welcome', 'tour_cancellation' — a stable enum-ish key per send method
  toEmail         String          // recipient (member PII)
  subject         String
  status          String          // 'sent' | 'failed'  (v1; 'skipped' deferred — see Capture point)
  resendMessageId String?         // Resend's returned id, when sent
  errorMessage    String?         // when failed
  userId          String?  → User           // recipient user, when known
  reservationId   String?  → Reservation    // related entity, when applicable
  organizationId  String?  → Organization
  waitlistEntryId String?  → WaitlistEntry
  createdAt       DateTime @default(now())
  @@index([userId, createdAt])
  @@index([reservationId, createdAt])

FK association is best-effort: each send method passes whatever ids it has (most already have userId/reservationId in their params). No FK = still logged (orphan, queryable by toEmail).

Migration via the db-migration skill (shadow-DB migrate diff, never migrate dev). Additive. Backfill is optional via apps/api/scripts/sent-emails-backfill.ts — lists Resend history (needs RESEND_MANAGER_API_KEY; the send key 401s on list), inserts only emails sent to a user in the DB, dedupes by resendMessageId. Backfilled rows have type='backfill' and no reservationId (Resend doesn't store it), so they surface on UserDetail (by toEmail) but not ReservationDetail. Ran locally 2026-06-09 (1,240 rows); prod run optional post-merge.

Capture point (the key design decision)

EmailService today has no single send method — ~15 sendXxx methods each call this.emailClient.emails.send(...) directly, and each independently checks shouldSkipSend().

Introduce one private dispatch(log, payload) method that: 1. calls this.emailClient.emails.send(payload), 2. writes the SentEmail row (status: 'sent' + resendMessageId, or 'failed' + errorMessage), 3. never throws for the logging step (log-write failure must not break a send — capture to Sentry).

Then route every sendXxx through dispatch, passing { type, userId?, reservationId?, organizationId?, waitlistEntryId? } as log plus the existing Resend payload (toEmail/subject are read off the payload). Pure interposition — the per-method shouldSkipSend() / shouldSkipCustomerEmail() / consent guards stay in place ahead of dispatch, so send behavior is unchanged.

skipped is deferred (built as sent/failed only). A skip only happens in dev/local (prod has Resend configured, ALLOW_ALL_EMAIL_DOMAINS=true, not test mode), so skipped rows carry no production audit value and writing them would require pulling the skip-guards + template render into dispatch across all ~30 transactional methods — a riskier rewrite. When that's wanted, move the guards into dispatch and re-add 'skipped' to SENT_EMAIL_STATUSES.

Instantiation/tx (resolved): EmailService is new'd per call and already uses the prisma singleton (e.g. the followup consent check). The SentEmail write uses that singleton, fire-and-forget after the send — not the triggering mutation's tx (emails are post-commit side effects; the log write must not be rolled back or hold the tx open).

API

GET /api/emails (staff+), offset pagination via createListSuccessResponse, filters: userId, reservationId, organizationId, waitlistEntryId, type, status. Returns { id, type, toEmail, subject, status, resendMessageId, createdAt }. Conform to ADR-019 envelope.

UI

A Sent emails DetailTable on UserDetail and ReservationDetail: - Columns: Subject (first), Type (humanized label), To, Status, Sent (date). - Self-fetching load-more hook useSentEmails(filters) mirroring useAuditLog (accumulating pages, 10/page), rendered via DetailTable's loadMore mode — paginated like the Logs feed. - Read-only (no row actions) — consistent with the access-codes table decision. - resendMessageId could link to the Resend dashboard (like the Stripe-subscription link) if a stable URL exists.

Privacy / retention

SentEmail.toEmail + subject are member PII. Internal-only (staff+ gated). Note for ops: define a retention window (e.g. purge > 18–24 months) rather than keep indefinitely — a cron sweep, out of v1 scope but flag in the plan. Never expose externally (org rule).

Phasing

  1. ✅ Model + migration + EmailService.dispatch refactor (all sends logged as sent/failed).
  2. GET /api/emails endpoint.
  3. ✅ Sent-emails table on User + Reservation detail.
  4. (Later) Resend delivery/open/bounce webhooks → status updates; skipped logging; retention cron; other entity pages.

Open questions

  • ~~EmailService instantiation/tx model~~ — resolved: singleton prisma, fire-and-forget after send (see Capture point).
  • type taxonomy — built by reusing each method's Sentry operation string (kebab-case key, e.g. hourly-reservation-confirmation); the UI humanizes it. Not yet a closed enum.
  • Staff-only, or also show the member their own email history in /account? v1: staff only.
  • FK coverage is partial — most member sends log reservationId/waitlistEntryId + toEmail but not userId (the sendXxx params carry userEmail, not the User uuid). UserDetail filters by toEmail. Threading userId through the email params is a future improvement.