[object Object]

← back to Sample Followup Sweep

Console: per-vendor Send button (fires exact letter via George token, human-click)

29a02139fbe4a0ef1135142abbed0aa364f1a25c · 2026-08-15 08:01:55 -0700 · Steve Abrams

Files touched

Diff

commit 29a02139fbe4a0ef1135142abbed0aa364f1a25c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 15 08:01:55 2026 -0700

    Console: per-vendor Send button (fires exact letter via George token, human-click)
---
 server.js | 44 +++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 43 insertions(+), 1 deletion(-)

diff --git a/server.js b/server.js
index e597128..7b8f057 100644
--- a/server.js
+++ b/server.js
@@ -92,6 +92,37 @@ const server = http.createServer(async (req, res) => {
     return send(res, 200, { to: d.to, subject: d.subject, html: d.html, count: v.items.length, exact: false });
   }
 
+  // Per-vendor send: fires THIS vendor's exact letter through George with the approval token.
+  // Human-in-the-loop: only runs on a click in the authed console. Reads creds at runtime, never logs them.
+  if (u.pathname === '/api/send-one' && req.method === 'POST') {
+    const b = await body(req);
+    const c = contacts();
+    const cc = c[b.slug] || {};
+    if (!cc.sample_email || !cc.account_number) return send(res, 400, { error: 'vendor not ready (needs sample email + account #)' });
+    const st = stagedFor(b.slug);
+    let payload;
+    if (st) payload = { account: 'info', to: st.to, subject: st.subject, body: st.body };
+    else { const v = fleet().vendors.find(x => x.slug === b.slug); if (!v) return send(res, 404, { error: 'no vendor' }); const d = draftFor(v, c); payload = { account: 'info', to: d.to, subject: d.subject, body: d.html }; }
+    const genv = (k) => { try { const m = fs.readFileSync('/Users/macstudio3/Projects/george-gmail/.env', 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')); return m ? m[1].trim() : ''; } catch (e) { return ''; } };
+    let gauth = genv('GEORGE_BASIC_AUTH'); if (!gauth.includes(':')) gauth = 'admin:' + genv('GEORGE_BASIC_AUTH_PASS');
+    const token = genv('GEORGE_EXTERNAL_SEND_TOKEN');
+    if (!token) return send(res, 500, { error: 'GEORGE_EXTERNAL_SEND_TOKEN not configured' });
+    const gp = JSON.stringify(payload);
+    const greq = http.request({ host: '127.0.0.1', port: 9850, path: '/api/send', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(gp), Authorization: 'Basic ' + Buffer.from(gauth).toString('base64'), 'X-Send-Approval': token } }, gr => {
+      let gb = ''; gr.on('data', x => gb += x); gr.on('end', () => {
+        let ok = false, mid = ''; try { const j = JSON.parse(gb); ok = !!j.success; mid = j.messageId || ''; } catch (e) {}
+        if (ok) {
+          try { const s = sent(); s.byEmail = s.byEmail || {}; const iso = new Date().toISOString(); for (let a of String(payload.to).split(',')) { a = a.trim().toLowerCase(); if (!a.includes('@')) continue; const cur = s.byEmail[a]; if (!cur) s.byEmail[a] = { lastSent: iso, count: 1 }; else { cur.count++; cur.lastSent = iso; } } fs.writeFileSync(p('data', 'sent.json'), JSON.stringify(s, null, 2)); } catch (e) {}
+          return send(res, 200, { ok: true, messageId: mid, to: payload.to });
+        }
+        return send(res, 502, { error: 'George send blocked/failed', detail: gb.slice(0, 200) });
+      });
+    });
+    greq.on('error', e => send(res, 502, { error: e.message }));
+    greq.write(gp); greq.end();
+    return;
+  }
+
   if (u.pathname === '/api/queue' && req.method === 'POST') {
     const b = await body(req); const c = contacts(); const vs = fleet().vendors;
     const picked = (b.slugs || []).map(s => vs.find(v => v.slug === s)).filter(Boolean);
@@ -145,6 +176,10 @@ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sample Fol
  tr.rr td{background:#f6f0ff}
  .badge.rrb{background:#ece2fb;color:#5b2ea6}
  .detailrow>td{padding:0 10px 8px}
+ td.actcell{white-space:nowrap}
+ .sendbtn{margin-left:6px;background:#137a34;color:#fff;border-color:#137a34}
+ .sendbtn.resend{background:#fff;color:#137a34}
+ .sendbtn:disabled{opacity:.6;cursor:default}
 </style></head><body>
 <header>
  <h1>Sample Follow-Up — Vendor Console</h1>
@@ -211,12 +246,19 @@ function render(){const t=document.getElementById('rows');t.innerHTML='';for(con
  '<td class="sentcell">'+sentCell(v)+'</td>'+
  '<td><input class="em" data-slug="'+v.slug+'" data-f="sample_email" value="'+esc(c.sample_email||'')+'" placeholder="sample email"></td>'+
  '<td><input class="ac" data-slug="'+v.slug+'" data-f="account_number" value="'+esc(c.account_number||'')+'" placeholder="acct #"></td>'+
- '<td><button data-prev="'+v.slug+'">preview</button></td>';
+ '<td class=actcell><button data-prev="'+v.slug+'">preview</button>'+
+   (ready(v)?'<button class="sendbtn'+(sentInfo(v).sent?' resend':'')+'" data-send="'+v.slug+'">'+(sentInfo(v).sent?'resend':'send')+'</button>':'')+'</td>';
  t.appendChild(tr);
  const dr=document.createElement('tr');dr.className='detailrow';dr.style.display='none';
  dr.innerHTML='<td></td><td colspan=9>'+itemsTable(v)+'</td>';t.appendChild(dr);}
  t.querySelectorAll('input.em,input.ac').forEach(i=>i.addEventListener('change',saveContact));
  t.querySelectorAll('button[data-prev]').forEach(b=>b.addEventListener('click',()=>preview(b.dataset.prev)));
+ t.querySelectorAll('button[data-send]').forEach(b=>b.addEventListener('click',async e=>{const slug=b.dataset.send;const v=S.fleet.vendors.find(x=>x.slug===slug);const c=S.contacts[slug]||{};
+  if(!confirm('Send the follow-up letter now to '+(c.sample_email||slug)+' ?'))return;
+  b.disabled=true;b.textContent='sending…';
+  try{const r=await fetch('/api/send-one',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug})});const d=await r.json();
+   if(d.ok){await load();}else{b.disabled=false;b.textContent='send';alert('Send failed: '+(d.error||'unknown')+(d.detail?'\\n'+d.detail:''));}}
+  catch(err){b.disabled=false;b.textContent='send';alert('Send error: '+err.message);}}));
  t.querySelectorAll('button.exp').forEach(b=>b.addEventListener('click',e=>{const dr=e.target.closest('tr').nextElementSibling;const open=dr.style.display==='none';dr.style.display=open?'table-row':'none';e.target.textContent=(open?'▼ ':'▶ ')+e.target.textContent.slice(2);}));}
 async function saveContact(e){const el=e.target;const slug=el.dataset.slug;const row=[...document.querySelectorAll('input[data-slug="'+slug+'"]')];
  const em=row.find(x=>x.dataset.f==='sample_email').value.trim();const ac=row.find(x=>x.dataset.f==='account_number').value.trim();

← 2397a7f Console: exact-letter preview + populate Sent snapshot (18 l  ·  back to Sample Followup Sweep  ·  yoloforever c1: Cody FIX-FIRST — sent.json freshness indicat e8bc50a →