[object Object]

← 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

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 →