[object Object]

← 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

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 &amp; 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 &amp; 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 →