ADR-021: Email & Notification System

Status: Accepted Owner: @bilal Date: 2026-04-01

Context

Envo notifies landlords about events (new issues, emergencies, escalations) via lib/tenant-engine/notification.ts. The current implementation has significant gaps:

  1. Email is stub-onlysendEmail() imports @sendgrid/mail but the env vars are never set; all emails log to console.
  2. Plain text only — No HTML templates, no branding, no CTAs.
  3. Missing notification triggersescalation and message types are defined in buildNotificationMessage() but never called.
  4. No proactive emails — No document expiry reminders, weekly digests, welcome emails, or payment failure alerts.
  5. SendGrid is the wrong provider — Resend offers a simpler API, better DX, CF Workers compatibility, and we can use domain-verified sending from ehq.tech without complex setup.

The existing ADR-005 (Notification Preferences) covers channel selection, quiet hours, and digest preferences — that remains deferred. This ADR covers the foundational email infrastructure that ADR-005 will build upon.

Notification Landscape Today

NotificationChannelStatus
New issueSMS (urgent) + EmailEmail stubbed
Emergency alertSMS + EmailEmail stubbed
Escalation to humanNot wired (type exists, never called)
Tenant messageNot wired (type exists, never called)
Issue status changeDoes not exist
Document expiry reminderDoes not exist
Weekly portfolio digestDoes not exist
Welcome/onboardingDoes not exist
Payment failureDoes not exist
Rent chase (levels 1-3)SMS/EmailDB events created, sending is TODO
Rent chase (level 4 — landlord alert)EmailSending is TODO

Decision

Provider: Resend

Replace @sendgrid/mail with resend.

CriterionSendGridResend
CF Workers compatibilityRequires polyfills for Node.js APIsNative fetch, works out of the box
Bundle size~45KB min~7KB min
API simplicitySet API key globally, mutable stateConstructor injection, immutable
HTML templatesExternal template engine or raw stringsSupports React Email or raw HTML
Domain verificationCNAME recordsCNAME records (same)
CostFree tier 100 emails/dayFree tier 3,000 emails/month

Templates: Pure HTML Functions (No React Email)

Use plain TypeScript functions that return { subject, html, text }. No @react-email/components.

Rationale:

  • React Email pulls in React’s server-side rendering pipeline (~150KB+), risky for CF Workers’ 10MB bundle limit.
  • Pure functions are zero-dependency, fully testable, and trivially portable.
  • Email HTML is table-based and static — React’s component model adds complexity without value here.
  • Can always migrate to React Email later if templates grow complex (V2).

Architecture

lib/email/
  client.ts           — Resend singleton, sendEmail() wrapper
  templates.ts        — Pure HTML template functions
  notifications.ts    — Higher-level orchestration (query recipients, call template, send)

lib/tenant-engine/
  notification.ts     — Refactored: uses lib/email/client instead of inline SendGrid

app/api/cron/
  document-expiry/    — Daily: compliance doc reminders
  weekly-digest/      — Weekly: portfolio summary
  rent-chase/         — Daily: (existing) rent chase-ups

lib/services/
  document-expiry.ts  — Query logic for expiring documents
  weekly-digest.ts    — Query logic for weekly stats

Email Types (V1 Scope)

A. Tenant Engine Notifications (refactor existing)

These are triggered by notifyLandlord() in lib/tenant-engine/notification.ts. The interface and call sites are unchanged; only the email sending and template rendering change.

TypeTriggerRecipientsV1 Action
new_issueTenant reports issueOrg owners/adminsSwap to Resend + HTML template
emergencyEmergency detected by AIOrg owners/adminsSwap to Resend + HTML template
escalationAI escalates to humanOrg owners/adminsWire notifyLandlord() call in process.ts:handleToolEscalate() + HTML template
messageDirect tenant messageOrg owners/adminsTemplate only — no trigger wired (no clear activation point yet)

B. New Notification Types

TypeTriggerRecipientsImplementation
Issue status changeGraphQL updateIssueStatus mutationOrg owners/admins (excluding the user who made the change)Hook in lib/graphql/mutations/issue.ts
Document expiry reminderDaily cron (0 7 * * *)Org owners/adminsBatched per org: one email listing all expiring/expired compliance docs
WelcomecompleteOnboarding() + Stripe webhook org creationThe new userFire-and-forget after org creation
Weekly digestWeekly cron (0 8 * * 1, Mondays 8am)Org owners/adminsStats: new issues this week, open issues, urgent issues, docs expiring soon, docs expired
Payment failureStripe invoice.payment_failed webhookOrg owners/adminsNew webhook case in app/api/stripe/webhook/route.ts

C. Rent Chase (Partial)

LevelChannelV1 Action
1-3 (tenant-facing)SMS/EmailDeferred to V2 — tenant-facing emails out of scope
4 (landlord alert)EmailWire sending via lib/email/client.ts

Sending Pattern

All non-urgent emails are fire-and-forget: the calling code does not await the result or fail if email sending fails. This prevents email infrastructure issues from blocking core operations (issue creation, status changes, onboarding).

