← back to Pitch Guard
Add 5-day followup candidate scanner (dry-run) — Followup Candidates section + per-candidate Draft-it
164394ad4da04170cd2bacc1c97809bf13ea89c4 · 2026-05-19 09:59:35 -0700 · Steve
Files touched
M public/index.htmlM server.js
Diff
commit 164394ad4da04170cd2bacc1c97809bf13ea89c4
Author: Steve <steve@designerwallcoverings.com>
Date: Tue May 19 09:59:35 2026 -0700
Add 5-day followup candidate scanner (dry-run) — Followup Candidates section + per-candidate Draft-it
---
public/index.html | 96 ++++++++++++++++++++++++++++++++++++++++++++++++-------
server.js | 76 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 161 insertions(+), 11 deletions(-)
diff --git a/public/index.html b/public/index.html
index 58ee30f..4d5cfb2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -80,6 +80,22 @@
.vgroup-hd .vct{margin-left:auto;background:var(--slatebg);color:var(--slate);
border-radius:9px;padding:1px 8px;font-size:11px}
.vgroup-note{padding:6px 14px 8px;font-size:11px;color:var(--muted);background:var(--cream)}
+ .cgroup-hd{padding:10px 14px;background:#eef1ef;border-bottom:1px solid var(--line);
+ cursor:pointer;display:flex;align-items:center;gap:7px;font-size:11px;font-weight:700;
+ color:var(--accent);text-transform:uppercase;letter-spacing:.7px}
+ .cgroup-hd:hover{background:#e6ebe8}
+ .cgroup-hd .arrow{font-size:9px;transition:transform .15s}
+ .cgroup-hd.closed .arrow{transform:rotate(-90deg)}
+ .cgroup-hd .vct{background:var(--accent);color:#fff;border-radius:9px;padding:1px 8px;font-size:11px}
+ .cgroup-hd .scan-btn{margin-left:auto;font-size:11px;padding:3px 10px}
+ .cand{padding:10px 14px;border-bottom:1px solid var(--line);cursor:pointer;position:relative}
+ .cand:hover{background:var(--cream)}
+ .cand .who{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:76px}
+ .cand .subj{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}
+ .cand .meta{font-size:11px;color:var(--muted);margin-top:3px}
+ .cand-draft{position:absolute;top:10px;right:12px;font-size:10px;font-weight:700;padding:4px 9px;
+ border-radius:5px;border:1px solid var(--accent);background:var(--accent);color:#fff;cursor:pointer}
+ .cand-draft:hover{background:#233b31}
.subbar{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--line);
background:var(--paper);flex-shrink:0}
.subbar label{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);font-weight:700}
@@ -238,6 +254,8 @@ const $ = s => document.querySelector(s);
let DRAFTS = [], CURRENT = null, MORE = false;
let vendorsOpen = localStorage.getItem('pg.vendorsOpen') === '1';
let sortMode = localStorage.getItem('pg.sort') || 'newest';
+let CANDIDATES = [], candScanned = false, candScanning = false;
+let candOpen = localStorage.getItem('pg.candOpen') !== '0';
const riskCache = {}; // email -> full /api/contact result
const liteCache = {}; // email -> light /api/risk result
const verdictOf = email => riskCache[email] || liteCache[email] || null;
@@ -430,26 +448,57 @@ function draftChip(d){
'<button class="draft-del" data-del="'+d.id+'" title="Move draft to Trash">✕</button>'+
'</div>';
}
+function candidateSectionHTML(){
+ let body;
+ if(candScanning) body='<div class="empty" style="padding:24px"><div class="spin"></div> scanning sent mail for ghosted quotes…</div>';
+ else if(!candScanned) body='<div class="vgroup-note">Not scanned yet — click <b>Scan</b> to find quote/pricing threads we replied to that got no client response in 5+ days and have no followup drafted.</div>';
+ else if(!CANDIDATES.length) body='<div class="vgroup-note">No followup candidates — every ghosted quote in range already has a draft.</div>';
+ else body=CANDIDATES.map(c=>
+ '<div class="cand" data-email="'+esc(c.contact)+'">'+
+ '<button class="cand-draft" data-draft-cand="'+esc(c.contact)+'" title="Create the followup draft">✎ Draft it</button>'+
+ '<div class="who">'+esc(c.contactName)+'</div>'+
+ '<div class="subj">'+esc(c.subject)+'</div>'+
+ '<div class="meta">'+esc(c.contact)+' · '+c.daysSince+'d no reply</div>'+
+ '</div>').join('');
+ return '<div class="cgroup-hd'+(candOpen?'':' closed')+'" id="cgroupHd" title="Quotes with no client reply in 5+ days — the 5-day followup rule">'+
+ '<span class="arrow">▼</span> Followup Candidates '+
+ '<span class="vct">'+(candScanned?CANDIDATES.length:'—')+'</span>'+
+ '<button class="btn-sm scan-btn" id="scanCandBtn">'+(candScanned?'Rescan':'Scan')+'</button>'+
+ '</div>'+
+ '<div id="cgroupBody" style="display:'+(candOpen?'block':'none')+'">'+body+'</div>';
+}
function renderDrafts(){
const el = $('#draftList');
if(!el) return; // drafts panel popped out / not in this window
- if(!DRAFTS.length){ el.innerHTML='<div class="empty">No drafts waiting.</div>'; return; }
const clients = sortDrafts(DRAFTS.filter(d=>d.kind!=='vendor'));
const vendors = sortDrafts(DRAFTS.filter(d=>d.kind==='vendor'));
- let html = MORE?'<div class="meta" style="padding:7px 14px;color:var(--muted);border-bottom:1px solid var(--line)">Showing the '+DRAFTS.length+' most recent followup drafts — the bot backlog is larger.</div>':'';
- html += clients.length ? clients.map(draftChip).join('')
- : '<div class="empty">No client drafts in this batch.</div>';
- if(vendors.length){
- html += '<div class="vgroup-hd'+(vendorsOpen?'':' closed')+'" id="vgroupHd" title="Vendor / supplier drafts — not clients to pitch">'+
- '<span class="arrow">▼</span> Vendors <span class="vct">'+vendors.length+'</span></div>'+
- '<div id="vgroupBody" style="display:'+(vendorsOpen?'block':'none')+'">'+
- '<div class="vgroup-note">Drafts addressed to suppliers / manufacturers — not clients. Review, don’t pitch.</div>'+
- vendors.map(draftChip).join('')+
- '</div>';
+ let html = candidateSectionHTML();
+ if(!DRAFTS.length){
+ html += '<div class="empty">No drafts loaded.</div>';
+ } else {
+ if(MORE) html+='<div class="meta" style="padding:7px 14px;color:var(--muted);border-bottom:1px solid var(--line)">Showing the '+DRAFTS.length+' most recent followup drafts — the bot backlog is larger.</div>';
+ html += clients.length ? clients.map(draftChip).join('') : '<div class="empty">No client drafts in this batch.</div>';
+ if(vendors.length){
+ html += '<div class="vgroup-hd'+(vendorsOpen?'':' closed')+'" id="vgroupHd" title="Vendor / supplier drafts — not clients to pitch">'+
+ '<span class="arrow">▼</span> Vendors <span class="vct">'+vendors.length+'</span></div>'+
+ '<div id="vgroupBody" style="display:'+(vendorsOpen?'block':'none')+'">'+
+ '<div class="vgroup-note">Drafts addressed to suppliers / manufacturers — not clients. Review, don’t pitch.</div>'+
+ vendors.map(draftChip).join('')+
+ '</div>';
+ }
}
el.innerHTML = html;
el.querySelectorAll('.draft').forEach(n=>n.onclick=()=>selectDraft(n.dataset.id));
el.querySelectorAll('.draft-del').forEach(b=>b.onclick=e=>{ e.stopPropagation(); deleteDraft(b.dataset.del); });
+ el.querySelectorAll('.cand').forEach(n=>n.onclick=ev=>{ if(ev.target.closest('[data-draft-cand]'))return; loadContact(n.dataset.email); });
+ el.querySelectorAll('[data-draft-cand]').forEach(b=>b.onclick=ev=>{ ev.stopPropagation(); createFollowup(b.dataset.draftCand); });
+ const ch=$('#cgroupHd');
+ if(ch) ch.onclick=ev=>{
+ if(ev.target.closest('#scanCandBtn')) return;
+ candOpen=!candOpen; localStorage.setItem('pg.candOpen',candOpen?'1':'0');
+ $('#cgroupBody').style.display=candOpen?'block':'none'; ch.classList.toggle('closed',!candOpen);
+ };
+ const scb=$('#scanCandBtn'); if(scb) scb.onclick=ev=>{ ev.stopPropagation(); scanCandidates(); };
const vh = $('#vgroupHd');
if(vh) vh.onclick=()=>{
vendorsOpen=!vendorsOpen;
@@ -459,6 +508,31 @@ function renderDrafts(){
};
const dc=$('#draftCount'); if(dc) dc.textContent = clients.length + (MORE?'+':'');
}
+async function scanCandidates(){
+ if(candScanning) return;
+ candScanning=true; renderDrafts();
+ try{
+ const r=await fetch('/api/followup-candidates'+(candScanned?'?fresh=1':''));
+ const d=await r.json();
+ if(d.error) throw new Error(d.error);
+ CANDIDATES=d.candidates||[]; candScanned=true;
+ toast('Scan done — '+CANDIDATES.length+' followup candidate'+(CANDIDATES.length===1?'':'s'));
+ }catch(e){ toast('Scan failed: '+e.message,1); }
+ candScanning=false; renderDrafts();
+}
+async function createFollowup(email){
+ const c=CANDIDATES.find(x=>x.contact===email); if(!c) return;
+ if(!confirm('Create a followup draft for:\n\n'+c.contactName+' <'+c.contact+'>\n"'+c.subject+'" ('+c.daysSince+'d no reply)\n\nIt lands as a draft in info@ — nothing is sent until you send it.')) return;
+ try{
+ const r=await fetch('/api/followup/create',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({to:c.contact,subject:c.subject,threadId:c.threadId})});
+ const out=await r.json();
+ if(out.error) throw new Error(out.error);
+ CANDIDATES=CANDIDATES.filter(x=>x.contact!==email);
+ renderDrafts();
+ toast('Followup draft created for '+c.contact);
+ }catch(e){ toast('Draft failed: '+e.message,1); }
+}
async function deleteDraft(id){
const d = DRAFTS.find(x=>x.id===id); if(!d) return;
diff --git a/server.js b/server.js
index 0088a3f..6a66b05 100644
--- a/server.js
+++ b/server.js
@@ -132,6 +132,64 @@ app.get('/api/drafts', async (req, res) => {
} catch (e) { res.status(e.status || 500).json({ error: e.message }); }
});
+// ── API: 5-day followup candidates (DRY-RUN — creates nothing) ─────────────
+// Threads where info@ sent the last message and the client went quiet for 5+
+// days, with no followup draft already pending. Steve reviews, then approves.
+let candCache = null;
+const CAND_TTL = 20 * 60 * 1000;
+app.get('/api/followup-candidates', async (req, res) => {
+ try {
+ if (candCache && !req.query.fresh && Date.now() - candCache.t < CAND_TTL)
+ return res.json({ ...candCache.v, cached: true });
+ // our sent mail, 5–35 days old — the window where a followup comes due
+ let sent = [], pageToken;
+ for (let pg = 0; pg < 2; pg++) {
+ const out = await george('/api/messages', { query: { account: ACCOUNT, q: 'in:sent older_than:5d newer_than:35d', maxResults: 100, pageToken } });
+ sent = sent.concat(out.messages || []);
+ pageToken = out.nextPageToken;
+ if (!pageToken) break;
+ await sleep(600);
+ }
+ // keep the newest sent message per recipient
+ const byContact = new Map();
+ for (const m of sent) {
+ const c = extractEmail(m.to);
+ if (!c) continue;
+ const ts = Date.parse(m.date) || 0;
+ const cur = byContact.get(c);
+ if (!cur || ts > cur.ts) byContact.set(c, { ts, msg: m });
+ }
+ const contacts = [...byContact.entries()].sort((a, b) => b[1].ts - a[1].ts).slice(0, 55);
+ const candidates = [];
+ for (const [contact, info] of contacts) {
+ const daysSince = Math.floor((Date.now() - info.ts) / 86400000);
+ if (daysSince < 5) continue;
+ if (classifyRecipient(contact) === 'vendor') continue;
+ try {
+ // did the client reply after our last message? (from: + after:YYYY/MM/DD)
+ const sd = new Date(info.ts);
+ const after = `${sd.getFullYear()}/${sd.getMonth() + 1}/${sd.getDate()}`;
+ const reply = await george('/api/search', { query: { account: ACCOUNT, q: `from:${contact} after:${after}`, maxResults: 2 } });
+ if ((reply.messages || []).some(x => !x.error)) continue; // they replied → not ghosted
+ // is a followup draft to this contact already pending?
+ const dr = await george('/api/search', { query: { account: ACCOUNT, q: `in:draft to:${contact}`, maxResults: 2 } });
+ if ((dr.messages || []).some(x => !x.error)) continue; // already drafted
+ candidates.push({
+ threadId: info.msg.threadId,
+ subject: info.msg.subject || '(no subject)',
+ contact, contactName: displayName(info.msg.to) || contact,
+ lastDate: info.msg.date, daysSince,
+ });
+ } catch (e) {}
+ await sleep(150);
+ }
+ candidates.sort((a, b) => b.daysSince - a.daysSince);
+ const payload = { count: candidates.length, scanned: contacts.length, candidates };
+ candCache = { t: Date.now(), v: payload };
+ res.json(payload);
+ } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
+});
+
// ── API: full message (works for drafts too) ──────────────────────────────
app.get('/api/message/:id', async (req, res) => {
try {
@@ -282,6 +340,24 @@ app.post('/api/draft/:id/trash', async (req, res) => {
} catch (e) { res.status(e.status || 500).json({ error: e.message }); }
});
+// ── API: create a followup draft for an approved candidate ─────────────────
+const FOLLOWUP_BODY =
+ 'Hi,<br><br>Just following up on the quote / pricing I sent for the project below. ' +
+ 'Wanted to check in and see if you have any questions, need anything adjusted, or are ready to move forward.' +
+ '<br><br>Happy to revise quantities, swap materials, or break the quote out differently if that helps. ' +
+ 'Let me know what works for you.<br><br>Thanks,<br>Designer Wallcoverings<br>info@designerwallcoverings.com';
+app.post('/api/followup/create', async (req, res) => {
+ try {
+ const { to, subject } = req.body || {};
+ if (!to) return res.status(400).json({ error: 'to required' });
+ const subj = /^re:/i.test(subject || '') ? subject : 'Re: ' + (subject || 'your inquiry');
+ const out = await george('/api/drafts', { method: 'POST', body: { account: ACCOUNT, to, subject: subj, body: FOLLOWUP_BODY } });
+ candCache = null; // invalidate so a rescan reflects the new draft
+ draftsCache = null;
+ res.json({ ok: true, draft: out });
+ } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
+});
+
app.get('/api/health', (req, res) => res.json({ ok: true, george: GEORGE, account: ACCOUNT }));
app.listen(PORT, () => console.log(`Pitch Guard → http://127.0.0.1:${PORT} (info@${OURS} via George)`));
← 0bd7aef Fix recent-followup detection — broad regex for all followup
·
back to Pitch Guard
·
Pitch panel: Edit/Preview HTML toggle + Open-as-page; strip bfffaf9 →