← back to Pitch Guard
pitch-guard: persist dates+letters sent per person (durable ledger) + surface George's SENT mail per contact
bd4163117813d13354e50c8d6b4e3099c29c2a1d · 2026-06-16 14:09:45 -0700 · Steve Abrams
Files touched
M .gitignoreM public/index.htmlM server.js
Diff
commit bd4163117813d13354e50c8d6b4e3099c29c2a1d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jun 16 14:09:45 2026 -0700
pitch-guard: persist dates+letters sent per person (durable ledger) + surface George's SENT mail per contact
---
.gitignore | 3 +++
public/index.html | 28 ++++++++++++++++++++++--
server.js | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 92 insertions(+), 3 deletions(-)
diff --git a/.gitignore b/.gitignore
index 3a9c1df..4c0b2a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,6 @@ build/
*.bak.*
*.pre-*
.pre-*
+
+# runtime sent-letter ledger (customer correspondence — keep out of git)
+data/sent-log.json
diff --git a/public/index.html b/public/index.html
index c88ce4a..7743934 100644
--- a/public/index.html
+++ b/public/index.html
@@ -631,16 +631,40 @@ function renderCorr(c){
'<span class="t-meta">'+t.messages.length+' msg · '+fmtDate(t.lastDate)+'</span></div>'+msgs+'</div>';
}).join('');
+ // Letters we've sent to this contact — dates + expandable full bodies.
+ let sentBlock='';
+ const sl = c.sentLetters||[];
+ if(sl.length){
+ const rows = sl.map(m=>
+ '<div class="msg sent-letter" data-mid="'+esc(m.id)+'"'+(m.fromLedger?' data-ledger="1"':'')+'>'+
+ '<div class="m-top"><span class="dir out">SENT</span>'+
+ '<span class="m-from">'+esc(m.subject)+'</span>'+
+ '<span class="m-date">'+fmtDate(m.date)+'</span></div>'+
+ '<div class="m-snip">'+esc(m.snippet||'')+'</div>'+
+ '<div class="m-body" data-loaded="0"'+(m.fromLedger?(' data-ledgerbody="'+esc(m.ledgerBody||'')+'"'):'')+'></div>'+
+ '</div>').join('');
+ sentBlock='<div class="thread sent-letters-wrap"><div class="thread-hd">'+
+ '<span class="t-subj">✉ Letters we’ve sent to '+esc(c.email)+'</span>'+
+ '<span class="t-meta">'+sl.length+' sent</span></div>'+rows+'</div>';
+ }
+
$('#corr').innerHTML = banner +
'<div class="contact-hd"><b>'+esc(c.email)+'</b> — '+c.totalMessages+' messages across '+c.threadCount+' thread'+(c.threadCount===1?'':'s')+
- (c.customerReplied?' · customer has replied':'')+'</div>' + threads;
+ (c.customerReplied?' · customer has replied':'')+(sl.length?' · '+sl.length+' letter'+(sl.length===1?'':'s')+' sent':'')+'</div>' + sentBlock + threads;
$('#corr').querySelectorAll('.msg').forEach(n=>n.onclick=()=>toggleMsg(n));
}
async function toggleMsg(node){
node.classList.toggle('open');
const body = node.querySelector('.m-body');
if(node.classList.contains('open') && body.dataset.loaded==='0'){
- body.dataset.loaded='1'; body.textContent='loading…';
+ body.dataset.loaded='1';
+ // ledger letters carry their full body inline — no fetch needed
+ if(node.dataset.ledger==='1'){
+ const lb=body.dataset.ledgerbody||'';
+ body.innerHTML = renderEmailHTML(stripBanner(lb)) || '(no text body)';
+ return;
+ }
+ body.textContent='loading…';
try{ const r=await fetch('/api/message/'+node.dataset.mid); const m=await r.json();
body.textContent = m.body || m.snippet || '(no text body)';
}catch(e){ body.textContent='⚠ '+e.message; }
diff --git a/server.js b/server.js
index dd2337f..e4d8a0f 100644
--- a/server.js
+++ b/server.js
@@ -120,6 +120,25 @@ function cacheGet(k) { const e = cache.get(k); if (e && Date.now() - e.t < TTL)
function cacheSet(k, v) { cache.set(k, { t: Date.now(), v }); }
const sleep = ms => new Promise(r => setTimeout(r, ms));
+// ── persistent sent-letter ledger (data/sent-log.json) ─────────────────────
+// Steve's ask: keep the dates + the actual letters we sent to each person,
+// durably — so the record survives restarts and any Gmail-search lag.
+const SENT_LOG = path.join(__dirname, 'data', 'sent-log.json');
+function readSentLog() {
+ try { return JSON.parse(fs.readFileSync(SENT_LOG, 'utf8')); } catch { return []; }
+}
+function appendSentLog(rec) {
+ const log = readSentLog();
+ log.push(rec);
+ try { fs.writeFileSync(SENT_LOG, JSON.stringify(log.slice(-5000), null, 2)); } // bounded: newest 5000
+ catch (e) { console.warn('[sent-log] write failed:', e.message); }
+}
+function sentLogFor(email) {
+ email = String(email || '').toLowerCase();
+ return readSentLog().filter(r => (r.toEmail || '').toLowerCase() === email)
+ .sort((a, b) => (Date.parse(b.sentAt) || 0) - (Date.parse(a.sentAt) || 0));
+}
+
// drafts list cache — the followup bot keeps a huge backlog; each page costs
// ~100 Gmail messages.get calls, so cap + throttle to stay under the per-minute quota.
let draftsCache = null;
@@ -309,10 +328,34 @@ app.get('/api/contact', async (req, res) => {
}
} catch (e) { /* non-fatal — followup check is advisory */ }
+ // Letters we've SENT to this contact — an explicit in:sent pull so the full
+ // outbound history (dates + bodies) is visible, not just whatever surfaced in
+ // the from/to thread scan. Bodies lazy-load via /api/message/:id in the UI.
+ let sentLetters = [];
+ try {
+ const s = await george('/api/search', { query: { account: ACCOUNT, q: `in:sent to:${email}`, maxResults: 25 } });
+ sentLetters = (s.messages || []).filter(m => !m.error).map(m => ({
+ id: m.id, threadId: m.threadId, subject: m.subject || '(no subject)',
+ date: m.date || '', ts: Date.parse(m.date) || 0, snippet: m.snippet || '',
+ }));
+ } catch (e) { /* non-fatal — sent-mail view is advisory */ }
+ // fold in any durable-ledger letters Gmail search may have missed (no id overlap)
+ const haveIds = new Set(sentLetters.map(x => x.id));
+ for (const r of sentLogFor(email)) {
+ if (r.messageId && haveIds.has(r.messageId)) continue;
+ sentLetters.push({
+ id: r.messageId || ('ledger:' + (r.sentAt || '')), threadId: r.threadId || '',
+ subject: r.subject || '(no subject)', date: r.sentAt || '', ts: Date.parse(r.sentAt) || 0,
+ snippet: String(r.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160),
+ ledgerBody: r.body || '', fromLedger: true,
+ });
+ }
+ sentLetters.sort((a, b) => b.ts - a.ts);
+
const result = {
email, totalMessages: msgs.length, threadCount: threads.length,
signals: [...allSignals].map(k => SIGNALS.find(s => s.key === k).label),
- customerReplied, risk, recentFollowup, threads,
+ customerReplied, risk, recentFollowup, threads, sentLetters,
};
cacheSet(email, result);
res.json(result);
@@ -365,10 +408,29 @@ app.post('/api/send', async (req, res) => {
}
const payload = { account: ACCOUNT, to, subject, body, no_source_tag: true, ...(threadId ? { threadId } : {}), ...(replyToMessageId ? { replyToMessageId } : {}) };
const sent = await george('/api/send', { method: 'POST', body: payload });
+ // durable record: the date + the actual letter we sent this person
+ try {
+ appendSentLog({
+ toEmail: extractEmail(to), toName: displayName(to) || extractEmail(to), to,
+ subject: subject || '', body: body || '', threadId: threadId || '',
+ messageId: (sent && (sent.id || sent.messageId || (sent.sent && sent.sent.id))) || '',
+ sentAt: new Date().toISOString(),
+ });
+ } catch (e) { console.warn('[sent-log] append on send failed:', e.message); }
+ cacheGet && cache.delete(extractEmail(to)); // drop stale contact cache so the new letter shows
res.json({ ok: true, sent });
} catch (e) { res.status(e.status || 500).json({ error: e.message }); }
});
+// ── API: the durable ledger of letters we've sent (dates + bodies) ─────────
+// ?email= → just that person's history; no email → the whole ledger (newest 500).
+app.get('/api/sent-log', (req, res) => {
+ const email = extractEmail(req.query.email);
+ if (email) return res.json({ email, count: sentLogFor(email).length, letters: sentLogFor(email) });
+ const all = readSentLog().sort((a, b) => (Date.parse(b.sentAt) || 0) - (Date.parse(a.sentAt) || 0)).slice(0, 500);
+ res.json({ count: all.length, letters: all });
+});
+
// ── API: trash a draft (reversible — moves to Gmail Trash, recoverable 30 days) ──
app.post('/api/draft/:id/trash', async (req, res) => {
try {
← c3d6f36 Link favicon.svg in head (use branded SVG, not .ico fallback
·
back to Pitch Guard
·
Fix non-deterministic followup-candidate scanner: single-fli 0d9ee55 →