[object Object]

← back to Dw Pitch Followup

auto-save: 2026-07-07T14:01:00 (2 files) — public/index.html server.js

182e2a67e1de2a1c955a01fd746b2c790d57ab2f · 2026-07-07 14:01:02 -0700 · Steve Abrams

Files touched

Diff

commit 182e2a67e1de2a1c955a01fd746b2c790d57ab2f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 7 14:01:02 2026 -0700

    auto-save: 2026-07-07T14:01:00 (2 files) — public/index.html server.js
---
 public/index.html | 14 ++++++++++----
 server.js         | 35 +++++++++++++++++++++++++++++++++++
 2 files changed, 45 insertions(+), 4 deletions(-)

diff --git a/public/index.html b/public/index.html
index 60b7519..a614e8d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -53,6 +53,10 @@
   .chips{display:flex;flex-wrap:wrap;gap:6px}
   .chip{font-size:calc(11px*var(--scale));padding:2px 8px;border-radius:11px;background:#26221c;color:var(--mut);border:1px solid var(--line)}
   .chip.k{color:var(--gold)} .chip.g{color:var(--green)} .chip.a{color:var(--amber)} .chip.r{color:var(--red)}
+  .chip.sent{color:var(--green);border-color:var(--green)}
+  .btn.send{background:#33280f;color:var(--gold);border-color:var(--gold)}
+  .btn.send:hover{background:var(--gold);color:#1a1611}
+  .sendstat{font-size:11.5px;margin-left:2px}
   .pats{font-size:calc(11.5px*var(--scale));color:var(--mut)}
   .acts{display:flex;gap:8px;margin-top:2px}
   .letter{margin-top:6px;border-top:1px dashed var(--line);padding-top:8px;display:none}
@@ -153,17 +157,19 @@ function filterSort(list){
 
 function cardHTML(list,r){
   const k = rowKey(list,r), sel = selSet(list).has(k);
+  const sentRec = LS('sent:'+list+':'+k, null);
+  const sentChip = sentRec ? ` <span class="chip sent" title="${esc(new Date(sentRec.at).toISOString())}">✓ sent ${esc(fmtWhen(new Date(sentRec.at).toISOString()))}</span>` : '';
   let head, facts='';
   if(list==='list3'){
-    head = `<div><div class="co">${esc(r.vendor)} <span class="chip r">${r.days_overdue}d overdue</span></div>
+    head = `<div><div class="co">${esc(r.vendor)} <span class="chip r">${r.days_overdue}d overdue</span>${sentChip}</div>
       <div class="sub">${esc(r.company||('account '+r.account))} · acct ${esc(r.account)}</div></div>`;
     facts = `<div class="chips">${showMfr()?`<span class="chip k">${esc(r.sku||'—')}</span>`:''}<span class="chip">${esc(lbl(r.pattern)||'pattern')}</span>${dateChip(r)}</div>`;
   } else if(list==='list2'){
-    head = `<div><div class="co">${esc(r.company)}</div><div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
+    head = `<div><div class="co">${esc(r.company)}${sentChip}</div><div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
     facts = `<div class="chips"><span class="chip a">${r.invoice_count} sample inv</span><span class="chip">max $${r.max_total}</span>${dateChip(r)}</div>
       ${(()=>{const d=r.details&&r.details[0]?lbl(r.details[0]):'';return d?`<div class="pats">${esc(d)}</div>`:''})()}`;
   } else {
-    head = `<div><div class="co">${esc(r.company)} <span class="chip g">${r.ratio}% sent</span></div>
+    head = `<div><div class="co">${esc(r.company)} <span class="chip g">${r.ratio}% sent</span>${sentChip}</div>
       <div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
     facts = `<div class="chips"><span class="chip">${r.sent}/${r.ordered} sent</span>${r.missing?`<span class="chip a">${r.missing} missing</span>`:''}${dateChip(r)}</div>
       ${(()=>{const p=(r.patterns||[]).map(lbl).filter(Boolean).slice(0,3).join(' · ');return p?`<div class="pats">${esc(p)}</div>`:''})()}`;
@@ -173,7 +179,7 @@ function cardHTML(list,r){
     ${facts}
     <div class="acts"><button class="btn gold" data-gen>Generate letter</button></div>
     <div class="letter"><div class="subj"></div><div class="ctxnote"></div><textarea spellcheck="false"></textarea>
-      <div class="acts"><button class="btn" data-regen>↻ Regenerate</button><button class="btn" data-copy>Copy letter</button><span class="meta" data-variant></span></div></div>
+      <div class="acts"><button class="btn" data-regen>↻ Regenerate</button><button class="btn" data-copy>Copy letter</button><button class="btn send" data-send>✉ Send now (info@)</button><span class="sendstat"></span><span class="meta" data-variant></span></div></div>
   </div>`;
 }
 
diff --git a/server.js b/server.js
index 3772451..e74c723 100644
--- a/server.js
+++ b/server.js
@@ -231,6 +231,41 @@ app.post('/api/letter', async (req, res) => {
   }
 });
 
+// POST /api/send — actually EMAIL a generated letter, FROM info@designerwallcoverings.com,
+// via George's /api/send. Best-effort: reply into the client's last info@ thread so it lands
+// as a follow-up, not a cold email. George applies its own external-send guard; if it blocks,
+// we pass that back verbatim. Each send is a deliberate per-recipient click in the UI.
+app.post('/api/send', async (req, res) => {
+  try {
+    const { to, subject, body } = req.body || {};
+    if (!to || !subject || !body) return res.status(400).json({ error: 'to, subject and body are required' });
+    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(to).trim())) return res.status(400).json({ error: `not a valid recipient email: ${to}` });
+
+    // Best-effort: find the newest message in the info@ mailbox for this address to thread into.
+    let replyToMessageId = null;
+    try {
+      const q = encodeURIComponent(`from:${to} OR to:${to}`);
+      const r = await fetch(`${GEORGE}/api/search?q=${q}&account=info&maxResults=1`, {
+        headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(8000),
+      });
+      if (r.ok) { const j = await r.json(); const m = (j.messages || [])[0]; if (m && m.id) replyToMessageId = m.id; }
+    } catch { /* thread best-effort only — fall through to a fresh send */ }
+
+    const payload = { account: 'info', from: 'info@designerwallcoverings.com', to: String(to).trim(), subject, body, source: 'dw-pitch-followup' };
+    if (replyToMessageId) payload.replyToMessageId = replyToMessageId;
+
+    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),
+    });
+    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 });
+    res.json({ success: true, account: 'info', messageId: j.messageId, threadId: j.threadId, inThread: !!(j.inThread || replyToMessageId), cost: '$0 (Gmail relay via George)' });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 app.listen(PORT, '127.0.0.1', () => {
   console.log(`[dw-pitch-followup] listening on http://127.0.0.1:${PORT}  (auth ${BASIC_AUTH.split(':')[0]} / …)  letters via ${LETTER_MODEL}`);
 });

← 5d7759e Redesign viewer: 3-column board (all lists at once) + List/G  ·  back to Dw Pitch Followup  ·  Add 'Send now (info@)' — emails generated letter via George 306b791 →