← back to Commercialrealestate
Add gated Send-via-George button — creates a Gmail DRAFT (never sends)
c164c3ffd0514a3e7ccafa5448f5f1cf948c143d · 2026-07-17 08:51:21 -0700 · Steve
- Modal gets a 📤 Send via George button beside Save letter. Confirm gate, then
POSTs /api/contacts/:key/send-draft which proxies to the George gmail-agent on the
same box (127.0.0.1:9850 /api/drafts) creating a Gmail DRAFT from steve-office to
the agent — it is NOT sent (never calls /api/send). Letter log shows a 📤 drafted badge.
- GATED THREE WAYS: draft-only (nothing leaves until Steve reviews+sends in Gmail);
FAIL-CLOSED (no GEORGE_AUTH in CRCP env -> 503 george_not_configured, does nothing);
never touches George's live-send route. Browser never holds George creds (server proxies).
- Validates recipient email at click time (repaint-proof); graceful alert on fail-closed.
Verified headless: button renders, no-email click validates, confirm gate fires, fail-closed
path handled gracefully. Inert until GEORGE_AUTH is set in the CRCP env.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M public/index.htmlM scripts/serve.js
Diff
commit c164c3ffd0514a3e7ccafa5448f5f1cf948c143d
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 17 08:51:21 2026 -0700
Add gated Send-via-George button — creates a Gmail DRAFT (never sends)
- Modal gets a 📤 Send via George button beside Save letter. Confirm gate, then
POSTs /api/contacts/:key/send-draft which proxies to the George gmail-agent on the
same box (127.0.0.1:9850 /api/drafts) creating a Gmail DRAFT from steve-office to
the agent — it is NOT sent (never calls /api/send). Letter log shows a 📤 drafted badge.
- GATED THREE WAYS: draft-only (nothing leaves until Steve reviews+sends in Gmail);
FAIL-CLOSED (no GEORGE_AUTH in CRCP env -> 503 george_not_configured, does nothing);
never touches George's live-send route. Browser never holds George creds (server proxies).
- Validates recipient email at click time (repaint-proof); graceful alert on fail-closed.
Verified headless: button renders, no-email click validates, confirm gate fires, fail-closed
path handled gracefully. Inert until GEORGE_AUTH is set in the CRCP env.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
public/index.html | 20 ++++++++++++++++++--
scripts/serve.js | 31 +++++++++++++++++++++++++++++++
2 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/public/index.html b/public/index.html
index c9f98ed..ae52207 100644
--- a/public/index.html
+++ b/public/index.html
@@ -232,6 +232,9 @@
.composeActs { display:flex; gap:8px; justify-content:flex-end; }
.composeActs button { font-size:12px; padding:6px 14px; border-radius:10px; border:1px solid #2a3340; background:#141a22; color:var(--ink); cursor:pointer; }
.composeActs button.primary { border-color:#2f5233; color:#8fdfa0; }
+ .composeActs button.gated { border-color:#5a4a1e; color:#e3b341; }
+ .composeActs button.gated:hover:not([disabled]) { border-color:#e3b341; }
+ .composeActs button[disabled] { opacity:.4; cursor:not-allowed; }
.crmToast { position:fixed; bottom:22px; left:50%; transform:translateX(-50%) translateY(12px); background:#11161d; border:1px solid #2a3340; color:var(--ink); font-size:12.5px; padding:9px 16px; border-radius:20px; opacity:0; transition:opacity .2s,transform .2s; z-index:9500; pointer-events:none; }
.crmToast.on { opacity:1; transform:translateX(-50%) translateY(0); }
/* Card-view agent-contact block (every card, every asset type) */
@@ -1600,7 +1603,7 @@ function contactModalBody(p){
const a=agentInfo(p), id=p.id, firm=a.firm;
const web=a.name?` <a class="flink" href="${gsearch('"'+a.name+'"'+(firm&&firm!=='Unknown'?' "'+firm+'"':'')+' real estate agent')}" target="_blank" rel="noopener noreferrer">find agent ↗</a>`:'';
const letters=a.letters.length?`<div class="crm-letters"><div class="deeplbl">Saved letters (${a.letters.length}) — saved, not sent</div>`
- +a.letters.map((l,i)=>`<div class="lett"><div class="letth"><b>${esc(l.subject||'(no subject)')}</b><span class="letts">${fmtWhen(l.ts)}</span><button class="letdel" data-letdel="${id}" data-idx="${i}" title="Delete this saved letter">✕</button></div><div class="lettb">${esc(l.body||'')}</div></div>`).join('')+`</div>`:'';
+ +a.letters.map((l,i)=>`<div class="lett"><div class="letth"><b>${esc(l.subject||'(no subject)')}</b>${l.draft_id?`<span class="achip done" title="Gmail draft created${l.to?' to '+esc(l.to):''} — review & send in Gmail">📤 drafted</span>`:''}<span class="letts">${fmtWhen(l.ts)}</span><button class="letdel" data-letdel="${id}" data-idx="${i}" title="Delete this saved letter">✕</button></div><div class="lettb">${esc(l.body||'')}</div></div>`).join('')+`</div>`:'';
return `<div class="composeCard">
<div class="composeH">✎ Agent contact — <b>${esc(p.address||'listing')}</b>${p.city?', '+esc(p.city):''}${web}<button class="composeX" title="Close">✕</button></div>
<div class="cm-info">
@@ -1611,11 +1614,12 @@ function contactModalBody(p){
<button class="abtn markbtn" data-mark="${id}">${a.contacted_at?'↻ Update contacted date':'✓ Mark contacted'}</button>
${a.contacted_at?`<span class="achip done">contacted ${fmtWhen(a.contacted_at)}</span>`:''}
</div>
- <div class="composeGate">⚑ Letters are SAVED to this listing's log — <b>not sent</b>. Sending to an agent is Steve-gated (George external-send).</div>
+ <div class="composeGate">⚑ <b>Save letter</b> stores it in this listing's log. <b>Send via George</b> creates a Gmail <b>DRAFT</b> to the agent — it is NOT sent; you review & hit send in Gmail.</div>
<input id="composeSubj" placeholder="Subject" value="${esc('Interest in '+(p.address||'your listing')+(p.city?', '+p.city:''))}">
<textarea id="composeBody" rows="8">${esc(agentDraft(p,a))}</textarea>
<div class="composeActs">
<button id="composeSave" class="primary" data-id="${id}">💾 Save letter</button>
+ <button id="composeSend" class="gated" data-id="${id}" title="Create a Gmail DRAFT to the agent (fill the Email field first) — not sent, you review & send in Gmail">📤 Send via George</button>
<button id="composeCopy">📋 Copy</button>
<button class="composeX2">Cancel</button>
</div>
@@ -1639,6 +1643,18 @@ function openContact(id){
else toast('⚠ '+(j.error||'save failed')); }catch(err){ toast('⚠ '+err.message); }
return;
}
+ if(e.target.closest('#composeSend')){
+ const to=((m.querySelector('.crmf[data-info=email]')||{}).value||'').trim();
+ if(!to){ toast('add the agent email first'); return; }
+ const subject=m.querySelector('#composeSubj').value, body=m.querySelector('#composeBody').value;
+ if(!confirm('Create a Gmail DRAFT to '+to+' via George?\n\nIt will NOT be sent — it lands in your Gmail drafts (steve@designerwallcoverings.com) for you to review and send.')) return;
+ try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/send-draft',{to,subject,body});
+ if(j.ok){ window.CONTACTS[id]=j.contact; refreshContactUI(id); repaintModal(id); toast('📤 Gmail draft created — review & send in Gmail'); }
+ else if(j.error==='george_not_configured'){ toast('⚠ George drafting not enabled on this box'); alert('George drafting isn’t enabled on this server yet — the letter is not drafted.\n\nTo turn it on, GEORGE_AUTH must be set in the CRCP env (one-time). The letter is unaffected; use 💾 Save letter to keep it.'); }
+ else toast('⚠ '+(j.detail||j.error||'draft failed')); }
+ catch(err){ toast('⚠ '+err.message); }
+ return;
+ }
if(e.target.closest('[data-saveinfo]')){ saveAgentInfo(id); return; }
if(e.target.closest('[data-mark]')){ markContacted(id); return; }
const ld=e.target.closest('[data-letdel]'); if(ld){ deleteLetter(id,+ld.dataset.idx); return; }
diff --git a/scripts/serve.js b/scripts/serve.js
index b2bfad9..2863712 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -113,6 +113,37 @@ app.post('/api/contacts/:key/letter/:idx/delete', (req, res) => {
if (idx >= 0 && idx < rec.letters.length) rec.letters.splice(idx, 1);
writeContacts(map); res.json({ ok: true, contact: rec });
});
+// "Send via George" — creates a Gmail DRAFT (never a live send) via the George gmail-agent on
+// this same box (127.0.0.1:9850 /api/drafts). GATED THREE WAYS: (1) it only ever creates a DRAFT,
+// so nothing leaves until Steve reviews + sends in Gmail; (2) it is FAIL-CLOSED — with no
+// GEORGE_AUTH in the CRCP env it returns george_not_configured and does nothing; (3) it never
+// calls George's /api/send. From the steve-office account by default.
+const GEORGE_URL = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const GEORGE_AUTH = process.env.GEORGE_AUTH || (process.env.GMAIL_BASIC_AUTH_B64 ? 'Basic ' + process.env.GMAIL_BASIC_AUTH_B64 : '');
+app.post('/api/contacts/:key/send-draft', async (req, res) => {
+ const key = clip(req.params.key, 120); if (!key) return res.status(400).json({ error: 'no key' });
+ const b = req.body || {};
+ const to = clip(b.to, 200).trim(); const subject = clip(b.subject, 400); const body = clip(b.body, 20000);
+ if (!to || !/@/.test(to)) return res.status(400).json({ error: 'no valid agent email — add one first' });
+ if (!body.trim()) return res.status(400).json({ error: 'empty letter body' });
+ if (!GEORGE_AUTH) return res.status(503).json({ error: 'george_not_configured', hint: 'set GEORGE_AUTH (or GMAIL_BASIC_AUTH_B64) in the CRCP env to enable Gmail drafting' });
+ try {
+ const gr = await fetch(GEORGE_URL + '/api/drafts?account=steve-office', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': GEORGE_AUTH },
+ body: JSON.stringify({ to, subject, body, source: 'crcp-agent-contact' })
+ });
+ const gj = await gr.json().catch(() => ({}));
+ if (!gr.ok || !gj.success) return res.status(502).json({ error: 'george_error', detail: gj.error || ('HTTP ' + gr.status) });
+ // Attach the draft to the matching saved letter (or record a new one).
+ const map = readContacts(); const rec = contactRec(map, key);
+ let letter = rec.letters.find(l => l.body === body && !l.draft_id);
+ if (!letter) { letter = { ts: new Date().toISOString(), subject, body, channel: 'letter' }; rec.letters.push(letter); }
+ letter.status = 'gmail_draft'; letter.draft_id = gj.draftId; letter.drafted_at = new Date().toISOString(); letter.to = to;
+ if (!rec.contacted_at) rec.contacted_at = letter.drafted_at;
+ writeContacts(map);
+ res.json({ ok: true, draftId: gj.draftId, account: gj.account, contact: rec, gmailDrafts: 'https://mail.google.com/mail/u/0/#drafts' });
+ } catch (e) { res.status(502).json({ error: 'george_unreachable', detail: String(e.message).split('\n')[0] }); }
+});
// Arcstone-relationship flag (SQL). A broker/agent is flagged if Arcstone has a
// prior relationship: (1) already pitched/contacted (broker_pitch), (2) co-listed
← f503eb1 Agent-contact CRM now on EVERY listing + card view (one moda
·
back to Commercialrealestate
·
serve.js: load project .env via native process.loadEnvFile ( 5a19595 →