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
- ✅ Model + migration +
EmailService.dispatchrefactor (all sends logged as sent/failed). - ✅
GET /api/emailsendpoint. - ✅ Sent-emails table on User + Reservation detail.
- (Later) Resend delivery/open/bounce webhooks → status updates;
skippedlogging; retention cron; other entity pages.
Open questions
- ~~
EmailServiceinstantiation/tx model~~ — resolved: singletonprisma, fire-and-forget after send (see Capture point). typetaxonomy — built by reusing each method's Sentryoperationstring (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+toEmailbut notuserId(thesendXxxparams carryuserEmail, not the User uuid). UserDetail filters bytoEmail. ThreadinguserIdthrough the email params is a future improvement.