Non-blocking items from the v76 reviewer pass. No redeploy needed — the box runs
v76 and its happy path is unaffected; these ride the next build:
- digest_mailer.send_digest: when Gmail is enabled but no sender resolves
(CRM_DIGEST_SENDER unset and no admin email), raise NoTransport so the caller
returns a clear 400 instead of a generic 502.
- gmail_send.send_via_gmail: wrap OSError/URLError (timeout/DNS) as a RuntimeError
("Gmail API unreachable: ...") to match the HTTPError handling; include the
sender in the HTTPError message for debuggability.
- credentials.py: correct the now-stale GMAIL_COMPOSE_SCOPE comment (the digest
mailer sends with this scope; only outreach drafts never send).
- test_gmail_send.py: add the HTTPError->RuntimeError branch, default_sender DB
fallback (+None case + env override), and the send_digest SMTP-tag path.
19/19 backend tests green.
(1) Voice: _voice_examples now picks the sender's prior sent emails OF THE SAME PURPOSE
(PURPOSE_PATTERNS keyword cues per outreach type), larger sample (8) weighted by purpose
then recency — not just recent. meta carries on_topic for transparency.
(2) Tier-B sending (gmail.compose now authorized in Workspace DWD). New
email_integration/compose.py create_outreach_draft: mints a compose-scoped DWD token for
the sender (credentials._mint/access_token_for parameterized by scope; GMAIL_COMPOSE_SCOPE),
builds an RFC822 message, and POSTs gmail.drafts.create into the SENDER's mailbox — as an
in-thread reply (threadId + In-Reply-To/References, recipient = matched LP address) when
there's an active thread, else a fresh email. NEVER sends — the human sends from Gmail
(guardrails #4, #6). Route POST /api/outreach/gmail-draft; UI "Create Gmail draft" button +
"Open Gmail Drafts" link. Tests: test_compose.py (parse/reply-target/RFC822+threading).
Message construction unit-verified; the live drafts.create runs on the box.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>