← 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
M public/index.htmlM server.js
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 →