diff --git a/backend/email_integration/compose.py b/backend/email_integration/compose.py new file mode 100644 index 0000000..ee160dd --- /dev/null +++ b/backend/email_integration/compose.py @@ -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
' 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"} diff --git a/backend/email_integration/credentials.py b/backend/email_integration/credentials.py index bbff89b..84dfc9e 100644 --- a/backend/email_integration/credentials.py +++ b/backend/email_integration/credentials.py @@ -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, diff --git a/backend/email_integration/test_compose.py b/backend/email_integration/test_compose.py new file mode 100644 index 0000000..a456349 --- /dev/null +++ b/backend/email_integration/test_compose.py @@ -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','