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"
|
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"
|
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||||
|
|
||||||
|
|
||||||
@@ -61,13 +64,14 @@ class DWDCredentialProvider:
|
|||||||
self._cache: dict[str, AccessToken] = {}
|
self._cache: dict[str, AccessToken] = {}
|
||||||
self._lock = threading.Lock()
|
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:
|
with self._lock:
|
||||||
cached = self._cache.get(email_address)
|
cached = self._cache.get(key)
|
||||||
if cached and cached.expires_at - time.time() > 60:
|
if cached and cached.expires_at - time.time() > 60:
|
||||||
return cached
|
return cached
|
||||||
token = self._mint(email_address)
|
token = self._mint(email_address, scope)
|
||||||
self._cache[email_address] = token
|
self._cache[key] = token
|
||||||
return token
|
return token
|
||||||
|
|
||||||
def revoke(self, email_address: str) -> None:
|
def revoke(self, email_address: str) -> None:
|
||||||
@@ -78,7 +82,7 @@ class DWDCredentialProvider:
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ helpers
|
# ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
def _mint(self, subject_email: str) -> AccessToken:
|
def _mint(self, subject_email: str, scope: str = GMAIL_READONLY_SCOPE) -> AccessToken:
|
||||||
try:
|
try:
|
||||||
from cryptography.hazmat.primitives import hashes, serialization # type: ignore
|
from cryptography.hazmat.primitives import hashes, serialization # type: ignore
|
||||||
from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
|
from cryptography.hazmat.primitives.asymmetric import padding # type: ignore
|
||||||
@@ -92,7 +96,7 @@ class DWDCredentialProvider:
|
|||||||
claim = {
|
claim = {
|
||||||
"iss": self._client_email,
|
"iss": self._client_email,
|
||||||
"sub": subject_email,
|
"sub": subject_email,
|
||||||
"scope": GMAIL_READONLY_SCOPE,
|
"scope": scope,
|
||||||
"aud": GOOGLE_TOKEN_URL,
|
"aud": GOOGLE_TOKEN_URL,
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + 3600,
|
"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()
|
||||||
@@ -128,10 +128,23 @@ def _context(conn, investor_id):
|
|||||||
return name, "\n\n".join(sections)
|
return name, "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
def _voice_examples(conn, sender_email, limit=4):
|
# Keyword cues used to pick the sender's prior emails of the SAME PURPOSE as the draft
|
||||||
"""The sender's OWN recent sent LP emails — used as voice few-shot AND surfaced for
|
# (so the voice few-shot matches what they're writing, not just whatever is most recent).
|
||||||
transparency (no black box). Returns (blocks_for_model, meta_for_ui). meta is the
|
PURPOSE_PATTERNS = {
|
||||||
sender's own emails, safe to show them."""
|
"intro": ["introduc", "nice to meet", "reaching out", "wanted to connect", "by way of introduction", "e-meet"],
|
||||||
|
"follow_up": ["follow up", "following up", "circle back", "circling back", "checking in",
|
||||||
|
"wanted to revisit", "any thoughts", "wanted to follow", "touching base"],
|
||||||
|
"fund_update": ["update", "progress", "quarter", "deployed", "portfolio", "milestone", "closing", "fund iii"],
|
||||||
|
"meeting_follow_up": ["great to meet", "great speaking", "thanks for the call", "thanks for your time",
|
||||||
|
"after our", "following our", "enjoyed our", "great to connect", "great chatting"],
|
||||||
|
"nurture": ["checking in", "hope you", "thinking of you", "stay in touch", "wanted to share", "thought you"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _voice_examples(conn, sender_email, outreach_type=None, limit=8):
|
||||||
|
"""The sender's OWN sent LP emails OF THE SAME PURPOSE — used as voice few-shot AND
|
||||||
|
surfaced for transparency (no black box). Larger sample, purpose-weighted (not just
|
||||||
|
recent). Returns (blocks_for_model, meta_for_ui); meta is the sender's own emails."""
|
||||||
if not sender_email:
|
if not sender_email:
|
||||||
return [], []
|
return [], []
|
||||||
try:
|
try:
|
||||||
@@ -139,12 +152,19 @@ def _voice_examples(conn, sender_email, limit=4):
|
|||||||
"SELECT subject, body_text, snippet, sent_at, to_emails_json FROM emails "
|
"SELECT subject, body_text, snippet, sent_at, to_emails_json FROM emails "
|
||||||
"WHERE LOWER(from_email) = LOWER(?) AND is_matched = 1 "
|
"WHERE LOWER(from_email) = LOWER(?) AND is_matched = 1 "
|
||||||
"AND body_text IS NOT NULL AND TRIM(body_text) <> '' "
|
"AND body_text IS NOT NULL AND TRIM(body_text) <> '' "
|
||||||
"ORDER BY sent_at DESC LIMIT ?", (sender_email, limit)).fetchall()
|
"ORDER BY sent_at DESC LIMIT 80", (sender_email,)).fetchall()
|
||||||
except Exception:
|
except Exception:
|
||||||
return [], []
|
return [], []
|
||||||
|
pats = PURPOSE_PATTERNS.get(outreach_type or "", [])
|
||||||
|
scored = []
|
||||||
|
for idx, r in enumerate(rows):
|
||||||
|
text = ((r["subject"] or "") + " " + (r["body_text"] or r["snippet"] or "")).lower()
|
||||||
|
score = sum(1 for p in pats if p in text)
|
||||||
|
scored.append((score, -idx, r)) # purpose match first, then more recent
|
||||||
|
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||||
blocks, meta = [], []
|
blocks, meta = [], []
|
||||||
for r in rows:
|
for score, _neg_idx, r in scored[:limit]:
|
||||||
body = (r["body_text"] or r["snippet"] or "")[:1200].strip()
|
body = (r["body_text"] or r["snippet"] or "")[:900].strip()
|
||||||
if not body:
|
if not body:
|
||||||
continue
|
continue
|
||||||
blocks.append(f"Example — {r['subject'] or '(no subject)'}\n{body}")
|
blocks.append(f"Example — {r['subject'] or '(no subject)'}\n{body}")
|
||||||
@@ -155,7 +175,8 @@ def _voice_examples(conn, sender_email, limit=4):
|
|||||||
to = arr[0].get("email") if isinstance(arr[0], dict) else arr[0]
|
to = arr[0].get("email") if isinstance(arr[0], dict) else arr[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
to = ""
|
to = ""
|
||||||
meta.append({"subject": r["subject"] or "(no subject)", "date": (r["sent_at"] or "")[:10], "to": to})
|
meta.append({"subject": r["subject"] or "(no subject)", "date": (r["sent_at"] or "")[:10],
|
||||||
|
"to": to, "on_topic": score > 0})
|
||||||
return blocks, meta
|
return blocks, meta
|
||||||
|
|
||||||
|
|
||||||
@@ -194,7 +215,7 @@ def draft_outreach(conn, investor_id, outreach_type, guidance, db_path, sender_e
|
|||||||
if not name:
|
if not name:
|
||||||
return {"status": "not_found"}
|
return {"status": "not_found"}
|
||||||
type_desc = OUTREACH_TYPES.get(outreach_type, OUTREACH_TYPES["follow_up"])
|
type_desc = OUTREACH_TYPES.get(outreach_type, OUTREACH_TYPES["follow_up"])
|
||||||
voice_blocks, voice_meta = _voice_examples(conn, sender_email)
|
voice_blocks, voice_meta = _voice_examples(conn, sender_email, outreach_type)
|
||||||
|
|
||||||
# 1) Scrub the sender's voice examples + the recipient context TOGETHER (shared token
|
# 1) Scrub the sender's voice examples + the recipient context TOGETHER (shared token
|
||||||
# space). Nothing reaches Claude in the clear; the voice examples are reference only.
|
# space). Nothing reaches Claude in the clear; the voice examples are reference only.
|
||||||
|
|||||||
@@ -1915,6 +1915,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
return self.handle_architect_ground(user, body)
|
return self.handle_architect_ground(user, body)
|
||||||
if path == '/api/outreach/draft':
|
if path == '/api/outreach/draft':
|
||||||
return self.handle_outreach_draft(user, body)
|
return self.handle_outreach_draft(user, body)
|
||||||
|
if path == '/api/outreach/gmail-draft':
|
||||||
|
return self.handle_outreach_gmail_draft(user, body)
|
||||||
if re.match(r'^/api/activity/proposals/[^/]+/approve$', path):
|
if re.match(r'^/api/activity/proposals/[^/]+/approve$', path):
|
||||||
return self.handle_decide_activity_proposal(user, path.split('/')[-2], 'approve', body)
|
return self.handle_decide_activity_proposal(user, path.split('/')[-2], 'approve', body)
|
||||||
if re.match(r'^/api/activity/proposals/[^/]+/dismiss$', path):
|
if re.match(r'^/api/activity/proposals/[^/]+/dismiss$', path):
|
||||||
@@ -3972,6 +3974,40 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
conn.close()
|
conn.close()
|
||||||
return self.send_json({"data": res})
|
return self.send_json({"data": res})
|
||||||
|
|
||||||
|
def handle_outreach_gmail_draft(self, user, body):
|
||||||
|
"""Create a Gmail DRAFT from an approved outreach draft (in-thread reply when there
|
||||||
|
is an active thread). Never sends — the human sends from Gmail (guardrails #4, #6)."""
|
||||||
|
body = body or {}
|
||||||
|
inv = body.get('investor_id')
|
||||||
|
text = body.get('draft') or ''
|
||||||
|
if not inv or not text.strip():
|
||||||
|
return self.send_error_json("investor_id and draft required", 400)
|
||||||
|
try:
|
||||||
|
from email_integration import compose as _compose
|
||||||
|
except Exception:
|
||||||
|
return self.send_error_json("Gmail compose unavailable", 503)
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
sender_email = None
|
||||||
|
try:
|
||||||
|
r = conn.execute("SELECT email FROM users WHERE id=?", (user.get('user_id'),)).fetchone()
|
||||||
|
sender_email = r[0] if r else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
res = _compose.create_outreach_draft(conn, sender_email, inv, text)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO interaction_log (id, ts, actor_type, actor_id, action, target_type, target_id, payload, source, created_at) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(generate_id(), now(), "human", user.get('user_id'), "outreach.gmail_draft_created",
|
||||||
|
"fundraising_investor", inv, json.dumps({"status": res.get('status')}), "crm_ui", now()))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return self.send_json({"data": res})
|
||||||
|
|
||||||
# ─── Architect thesis (Phase 1) ───
|
# ─── Architect thesis (Phase 1) ───
|
||||||
def handle_list_thesis_lines(self, user):
|
def handle_list_thesis_lines(self, user):
|
||||||
if thesis_review is None:
|
if thesis_review is None:
|
||||||
|
|||||||
@@ -9957,6 +9957,17 @@
|
|||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [draftText, setDraftText] = useState('');
|
const [draftText, setDraftText] = useState('');
|
||||||
const [radar, setRadar] = useState([]);
|
const [radar, setRadar] = useState([]);
|
||||||
|
const [lastInvestor, setLastInvestor] = useState('');
|
||||||
|
const [gmailBusy, setGmailBusy] = useState(false);
|
||||||
|
const [gmailResult, setGmailResult] = useState(null);
|
||||||
|
const GMAIL_FAIL = {
|
||||||
|
no_recipient: "No email address on file for this investor, so there's nothing to address the draft to.",
|
||||||
|
integration_disabled: 'Gmail integration is off on the server.',
|
||||||
|
auth_error: 'Could not authorize Gmail — check the compose scope is authorized in Workspace admin.',
|
||||||
|
no_sender: 'Your account has no email on file to draft from.',
|
||||||
|
empty: 'The draft is empty.',
|
||||||
|
gmail_error: 'Gmail rejected the draft.',
|
||||||
|
};
|
||||||
const TYPES = [
|
const TYPES = [
|
||||||
['intro', 'Intro'],
|
['intro', 'Intro'],
|
||||||
['follow_up', 'Warm follow-up'],
|
['follow_up', 'Warm follow-up'],
|
||||||
@@ -9994,6 +10005,8 @@
|
|||||||
try {
|
try {
|
||||||
setDrafting(true);
|
setDrafting(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setGmailResult(null);
|
||||||
|
setLastInvestor(inv);
|
||||||
const res = await api('/api/outreach/draft', {
|
const res = await api('/api/outreach/draft', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ investor_id: inv, outreach_type: t, guidance }),
|
body: JSON.stringify({ investor_id: inv, outreach_type: t, guidance }),
|
||||||
@@ -10015,6 +10028,26 @@
|
|||||||
catch (_) { onShowToast('Could not copy', 'error'); }
|
catch (_) { onShowToast('Could not copy', 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createGmailDraft = async () => {
|
||||||
|
if (gmailBusy || !lastInvestor) return;
|
||||||
|
try {
|
||||||
|
setGmailBusy(true);
|
||||||
|
setGmailResult(null);
|
||||||
|
const res = await api('/api/outreach/gmail-draft', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ investor_id: lastInvestor, draft: draftText }),
|
||||||
|
}, token);
|
||||||
|
const d = res.data || res;
|
||||||
|
setGmailResult(d);
|
||||||
|
if (d.status === 'ok') onShowToast(d.threaded ? 'Reply draft created in your Gmail' : 'Draft created in your Gmail', 'success');
|
||||||
|
else onShowToast(GMAIL_FAIL[d.status] || d.reason || 'Could not create the draft', 'error');
|
||||||
|
} catch (err) {
|
||||||
|
onShowToast(getErrorMessage(err, 'Could not create the draft'), 'error');
|
||||||
|
} finally {
|
||||||
|
setGmailBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const ok = result && result.status === 'ok';
|
const ok = result && result.status === 'ok';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -10092,7 +10125,15 @@
|
|||||||
value={draftText} onChange={(e) => setDraftText(e.target.value)} />
|
value={draftText} onChange={(e) => setDraftText(e.target.value)} />
|
||||||
<div className="index-action-buttons" style={{ marginTop: '10px' }}>
|
<div className="index-action-buttons" style={{ marginTop: '10px' }}>
|
||||||
<button onClick={copy}>Copy draft</button>
|
<button onClick={copy}>Copy draft</button>
|
||||||
|
<button onClick={createGmailDraft} disabled={gmailBusy}>
|
||||||
|
{gmailBusy ? 'Creating…' : 'Create Gmail draft'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{gmailResult && gmailResult.status === 'ok' && (
|
||||||
|
<div className="index-action-hint" style={{ marginTop: '8px' }}>
|
||||||
|
{gmailResult.threaded ? 'In-thread reply draft' : 'Draft'} created in your Gmail. <a href={gmailResult.gmail_url} target="_blank" rel="noopener noreferrer">Open Gmail Drafts</a> to review and send.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="index-action-hint" style={{ marginTop: '12px' }}>
|
<div className="index-action-hint" style={{ marginTop: '12px' }}>
|
||||||
<strong>Voice based on:</strong>{' '}
|
<strong>Voice based on:</strong>{' '}
|
||||||
{result.voice_examples && result.voice_examples.length > 0
|
{result.voice_examples && result.voice_examples.length > 0
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
|||||||
// * 0.1.0:67 (remove LP Objections page — generic/unverifiable; pivot to proactive outreach)
|
// * 0.1.0:67 (remove LP Objections page — generic/unverifiable; pivot to proactive outreach)
|
||||||
// * 0.1.0:68 (Outreach Draft Assistant — tailored LP drafts via thesis + redaction boundary)
|
// * 0.1.0:68 (Outreach Draft Assistant — tailored LP drafts via thesis + redaction boundary)
|
||||||
// * 0.1.0:69 (follow-up radar — deterministic "needs attention" list + one-click draft)
|
// * 0.1.0:69 (follow-up radar — deterministic "needs attention" list + one-click draft)
|
||||||
// * Current: 0.1.0:70 (outreach voice upgrade — per-user voice from own emails + transparency; active-thread context)
|
// * 0.1.0:70 (outreach voice upgrade — per-user voice from own emails + transparency; active-thread context)
|
||||||
export const PACKAGE_VERSION = '0.1.0:70'
|
// * Current: 0.1.0:71 (voice by-purpose larger sample + Tier-B: create Gmail draft w/ in-thread reply)
|
||||||
|
export const PACKAGE_VERSION = '0.1.0:71'
|
||||||
|
|
||||||
export const DATA_MOUNT_PATH = '/data'
|
export const DATA_MOUNT_PATH = '/data'
|
||||||
export const WEB_PORT = 8080
|
export const WEB_PORT = 8080
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ import { v_0_1_0_67 } from './v0.1.0.67'
|
|||||||
import { v_0_1_0_68 } from './v0.1.0.68'
|
import { v_0_1_0_68 } from './v0.1.0.68'
|
||||||
import { v_0_1_0_69 } from './v0.1.0.69'
|
import { v_0_1_0_69 } from './v0.1.0.69'
|
||||||
import { v_0_1_0_70 } from './v0.1.0.70'
|
import { v_0_1_0_70 } from './v0.1.0.70'
|
||||||
|
import { v_0_1_0_71 } from './v0.1.0.71'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0_70,
|
current: v_0_1_0_71,
|
||||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69],
|
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
// Two outreach upgrades. (1) Voice now learns from the sender's prior emails OF THE SAME
|
||||||
|
// PURPOSE (larger, purpose-weighted sample, not just recent). (2) Tier-B sending: with the
|
||||||
|
// gmail.compose scope authorized, an approved draft can be created straight into the
|
||||||
|
// sender's Gmail (and therefore Superhuman) Drafts — as an in-thread reply when there's an
|
||||||
|
// active thread, or a fresh email otherwise. Our code only ever CREATES a draft; the human
|
||||||
|
// sends from Gmail (guardrails #4, #6). No schema change.
|
||||||
|
export const v_0_1_0_71 = VersionInfo.of({
|
||||||
|
version: '0.1.0:71',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US: [
|
||||||
|
'Outreach: drafts now learn your voice from your prior emails of the same purpose (a',
|
||||||
|
'bigger, more relevant sample), and a new "Create Gmail draft" button drops the approved',
|
||||||
|
'draft into your Gmail Drafts — as an in-thread reply for follow-ups — for you to review',
|
||||||
|
'and send. Nothing is sent automatically.',
|
||||||
|
].join(' '),
|
||||||
|
},
|
||||||
|
migrations: { up: async () => {}, down: async () => {} },
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user