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:
Keysat
2026-06-08 22:30:05 -05:00
parent 49f84ca9a4
commit 606b336a00
9 changed files with 297 additions and 19 deletions
+41
View File
@@ -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)} />
<div className="index-action-buttons" style={{ marginTop: '10px' }}>
<button onClick={copy}>Copy draft</button>
<button onClick={createGmailDraft} disabled={gmailBusy}>
{gmailBusy ? 'Creating…' : 'Create Gmail draft'}
</button>
</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' }}>
<strong>Voice based on:</strong>{' '}
{result.voice_examples && result.voice_examples.length > 0