outreach: voice by-purpose (larger sample) + Tier-B Gmail draft creation (v0.1.0:71)

(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>
This commit is contained in:
Keysat
2026-06-08 22:30:05 -05:00
parent 49f84ca9a4
commit 606b336a00
9 changed files with 297 additions and 19 deletions
+10 -6
View File
@@ -32,6 +32,9 @@ from . import errors
GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"
# Drafts scope (authorized in Workspace DWD). We only ever CREATE drafts with it; the
# human sends from Gmail. (Google bundles send into this scope, but our code never sends.)
GMAIL_COMPOSE_SCOPE = "https://www.googleapis.com/auth/gmail.compose"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
@@ -61,13 +64,14 @@ class DWDCredentialProvider:
self._cache: dict[str, AccessToken] = {}
self._lock = threading.Lock()
def access_token_for(self, email_address: str) -> AccessToken:
def access_token_for(self, email_address: str, scope: str = GMAIL_READONLY_SCOPE) -> AccessToken:
key = f"{email_address}|{scope}"
with self._lock:
cached = self._cache.get(email_address)
cached = self._cache.get(key)
if cached and cached.expires_at - time.time() > 60:
return cached
token = self._mint(email_address)
self._cache[email_address] = token
token = self._mint(email_address, scope)
self._cache[key] = token
return token
def revoke(self, email_address: str) -> None:
@@ -78,7 +82,7 @@ class DWDCredentialProvider:
# ------------------------------------------------------------------ helpers
def _mint(self, subject_email: str) -> AccessToken:
def _mint(self, subject_email: str, scope: str = GMAIL_READONLY_SCOPE) -> AccessToken:
try:
from cryptography.hazmat.primitives import hashes, serialization # type: ignore
from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
@@ -92,7 +96,7 @@ class DWDCredentialProvider:
claim = {
"iss": self._client_email,
"sub": subject_email,
"scope": GMAIL_READONLY_SCOPE,
"scope": scope,
"aud": GOOGLE_TOKEN_URL,
"iat": now,
"exp": now + 3600,