ADR-0036: Email the renewal offer + letter to tenants with no phone

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:

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:

  1. 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, sendOutreachEmail NOOPs early when tenant.phone is 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 pass Boolean(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 returns dispatched_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.

  2. It is a new autonomous send, gated like every other channel. sendOutreachEmail changes from permanent NOOP to a real send, and therefore must respect the same two-factor renewal gate as SMS/voice/letter: global RENEWAL_AUTONOMOUS_SENDING === 'armed' AND per-property Property.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).

  3. Content reuses send-renewal-email.ts, extended twice. sendRenewalEmail / RenewalEmailContext already render greeting + property + lease-end + a single rate line + portal CTA over the shared brand layout. Extend to (a) render the full term options from preparedOffer.terms (e.g. 6-mo and 12-mo rents), not just the active rate, and (b) carry the letter PDF as a sendEmail attachment — the transport already supports attachments (base64 via SendGrid, src/lib/integrations/email/client.ts:160).

  4. 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 of send-renewal-letter-mms.ts into 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:

Renewal UI (detail page, renewals/[tenantId]/page.tsx): the per-channel outcome row already renders emailOUTCOME_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):

Alternatives considered

  1. 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).
  2. 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.
  3. 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.
  4. 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)