[object Object]

← 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

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 →