ADR-0036: Email the renewal offer + letter to tenants with no phone
- Status: Accepted
- Date: 2026-05-27
- Deciders: Fede
- Related: ADR-0025 (Temporal for renewals),
PMS_INTEGRATION.md, PR #1026 (renewal email → permanent NOOP, 2026-05-16),sms-cascade.ts(SMS→email cascade retired 2026-05-17)
Context
A renewal tenant reaches a dead end when we have no usable phone number. This surfaced in production on a Camellia renewal (unit 608): the number on file belonged to a former roommate, so every Clara channel failed — voice hit a stranger's voicemail, SMS and the renewal letter MMS had nowhere to land — and the workflow escalated to a PM as "couldn't reach."
This is structural, not a one-off. Today's renewal outreach channels are:
- Letter MMS (pre-outreach): fetches the AppFolio Renewal Notice Letter PDF via the browser-agent, uploads to S3, and texts it as an SMS + MMS bundle. Requires a phone —
sendRenewalLetterMmsshort-circuits withskipped_no_letterwhen!tenant.phone(send-renewal-letter-mms.ts:143), before it ever fetches the PDF. - SMS + voice (parallel, Phase 2): both short-circuit to
dispatched_failedon!tenant.phone(renewal.ts:530,:996). - Email: a deliberate permanent NOOP since PR #1026 (
sendOutreachEmail,renewal.ts:743). AppFolio's resident portal auto-sends a bare "your renewal offer is ready to sign" email when the offer goes Out For Signing; Clara sending a parallel email duplicated that and confused tenants on 2026-05-16 ("I didn't ask for that"). The SMS→email cascade was retired for the same reason on 2026-05-17 (sms-cascade.ts:105).
So a phoneless tenant receives only AppFolio's bare-bones email — which lacks the renewal terms and the letter PDF — and then gets escalated to a PM. There is no path today that delivers the renewal letter (the PDF carrying the full terms) to a tenant we can't text. The letter only ships over MMS, and MMS needs a phone.
The value of a Clara renewal email is precisely the delta over AppFolio's: it states the full renewal terms and attaches the renewal letter PDF. For a tenant with a phone, that delta is already covered by the letter MMS, so Clara emailing them is the redundant noise PR #1026 removed. For a tenant without a phone, that delta is currently undeliverable.
Decision
When a renewal tenant has no phone, Clara sends a renewal email that states the full renewal terms and attaches the renewal letter PDF. This is the email-channel analogue of the letter MMS for tenants we cannot text. Scope is no-phone tenants only — phone-having tenants are unchanged (letter via MMS, no Clara email), preserving PR #1026's no-duplicate rule.
graph TD
START[Renewal offer prepared] --> Q{tenant.phone?}
Q -->|has phone| MMS[Letter MMS
SMS + PDF] --> P2P[Phase 2: SMS + voice]
P2P --> AF1[AppFolio auto-email
bare offer]
Q -->|no phone| EMAIL[NEW: Clara renewal email
full terms + letter PDF attached]
EMAIL --> AF2[AppFolio auto-email
bare offer]
style EMAIL fill:#6F2AF4,color:#fff
The decision has four load-bearing parts:
Guard, in two places (defense-in-depth).
deriveOutreachChannels(renewal-channels.ts) appends'email'to the channel set only when the tenant has no phone — so a phone-having renewal never even schedules the email activity (workflow history stays byte-identical → replay-safe). Independently,sendOutreachEmailNOOPs early whentenant.phoneis present, so a mis-wired caller can never email a phone-having tenant. The "no phone → email" decision lives in exactly one place; the activity guard is a backstop.The current signature is
deriveOutreachChannels(policy: ChannelPolicyShape | null | undefined): RenewalChannel[](returns['sms','voice']). It gains a second arg carrying tenant phone presence (e.g.{ tenantHasPhone: boolean }), defaulting to "has phone" so existing/replay callers that omit it keep today's behavior. Both production callers (renewals/outreach/route.ts,renewal-auto-start.ts) have the tenant record in scope and passBoolean(tenant.phone).Phoneless AND no email on file: there is no Clara channel — the activity stamps
channelOutcomes.email = 'failed'(reason: no email on file) and returnsdispatched_failed, so the renewal falls through to the normal reminder ladder → PM escalation exactly as a fully-unreachable tenant does today. We do not invent a new terminal state for it.It is a new autonomous send, gated like every other channel.
sendOutreachEmailchanges from permanent NOOP to a real send, and therefore must respect the same two-factor renewal gate as SMS/voice/letter: globalRENEWAL_AUTONOMOUS_SENDING === 'armed'AND per-propertyProperty.autonomousRenewalEnabled === true, fail-closed, plus the existing receipt/idempotency dedup so retries and replays don't double-send. Ships disarmed. No quiet-hours gate — email is not TCPA-regulated the way SMS/voice are (this omission is intentional and will be commented).Content reuses
send-renewal-email.ts, extended twice.sendRenewalEmail/RenewalEmailContextalready render greeting + property + lease-end + a single rate line + portal CTA over the shared brand layout. Extend to (a) render the full term options frompreparedOffer.terms(e.g. 6-mo and 12-mo rents), not just the active rate, and (b) carry the letter PDF as asendEmailattachment — the transport already supportsattachments(base64 via SendGrid,src/lib/integrations/email/client.ts:160).The letter PDF fetch is shared, not duplicated. Because the MMS path bails before fetching for phoneless tenants, the email path must fetch the PDF itself via the same browser-agent flow (
handleFetchRenewalLetter). Extract that "fetch letter → bytes" step out ofsend-renewal-letter-mms.tsinto a reusable helper consumed by both paths, so MMS and email can't drift. If the fetch fails, the email still sends (terms in the body) and the outcome is stamped — a terms-only email beats silence.
Lease.channelOutcomes.email is stamped 'sent'/'failed' so the renewal page shows the real per-channel outcome.
Entity classification
No new entities. Reuses Lease.channelOutcomes.email (already typed, types.ts:590), PreparedOffer.terms (types.ts:432), and the existing RenewalEmailContext.
Consequences
Commits us to:
- A shared
ensureRenewalLetterPdf-style helper as the single source for the browser-agent letter fetch (MMS + email both call it). - The renewal email template now renders full term options — a content change that flows into any future email-content eval.
- One more send path under the autonomous gate, with its own idempotency receipt name (
sendOutreachEmail) andchannelOutcomes.emailstamping.
Renewal UI (detail page, renewals/[tenantId]/page.tsx): the per-channel outcome row already renders email — OUTCOME_CHANNEL_META and buildChannelOutcomeSteps already iterate ['voice','sms','email','letterMms'], so channelOutcomes.email = 'sent'/'failed' shows as "Email — sent / send failed" with no UI change. To also show the email as a message in the outreach thread (parity with the SMS/letter rows, e.g. "Clara sent a renewal email"), three small edits are needed: OutreachEntry.channel is hard-coded 'sms' | 'voice' (:581) and the message-channel coercion + label (:641-647) silently fold email into SMS. This requires the email send to also write a conv-renewal-… conversation row with channel: 'email' (mirroring how the letter-MMS path records its companion SMS via recordOutreachConversation). Both — the 3 UI edits and the conversation-row write — are in scope for the implementation PR. The renewals list page is unaffected (it renders no per-channel rows).
Becomes easier: phoneless renewals (and tenants whose only contact is email) receive the complete offer + letter; fewer "couldn't reach" PM escalations for missing-number tenants.
Becomes harder: the browser-agent letter fetch is now on the email path's critical path (mitigated by graceful degrade to a terms-only email); the workflow fan-out and outcomes have one more channel to reason about.
Follow-ups (explicitly out of scope here):
- Dead-number case (phone present but undeliverable, like the Camellia case that prompted this) is not covered — we chose no-phone-only. Covering it needs delivery-failure detection (the retired cascade territory) and is a separate decision.
- Escalation tuning: a no-phone tenant who receives the email but doesn't respond still follows the normal reminder ladder → PM escalation. Whether a delivered email should suppress the "couldn't reach" framing is a follow-up.
Alternatives considered
- Send the rich email to all renewal tenants. Rejected — reintroduces exactly the duplicate-notification harm PR #1026 fixed (tenant gets AppFolio email + Clara email + SMS letter).
- Fire on no-phone OR phone-channel-delivery-failure. Deferred — needs delivery-failure detection (retired SMS→email cascade), a larger surface. Start with the clean structural gap (no phone at all); revisit dead numbers separately.
- Stop escalating phoneless tenants and rely on AppFolio's email. Rejected — AppFolio's email lacks the terms and the letter PDF, and the PM still gets no signal that phone outreach is impossible for this tenant.
- Deliver the letter to phoneless tenants over some non-email channel. Rejected — no other no-phone-capable letter channel exists; email is the natural carrier and the attachment transport already exists.
Test plan (for the implementation PR that follows)
- Unit (
temporal-activities-renewal.test.ts): no phone → sends (assertssendRenewalEmailcalled with terms + attachment); has phone → NOOP; gate disarmed / property not autonomous → no send; idempotency hit → no double-send; no email on file →dispatched_failed; letter-fetch failure → still sends terms-only + stamps outcome. - Unit (
renewal-channels.test.ts): no phone → channels include'email'; has phone → excludes'email'. - Real-comms gauntlet (
scripts/canary/e2e-permutation-matrix.ts): newno-phone-email-fallbackcell on the test property (appfolio-45) with an ephemeral no-phone tenant — asserts the email is attempted (and lands inrenewal-gauntlet@propflowai.co), the letter is attached, and no SMS/voice fires. - UI: verify the renewal detail page shows the "Email — sent" outcome row and the "Clara sent a renewal email" thread entry for a no-phone tenant (the outcome row needs no change; the thread entry needs the 3 edits + conversation-row write above).