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:
@@ -0,0 +1,92 @@
|
||||
"""Create a Gmail DRAFT (never send) in the sender's mailbox via domain-wide delegation
|
||||
with the gmail.compose scope. Lets an approved Outreach draft land in the user's Gmail
|
||||
(and therefore Superhuman) Drafts — as an in-thread reply when there's an active thread,
|
||||
or a fresh email otherwise. The human reviews and sends from Gmail (guardrails #4, #6 —
|
||||
our code only ever creates a draft, it never sends).
|
||||
"""
|
||||
import base64
|
||||
import email.message
|
||||
import json as _json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from . import config as _cfg
|
||||
from . import credentials as _creds
|
||||
|
||||
|
||||
def _parse_subject_body(draft_text):
|
||||
"""Split a draft of the form 'Subject: ...\\n\\n<body>' into (subject, body)."""
|
||||
text = (draft_text or "").strip()
|
||||
lines = text.split("\n")
|
||||
if lines and lines[0].lower().startswith("subject:"):
|
||||
subject = lines[0].split(":", 1)[1].strip()
|
||||
body = "\n".join(lines[1:]).lstrip("\n")
|
||||
return subject, body
|
||||
return "", text
|
||||
|
||||
|
||||
def _reply_target(conn, investor_id):
|
||||
"""LP address + active-thread headers for an in-thread reply, from the most recent
|
||||
matched email with this investor. Returns {to, thread_id, in_reply_to} or None."""
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT e.rfc_message_id, e.gmail_thread_id, l.matched_address "
|
||||
"FROM emails e JOIN email_investor_links l ON l.email_id = e.id "
|
||||
"WHERE l.fundraising_investor_id = ? AND e.is_matched = 1 "
|
||||
"ORDER BY e.sent_at DESC LIMIT 1", (investor_id,)).fetchone()
|
||||
except Exception:
|
||||
return None
|
||||
if not row or not row["matched_address"]:
|
||||
return None
|
||||
return {"to": row["matched_address"], "thread_id": row["gmail_thread_id"],
|
||||
"in_reply_to": row["rfc_message_id"]}
|
||||
|
||||
|
||||
def _build_raw(from_addr, to_addr, subject, body, in_reply_to=None):
|
||||
msg = email.message.EmailMessage()
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject or "(no subject)"
|
||||
if in_reply_to:
|
||||
msg["In-Reply-To"] = in_reply_to
|
||||
msg["References"] = in_reply_to
|
||||
msg.set_content(body or "")
|
||||
return base64.urlsafe_b64encode(msg.as_bytes()).decode("ascii")
|
||||
|
||||
|
||||
def create_outreach_draft(conn, sender_email, investor_id, draft_text):
|
||||
"""Create a Gmail draft in `sender_email`'s mailbox addressed to the investor.
|
||||
Returns {status, ...}. Never sends."""
|
||||
if not sender_email:
|
||||
return {"status": "no_sender"}
|
||||
if not _cfg.CONFIG.enabled:
|
||||
return {"status": "integration_disabled"}
|
||||
subject, body = _parse_subject_body(draft_text)
|
||||
if not body.strip():
|
||||
return {"status": "empty"}
|
||||
target = _reply_target(conn, investor_id)
|
||||
if not target:
|
||||
return {"status": "no_recipient"} # no email history -> no LP address to draft to
|
||||
try:
|
||||
provider = _creds.build_provider(lambda: conn)
|
||||
token = provider.access_token_for(sender_email, _creds.GMAIL_COMPOSE_SCOPE).token
|
||||
except Exception as exc:
|
||||
return {"status": "auth_error", "reason": str(exc)}
|
||||
raw = _build_raw(sender_email, target["to"], subject, body, target.get("in_reply_to"))
|
||||
payload = {"message": {"raw": raw}}
|
||||
if target.get("thread_id"):
|
||||
payload["message"]["threadId"] = target["thread_id"]
|
||||
url = f"https://gmail.googleapis.com/gmail/v1/users/{urllib.parse.quote(sender_email)}/drafts"
|
||||
req = urllib.request.Request(url, data=_json.dumps(payload).encode("utf-8"), method="POST",
|
||||
headers={"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
_json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"status": "gmail_error", "reason": e.read().decode("utf-8", "replace")[:300]}
|
||||
except Exception as exc:
|
||||
return {"status": "gmail_error", "reason": str(exc)}
|
||||
return {"status": "ok", "to": target["to"], "threaded": bool(target.get("thread_id")),
|
||||
"gmail_url": "https://mail.google.com/mail/u/0/#drafts"}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test the Gmail-draft message construction (the part that doesn't need live Gmail):
|
||||
subject/body parsing, reply-target resolution, and the RFC822 build incl. threading
|
||||
headers. The actual drafts.create call is exercised on the box. Synthetic data only.
|
||||
Run: cd backend && python3 email_integration/test_compose.py
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from email_integration import compose as cp # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
def main():
|
||||
s, b = cp._parse_subject_body("Subject: Following up\n\nHi Sarah,\n\nthanks for the call.")
|
||||
check(s == "Following up" and b.startswith("Hi Sarah"), "parses 'Subject:' line + body")
|
||||
s2, b2 = cp._parse_subject_body("No subject prefix here")
|
||||
check(s2 == "" and b2 == "No subject prefix here", "no subject line -> empty subject, full body")
|
||||
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.row_factory = sqlite3.Row
|
||||
c.executescript("""
|
||||
CREATE TABLE emails(id TEXT, rfc_message_id TEXT, gmail_thread_id TEXT, sent_at TEXT, is_matched INT);
|
||||
CREATE TABLE email_investor_links(email_id TEXT, fundraising_investor_id TEXT, matched_address TEXT);
|
||||
""")
|
||||
c.execute("INSERT INTO emails VALUES('e1','<m1@x>','t1','2026-06-01',1)")
|
||||
c.execute("INSERT INTO email_investor_links VALUES('e1','inv1','lp@harborvine.example')")
|
||||
c.commit()
|
||||
t = cp._reply_target(c, "inv1")
|
||||
check(t and t["to"] == "lp@harborvine.example" and t["thread_id"] == "t1" and t["in_reply_to"] == "<m1@x>",
|
||||
"reply target resolves LP address + thread + in-reply-to")
|
||||
check(cp._reply_target(c, "nope") is None, "no history -> no reply target")
|
||||
|
||||
raw = cp._build_raw("grant@ten31.xyz", "lp@x.example", "Hi", "Body text here", "<m1@x>")
|
||||
dec = base64.urlsafe_b64decode(raw).decode("utf-8", "replace")
|
||||
check("From: grant@ten31.xyz" in dec and "To: lp@x.example" in dec, "RFC822 has From + To")
|
||||
check("Subject: Hi" in dec and "Body text here" in dec, "RFC822 has Subject + body")
|
||||
check("In-Reply-To: <m1@x>" in dec and "References: <m1@x>" in dec, "threading headers set for replies")
|
||||
raw2 = cp._build_raw("a@b.co", "c@d.co", "", "body", None)
|
||||
dec2 = base64.urlsafe_b64decode(raw2).decode("utf-8", "replace")
|
||||
check("Subject: (no subject)" in dec2 and "In-Reply-To" not in dec2, "no subject / no thread -> fresh email")
|
||||
|
||||
if FAILS:
|
||||
print(f"\nFAILED ({len(FAILS)})")
|
||||
for f in FAILS:
|
||||
print(" - " + f)
|
||||
sys.exit(1)
|
||||
print("\nALL PASS (gmail compose message construction)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user