sendWelcomeEmail({ userId, orgName })
  .catch((err) => console.error('[email] Welcome email failed:', err))

Urgent notifications (emergency, escalation) remain synchronous within notifyLandlord() to ensure the NOTIFICATION_SENT event is recorded accurately.

Cron Authentication

Follow the existing pattern from app/api/cron/rent-chase/route.ts:

  • POST endpoint
  • x-cron-secret header validated against CRON_SECRET env var
  • Called by external scheduler (Cloudflare Worker cron trigger or similar)

Stub Mode

When RESEND_API_KEY is not set, sendEmail() logs the email details to console and returns { success: true, id: 'stub' }. This matches the existing pattern for Twilio SMS. Development and CI environments work without Resend credentials.

Domain & Deliverability

  • Sending domain: ehq.tech
  • From address: Envo <notifications@ehq.tech>
  • Requires DNS verification in Resend dashboard (CNAME records for DKIM/SPF)
  • Production secret: npx wrangler secret put RESEND_API_KEY

Options Considered

OptionProsCons
Resend + pure HTML templates (chosen)Tiny bundle, CF-native, zero deps, simpleManual HTML (no JSX), less ergonomic for complex layouts
Resend + React EmailJSX-based templates, component reuseHeavy bundle (~150KB+), risks CF Workers limit, overkill for V1
Keep SendGridAlready installedLarger bundle, Node.js API deps, mutable global state, worse DX
PostmarkGreat deliverabilityMore expensive, less flexible API
Direct SMTP via Cloudflare Email WorkersNo third-party dependencyComplex setup, no built-in analytics, harder to debug

Consequences

Positive

  • Landlords receive branded, actionable HTML emails with dashboard CTAs
  • Escalations now notify landlords (previously silent — tenants waited with no response)
  • Document expiry reminders proactively surface legal compliance risks
  • Weekly digest reduces need to log in daily for multi-property landlords
  • Payment failure alerts prevent silent subscription lapses
  • Clean separation: lib/email/ is reusable by any part of the codebase
  • Stub mode means zero setup burden for local development

Negative

  • New dependency (resend) — mitigated by tiny size and CF compatibility
  • HTML email templates are verbose and harder to maintain than JSX — acceptable for V1 volume
  • Daily cron for document expiry sends for any doc within 30 days, which may feel noisy — mitigated by batching per org and addressed properly in V2 via ADR-005 preferences

Risks

RiskLikelihoodMitigation
Resend outage blocks all emailLowFire-and-forget pattern; SMS still works for urgent alerts
HTML emails render poorly in some clientsMediumUse table-based layout, inline CSS, test with Litmus/Email on Acid
Document expiry cron overwhelms Resend rate limitsLowBatching per org; Resend free tier is 3,000/month, sufficient for early scale
CF Workers bundle exceeds 10MB after adding resendVery lowPackage is ~7KB; current bundle has significant headroom

V2 Roadmap

These items are explicitly out of scope for V1 but documented here for future planning:

V2a: Notification Preferences (ADR-005)

  • Per-user channel preferences (email, SMS, both, none)
  • Per-event-type opt-out
  • Quiet hours (bypass for emergencies)
  • Digest mode (batch non-urgent notifications into periodic summary)
  • UI: Settings → Notifications page in dashboard

V2b: Inbound Email

  • Receive tenant emails via Resend inbound webhook or Cloudflare Email Workers
  • Route to existing conversation engine (lib/tenant-engine/process.ts)
  • Map sender email → tenant identity via identifyTenantByEmail()
  • Create app/api/tenant/webhooks/email/route.ts

V2c: Tenant-Facing Emails

  • Rent chase levels 1-3 (payment reminders sent to tenants)
  • Issue acknowledgement emails to tenants
  • Move-in welcome emails with property documents attached
  • Requires careful opt-out/unsubscribe handling (UK PECR compliance)

V2d: Email Analytics & Tracking

  • Resend provides open/click tracking natively
  • Build dashboard UI to show delivery rates, opens, bounces
  • Add email_events table for audit trail
  • Bounce/complaint handling to auto-disable bad addresses

V2e: React Email Migration

  • If template count exceeds ~15 or layouts become complex, migrate to @react-email/components
  • Evaluate bundle impact on CF Workers at that point
  • Shared component library: <EmailLayout>, <Button>, <StatusBadge>, etc.

Key Files

FilePurpose
lib/email/client.tsResend client singleton, sendEmail()
lib/email/templates.tsAll HTML email template functions
lib/email/notifications.tsHigher-level: query recipients + template + send
lib/tenant-engine/notification.tsRefactored: delegates email to lib/email/client
lib/tenant-engine/process.tsWired: escalation now calls notifyLandlord()
lib/graphql/mutations/issue.tsWired: status change triggers email
lib/services/document-expiry.tsQuery expiring compliance documents
lib/services/weekly-digest.tsQuery weekly portfolio stats
app/api/cron/document-expiry/route.tsDaily cron for expiry reminders
app/api/cron/weekly-digest/route.tsWeekly cron for portfolio digest
lib/actions/onboarding.tsWired: welcome email after org creation
app/api/stripe/webhook/route.tsWired: payment failure email