Files
Keysat 606b336a00 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>
2026-06-08 22:30:05 -05:00

93 lines
4.0 KiB
Python

"""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"}