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:
- Email is stub-only —
sendEmail()imports@sendgrid/mailbut the env vars are never set; all emails log to console. - Plain text only — No HTML templates, no branding, no CTAs.
- Missing notification triggers —
escalationandmessagetypes are defined inbuildNotificationMessage()but never called. - No proactive emails — No document expiry reminders, weekly digests, welcome emails, or payment failure alerts.
- SendGrid is the wrong provider — Resend offers a simpler API, better DX, CF Workers compatibility, and we can use domain-verified sending from
ehq.techwithout 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
| Notification | Channel | Status |
|---|---|---|
| New issue | SMS (urgent) + Email | Email stubbed |
| Emergency alert | SMS + Email | Email stubbed |
| Escalation to human | — | Not wired (type exists, never called) |
| Tenant message | — | Not wired (type exists, never called) |
| Issue status change | — | Does not exist |
| Document expiry reminder | — | Does not exist |
| Weekly portfolio digest | — | Does not exist |
| Welcome/onboarding | — | Does not exist |
| Payment failure | — | Does not exist |
| Rent chase (levels 1-3) | SMS/Email | DB events created, sending is TODO |
| Rent chase (level 4 — landlord alert) | Sending is TODO |
Decision
Provider: Resend
Replace @sendgrid/mail with resend.
| Criterion | SendGrid | Resend |
|---|---|---|
| CF Workers compatibility | Requires polyfills for Node.js APIs | Native fetch, works out of the box |
| Bundle size | ~45KB min | ~7KB min |
| API simplicity | Set API key globally, mutable state | Constructor injection, immutable |
| HTML templates | External template engine or raw strings | Supports React Email or raw HTML |
| Domain verification | CNAME records | CNAME records (same) |
| Cost | Free tier 100 emails/day | Free 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.
| Type | Trigger | Recipients | V1 Action |
|---|---|---|---|
new_issue | Tenant reports issue | Org owners/admins | Swap to Resend + HTML template |
emergency | Emergency detected by AI | Org owners/admins | Swap to Resend + HTML template |
escalation | AI escalates to human | Org owners/admins | Wire notifyLandlord() call in process.ts:handleToolEscalate() + HTML template |
message | Direct tenant message | Org owners/admins | Template only — no trigger wired (no clear activation point yet) |
B. New Notification Types
| Type | Trigger | Recipients | Implementation |
|---|---|---|---|
| Issue status change | GraphQL updateIssueStatus mutation | Org owners/admins (excluding the user who made the change) | Hook in lib/graphql/mutations/issue.ts |
| Document expiry reminder | Daily cron (0 7 * * *) | Org owners/admins | Batched per org: one email listing all expiring/expired compliance docs |
| Welcome | completeOnboarding() + Stripe webhook org creation | The new user | Fire-and-forget after org creation |
| Weekly digest | Weekly cron (0 8 * * 1, Mondays 8am) | Org owners/admins | Stats: new issues this week, open issues, urgent issues, docs expiring soon, docs expired |
| Payment failure | Stripe invoice.payment_failed webhook | Org owners/admins | New webhook case in app/api/stripe/webhook/route.ts |
C. Rent Chase (Partial)
| Level | Channel | V1 Action |
|---|---|---|
| 1-3 (tenant-facing) | SMS/Email | Deferred to V2 — tenant-facing emails out of scope |
| 4 (landlord alert) | Wire 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-secretheader validated againstCRON_SECRETenv 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
| Option | Pros | Cons |
|---|---|---|
| Resend + pure HTML templates (chosen) | Tiny bundle, CF-native, zero deps, simple | Manual HTML (no JSX), less ergonomic for complex layouts |
| Resend + React Email | JSX-based templates, component reuse | Heavy bundle (~150KB+), risks CF Workers limit, overkill for V1 |
| Keep SendGrid | Already installed | Larger bundle, Node.js API deps, mutable global state, worse DX |
| Postmark | Great deliverability | More expensive, less flexible API |
| Direct SMTP via Cloudflare Email Workers | No third-party dependency | Complex 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
| Risk | Likelihood | Mitigation |
|---|---|---|
| Resend outage blocks all email | Low | Fire-and-forget pattern; SMS still works for urgent alerts |
| HTML emails render poorly in some clients | Medium | Use table-based layout, inline CSS, test with Litmus/Email on Acid |
| Document expiry cron overwhelms Resend rate limits | Low | Batching per org; Resend free tier is 3,000/month, sufficient for early scale |
CF Workers bundle exceeds 10MB after adding resend | Very low | Package 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_eventstable 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
| File | Purpose |
|---|---|
lib/email/client.ts | Resend client singleton, sendEmail() |
lib/email/templates.ts | All HTML email template functions |
lib/email/notifications.ts | Higher-level: query recipients + template + send |
lib/tenant-engine/notification.ts | Refactored: delegates email to lib/email/client |
lib/tenant-engine/process.ts | Wired: escalation now calls notifyLandlord() |
lib/graphql/mutations/issue.ts | Wired: status change triggers email |
lib/services/document-expiry.ts | Query expiring compliance documents |
lib/services/weekly-digest.ts | Query weekly portfolio stats |
app/api/cron/document-expiry/route.ts | Daily cron for expiry reminders |
app/api/cron/weekly-digest/route.ts | Weekly cron for portfolio digest |
lib/actions/onboarding.ts | Wired: welcome email after org creation |
app/api/stripe/webhook/route.ts | Wired: payment failure email |
Related
- ADR-005 Notification Preferences — V2: channel selection, quiet hours, digest preferences
- ADR-004 Billing Integration — Stripe webhook where payment failure email hooks in
- ADR-010 Tenant Engine — Notification framework lives within the tenant engine
- ADR-011 Regulatory Compliance — Document expiry reminders support compliance obligations
- ADR-013 Event-Driven Architecture — Notification events logged via
IssueEvent