606b336a00
(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>
63 lines
2.7 KiB
Python
63 lines
2.7 KiB
Python
#!/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()
|