← back to Dw Pitch Followup
Add 'Send now (info@)' — emails generated letter via George info@ account with external-send approval token (Steve-authorized), confirm-guard + in-thread + persisted sent marker
306b7912a3496aa3c356340a5231a8e949ed41a7 · 2026-07-07 14:16:17 -0700 · Steve Abrams
Files touched
M public/index.htmlM server.js
Diff
commit 306b7912a3496aa3c356340a5231a8e949ed41a7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 7 14:16:17 2026 -0700
Add 'Send now (info@)' — emails generated letter via George info@ account with external-send approval token (Steve-authorized), confirm-guard + in-thread + persisted sent marker
---
public/index.html | 22 ++++++++++++++++++++++
server.js | 18 +++++++++++++++---
2 files changed, 37 insertions(+), 3 deletions(-)
diff --git a/public/index.html b/public/index.html
index a614e8d..211b77e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -233,6 +233,27 @@ async function genLetter(card, opts={}){
}
// --- selected → text (across ALL columns) ---
+// Actually EMAIL the letter, FROM info@designerwallcoverings.com, via the server → George.
+// Deliberate per-recipient action: confirm() guards every send; nothing goes out on its own.
+async function sendLetter(card, btn){
+ const r = card._row, list = card._list, k = rowKey(list,r);
+ const to = (r.email||'').trim();
+ const subj = card.querySelector('.subj').textContent.replace(/^Subject:\s*/,'').trim();
+ const body = card.querySelector('textarea').value.trim();
+ const stat = card.querySelector('.sendstat');
+ if(!to){ stat.style.color='var(--red)'; stat.textContent = list==='list3' ? 'no vendor email on file — can’t send' : 'no client email on file — can’t send'; return; }
+ if(!subj || !body){ stat.style.color='var(--amber)'; stat.textContent='generate a letter first'; return; }
+ if(!confirm(`Send this letter NOW?\n\nFROM: info@designerwallcoverings.com\nTO: ${to}\nSUBJ: ${subj}\n\nThis emails the recipient immediately.`)) return;
+ const old = btn.textContent; btn.disabled=true; btn.textContent='sending…'; stat.style.color='var(--mut)'; stat.textContent='';
+ try{
+ const res = await fetch(API('/api/send'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to,subject:subj,body})});
+ const j = await res.json();
+ if(j.error) throw new Error(j.blocked ? `blocked by send-guard: ${j.error}` : j.error);
+ btn.textContent='✓ Sent'; stat.style.color='var(--green)'; stat.textContent=`sent via info@${j.inThread?' · in-thread':''}`;
+ setLS('sent:'+list+':'+k, {at:Date.now(), messageId:j.messageId||''});
+ }catch(e){ btn.disabled=false; btn.textContent=old; stat.style.color='var(--red)'; stat.textContent='send failed: '+e.message; }
+}
+
function selectedRowsFor(list){ const set=selSet(list); return (DATA[list]||[]).filter(r=>set.has(rowKey(list,r))); }
function selectedAsText(){
const blocks=[];
@@ -287,6 +308,7 @@ document.getElementById('board').addEventListener('click',e=>{
const card=e.target.closest('.card'); if(!card) return;
if(e.target.matches('[data-gen]')) return genLetter(card);
if(e.target.matches('[data-regen]')) return genLetter(card,{regenerate:true});
+ if(e.target.matches('[data-send]')) return sendLetter(card, e.target);
if(e.target.matches('[data-copy]')){ const ta=card.querySelector('textarea'); navigator.clipboard.writeText(`Subject: ${card.querySelector('.subj').textContent.replace(/^Subject: /,'')}\n\n${ta.value}`); e.target.textContent='Copied ✓'; setTimeout(()=>e.target.textContent='Copy letter',1200); }
});
diff --git a/server.js b/server.js
index e74c723..0f80ce7 100644
--- a/server.js
+++ b/server.js
@@ -23,7 +23,18 @@ const PORT = Number(process.env.PORT || 9768);
const DATA_DIR = path.join(__dirname, 'data');
const LISTS = path.join(DATA_DIR, 'lists.json');
const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
-const GEORGE_AUTH = 'Basic ' + Buffer.from(`admin:${process.env.GEORGE_BASIC_AUTH_PASS || ''}`).toString('base64');
+// Resolve George secrets: prefer our own env, else read them straight from george-gmail/.env
+// (same fallback George itself uses — launchd/pm2 don't load .env into process.env).
+const GEORGE_ENV = process.env.GEORGE_ENV_PATH || '/Users/macstudio3/Projects/george-gmail/.env';
+function envFromGeorge(key) {
+ try { const m = fs.readFileSync(GEORGE_ENV, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); return m ? m[1].trim().replace(/^["']|["']$/g, '') : ''; } catch { return ''; }
+}
+const GEORGE_BASIC_PASS = process.env.GEORGE_BASIC_AUTH_PASS || envFromGeorge('GEORGE_BASIC_AUTH_PASS');
+const GEORGE_AUTH = 'Basic ' + Buffer.from(`admin:${GEORGE_BASIC_PASS}`).toString('base64');
+// Human-approval token for EXTERNAL sends. George fail-closes without it (blocks any
+// non-@designerwallcoverings.com recipient). Attaching it = the Send-now click is the
+// human approval; George still stamps a source footer on every message.
+const GEORGE_EXT_SEND_TOKEN = process.env.GEORGE_EXTERNAL_SEND_TOKEN || envFromGeorge('GEORGE_EXTERNAL_SEND_TOKEN');
// Local LLM for genuine customization ($0). Falls back to deterministic compose if unreachable.
const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const LETTER_MODEL = process.env.LETTER_MODEL || 'hermes3:8b';
@@ -254,9 +265,10 @@ app.post('/api/send', async (req, res) => {
const payload = { account: 'info', from: 'info@designerwallcoverings.com', to: String(to).trim(), subject, body, source: 'dw-pitch-followup' };
if (replyToMessageId) payload.replyToMessageId = replyToMessageId;
+ const headers = { 'Content-Type': 'application/json', Authorization: GEORGE_AUTH };
+ if (GEORGE_EXT_SEND_TOKEN) headers['X-Send-Approval'] = GEORGE_EXT_SEND_TOKEN; // human-approved external send
const r = await fetch(`${GEORGE}/api/send`, {
- method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: GEORGE_AUTH },
- body: JSON.stringify(payload), signal: AbortSignal.timeout(20000),
+ method: 'POST', headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(20000),
});
const j = await r.json().catch(() => ({}));
if (!r.ok || j.error) return res.status(r.status === 403 ? 403 : (r.status || 500)).json({ error: j.error || 'send failed', blocked: !!j.blocked, external: j.external });
← 182e2a6 auto-save: 2026-07-07T14:01:00 (2 files) — public/index.html
·
back to Dw Pitch Followup
·
Compact cards + Columns/One-list layout toggle + ▸details ex c660a28 →