← back to Pitch Guard
Followup scanner now auto-runs: warms on startup + daily, candidates auto-load on page open
a2e4c2f0dac18ebf01e48422f045f173f1dd4bce · 2026-05-19 11:16:23 -0700 · Steve
Files touched
M public/index.htmlM server.js
Diff
commit a2e4c2f0dac18ebf01e48422f045f173f1dd4bce
Author: Steve <steve@designerwallcoverings.com>
Date: Tue May 19 11:16:23 2026 -0700
Followup scanner now auto-runs: warms on startup + daily, candidates auto-load on page open
---
public/index.html | 19 +++++++---
server.js | 104 ++++++++++++++++++++++++++++++------------------------
2 files changed, 72 insertions(+), 51 deletions(-)
diff --git a/public/index.html b/public/index.html
index 385682c..93069bc 100644
--- a/public/index.html
+++ b/public/index.html
@@ -261,7 +261,7 @@ 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 CANDIDATES = [], candScanned = false, candScanning = false, candScannedAt = null;
let candOpen = localStorage.getItem('pg.candOpen') !== '0';
const riskCache = {}; // email -> full /api/contact result
const liteCache = {}; // email -> light /api/risk result
@@ -459,8 +459,8 @@ 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=>
+ else if(!CANDIDATES.length) body='<div class="vgroup-note">No followup candidates — every ghosted quote in range already has a draft.'+(candScannedAt?' <span style="opacity:.7">· auto-scanned '+fmtDate(candScannedAt)+'</span>':'')+'</div>';
+ else body=(candScannedAt?'<div class="vgroup-note">Auto-scans daily · last scan '+fmtDate(candScannedAt)+' — review, then ✎ Draft it.</div>':'')+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>'+
@@ -522,11 +522,21 @@ async function scanCandidates(){
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;
+ CANDIDATES=d.candidates||[]; candScanned=true; candScannedAt=d.scannedAt||new Date().toISOString();
toast('Scan done — '+CANDIDATES.length+' followup candidate'+(CANDIDATES.length===1?'':'s'));
}catch(e){ toast('Scan failed: '+e.message,1); }
candScanning=false; renderDrafts();
}
+// quiet auto-load on page open — the server keeps the candidate list warm
+async function autoLoadCandidates(){
+ try{
+ const r=await fetch('/api/followup-candidates');
+ const d=await r.json();
+ if(d.error) return;
+ CANDIDATES=d.candidates||[]; candScanned=true; candScannedAt=d.scannedAt||null;
+ renderDrafts();
+ }catch(e){}
+}
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;
@@ -777,6 +787,7 @@ if(!document.body.classList.contains('solo')){
}
wirePanelControls();
loadDrafts().then(()=>{ if(soloDraft) selectDraft(soloDraft); });
+if(!document.body.classList.contains('solo')) autoLoadCandidates();
</script>
</body>
</html>
diff --git a/server.js b/server.js
index edd3db6..1e05563 100644
--- a/server.js
+++ b/server.js
@@ -138,64 +138,74 @@ 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) ─────────────
+// ── 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.
+// Auto-scanned on startup + every 24h so the candidate list stays warm.
let candCache = null;
const CAND_TTL = 20 * 60 * 1000;
+
+async function runCandidateScan() {
+ // 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, scannedAt: new Date().toISOString() };
+ candCache = { t: Date.now(), v: payload };
+ return payload;
+}
+
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);
+ res.json(await runCandidateScan());
} catch (e) { res.status(e.status || 500).json({ error: e.message }); }
});
+// Auto-scan: warm the candidate list ~30s after startup, then once a day.
+setTimeout(() => { runCandidateScan().catch(e => console.warn('[followup auto-scan] startup:', e.message)); }, 30_000);
+setInterval(() => { runCandidateScan().catch(e => console.warn('[followup auto-scan] daily:', e.message)); }, 24 * 60 * 60 * 1000);
+
// ── API: full message (works for drafts too) ──────────────────────────────
app.get('/api/message/:id', async (req, res) => {
try {
← b5dbc8d Send no_source_tag to George so customer emails skip the Fro
·
back to Pitch Guard
·
Move George URL + auth to gitignored .env (was hardcoded in 30852ed →