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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user