From 606b336a00f88329fab21f310124e8cc318d0184 Mon Sep 17 00:00:00 2001 From: Keysat Date: Mon, 8 Jun 2026 22:30:05 -0500 Subject: [PATCH] outreach: voice by-purpose (larger sample) + Tier-B Gmail draft creation (v0.1.0:71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (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 --- backend/email_integration/compose.py | 92 +++++++++++++++++++++++ backend/email_integration/credentials.py | 16 ++-- backend/email_integration/test_compose.py | 62 +++++++++++++++ backend/mcp/outreach_agent.py | 39 +++++++--- backend/server.py | 36 +++++++++ frontend/index.html | 41 ++++++++++ start9/0.4/startos/utils.ts | 5 +- start9/0.4/startos/versions/index.ts | 5 +- start9/0.4/startos/versions/v0.1.0.71.ts | 20 +++++ 9 files changed, 297 insertions(+), 19 deletions(-) create mode 100644 backend/email_integration/compose.py create mode 100644 backend/email_integration/test_compose.py create mode 100644 start9/0.4/startos/versions/v0.1.0.71.ts 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','','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"] == "", + "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", "") + 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: " in dec and "References: " 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() diff --git a/backend/mcp/outreach_agent.py b/backend/mcp/outreach_agent.py index 0a276a2..9ca51f9 100644 --- a/backend/mcp/outreach_agent.py +++ b/backend/mcp/outreach_agent.py @@ -128,10 +128,23 @@ def _context(conn, investor_id): return name, "\n\n".join(sections) -def _voice_examples(conn, sender_email, limit=4): - """The sender's OWN recent sent LP emails — used as voice few-shot AND surfaced for - transparency (no black box). Returns (blocks_for_model, meta_for_ui). meta is the - sender's own emails, safe to show them.""" +# Keyword cues used to pick the sender's prior emails of the SAME PURPOSE as the draft +# (so the voice few-shot matches what they're writing, not just whatever is most recent). +PURPOSE_PATTERNS = { + "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: return [], [] 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 " "WHERE LOWER(from_email) = LOWER(?) AND is_matched = 1 " "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: 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 = [], [] - for r in rows: - body = (r["body_text"] or r["snippet"] or "")[:1200].strip() + for score, _neg_idx, r in scored[:limit]: + body = (r["body_text"] or r["snippet"] or "")[:900].strip() if not body: continue 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] except Exception: 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 @@ -194,7 +215,7 @@ def draft_outreach(conn, investor_id, outreach_type, guidance, db_path, sender_e if not name: return {"status": "not_found"} 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 # space). Nothing reaches Claude in the clear; the voice examples are reference only. diff --git a/backend/server.py b/backend/server.py index ac67c6c..ef21b80 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1915,6 +1915,8 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_architect_ground(user, body) if path == '/api/outreach/draft': 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): return self.handle_decide_activity_proposal(user, path.split('/')[-2], 'approve', body) if re.match(r'^/api/activity/proposals/[^/]+/dismiss$', path): @@ -3972,6 +3974,40 @@ class CRMHandler(BaseHTTPRequestHandler): conn.close() 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) ─── def handle_list_thesis_lines(self, user): if thesis_review is None: diff --git a/frontend/index.html b/frontend/index.html index 9b57fce..8660876 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9957,6 +9957,17 @@ const [result, setResult] = useState(null); const [draftText, setDraftText] = 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 = [ ['intro', 'Intro'], ['follow_up', 'Warm follow-up'], @@ -9994,6 +10005,8 @@ try { setDrafting(true); setResult(null); + setGmailResult(null); + setLastInvestor(inv); const res = await api('/api/outreach/draft', { method: 'POST', body: JSON.stringify({ investor_id: inv, outreach_type: t, guidance }), @@ -10015,6 +10028,26 @@ 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'; return ( @@ -10092,7 +10125,15 @@ value={draftText} onChange={(e) => setDraftText(e.target.value)} />
+
+ {gmailResult && gmailResult.status === 'ok' && ( +
+ {gmailResult.threaded ? 'In-thread reply draft' : 'Draft'} created in your Gmail. Open Gmail Drafts to review and send. +
+ )}
Voice based on:{' '} {result.voice_examples && result.voice_examples.length > 0 diff --git a/start9/0.4/startos/utils.ts b/start9/0.4/startos/utils.ts index 0c0d76d..831a96a 100644 --- a/start9/0.4/startos/utils.ts +++ b/start9/0.4/startos/utils.ts @@ -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: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) -// * Current: 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' +// * 0.1.0:70 (outreach voice upgrade — per-user voice from own emails + transparency; active-thread context) +// * 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 WEB_PORT = 8080 diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index 5ea7606..23e2e52 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -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_69 } from './v0.1.0.69' 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({ - current: v_0_1_0_70, - 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], + 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, v_0_1_0_70], }) diff --git a/start9/0.4/startos/versions/v0.1.0.71.ts b/start9/0.4/startos/versions/v0.1.0.71.ts new file mode 100644 index 0000000..79e3b06 --- /dev/null +++ b/start9/0.4/startos/versions/v0.1.0.71.ts @@ -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 () => {} }, +})