← 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 →