← back to Pitch Guard

server.js

648 lines

'use strict';
/**
 * Pitch Guard — info@designerwallcoverings.com draft / correspondence viewer.
 *
 * The problem (per Samantha, IRL VP of DW): the followup bot drafts a generic
 * "just following up on the quote" reply on ONE thread, while OTHER threads with
 * the same contact have already closed — invoice sent, paid, shipped. Pitching a
 * customer who already ordered embarrasses Samantha.
 *
 * This viewer pulls every draft, and for the selected draft's contact it pulls
 * EVERY thread + scans for invoice/payment/shipping signals so you can decide,
 * with the full picture, whether the followup pitch should actually go out.
 *
 * Backend = thin proxy onto George (the DW Gmail HTTP agent) for the `info` acct.
 */
const express = require('express');
const path = require('path');
const fs = require('fs');

// ── load .env (gitignored — keeps George creds out of source) ──────────────
try {
  for (const line of fs.readFileSync(path.join(__dirname, '.env'), 'utf8').split('\n')) {
    const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
    if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
  }
} catch (e) { /* no .env — rely on real env vars */ }

const app = express();
app.use(express.json({ limit: '4mb' }));

// 404-guard: never serve snapshot/backup files even if one accidentally lands in public/.
// Matches *.bak / *.bak.* / *.pre-* anywhere in the path.
app.use((req, res, next) => {
  if (/\.(bak)(\.|$)|\/\.pre-|\.pre-/i.test(req.path)) {
    return res.status(404).type('text/plain').send('Not found');
  }
  next();
});

app.use(express.static(path.join(__dirname, 'public')));

const PORT = process.env.PORT || 8123;
const GEORGE = (process.env.GEORGE_URL || '').replace(/\/$/, '');
const AUTH = process.env.GEORGE_BASIC_AUTH || '';
// George blocks sends to external (customer) recipients unless the request carries
// the human-approval token. In PitchGuard the human approval IS Steve clicking
// "Send followup" behind nginx basic-auth, so we pass the token on every send.
const EXT_SEND_TOKEN = process.env.GEORGE_EXTERNAL_SEND_TOKEN || '';
const ACCOUNT = 'info'; // info@designerwallcoverings.com
// Always send/draft AS info@ — pin it explicitly so it can never fall back to a Gmail default.
const FROM = process.env.PITCHGUARD_FROM || 'Designer Wallcoverings <info@designerwallcoverings.com>';
if (!GEORGE || !AUTH) {
  console.error('Missing GEORGE_URL / GEORGE_BASIC_AUTH — copy .env.example to .env and fill it in.');
  process.exit(1);
}

// ── George proxy ──────────────────────────────────────────────────────────
async function george(p, { method = 'GET', query, body } = {}) {
  const url = new URL(GEORGE + p);
  if (query) for (const [k, v] of Object.entries(query)) if (v != null) url.searchParams.set(k, String(v));
  const res = await fetch(url, {
    method,
    headers: { Authorization: `Basic ${AUTH}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const txt = await res.text();
  let data; try { data = JSON.parse(txt); } catch { data = { raw: txt }; }
  if (!res.ok) { const e = new Error(data.error || txt.slice(0, 200)); e.status = res.status; throw e; }
  return data;
}

// ── helpers ───────────────────────────────────────────────────────────────
const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/;
function extractEmail(s) { const m = String(s || '').match(EMAIL_RE); return m ? m[0].toLowerCase() : ''; }
function displayName(s) {
  const m = String(s || '').match(/^\s*"?([^"<]+?)"?\s*</);
  return m ? m[1].trim() : extractEmail(s);
}
const OURS = 'designerwallcoverings.com';
function escHtml(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
// George prepends an internal "From job:" provenance banner to every draft —
// strip it so the customer-facing view (and edits) stay clean.
function stripJobBanner(s) {
  return String(s || '').replace(/<div style="font-family:ui-monospace[^>]*>[\s\S]*?<\/div>\s*/i, '');
}

// Signals that a contact has likely already transacted — the Samantha tripwire.
const SIGNALS = [
  { key: 'paid',     level: 3, label: 'PAID',     re: /\b(paid|payment received|received your payment|thank you for your (payment|order)|payment confirmation|invoice paid)\b/i },
  { key: 'shipped',  level: 3, label: 'SHIPPED',  re: /\b(shipped|shipment|has shipped|tracking (number|#|no)|out for delivery|fedex|ups tracking)\b/i },
  { key: 'ordered',  level: 2, label: 'ORDERED',  re: /\b(order #?\d|order confirmation|order placed|your order|purchase order|p\.?o\.? ?#?\d)\b/i },
  { key: 'invoiced', level: 2, label: 'INVOICED', re: /\b(invoice ?#?\d|invoice attached|here('| i)s your invoice|invoice for)\b/i },
];
// level ≥ this means "already transacted — do not bother with a quote followup"
const BLOCK_LEVEL = 2;

// Outbound "warm lead" signals — things WE sent that mean the client is MID-funnel and
// NEEDS a nudge (not a completed sale): an invoice awaiting payment, or samples out for
// evaluation. Steve's ask (2026-06-30): surface + sort by these to FIND who needs a
// followup. They never block — an invoice we SENT is the opposite of a closed sale.
const WARM_SIGNALS = [
  { key: 'invoice_sent',  label: 'INVOICE SENT',  re: /\binvoice/i },   // requires an attachment (enforced in detectWarmSent)
  { key: 'samples_sent',  label: 'SAMPLES SENT',  re: /\b(samples?|swatch(?:es)?|memo sample|cutting)\b/i },
  { key: 'stock_checked', label: 'STOCK CHECKED', re: /\b(in[\s-]?stock|in (our )?(warehouse|stock)|availabilit|available|on hand|lead[\s-]?time|currently (have|in stock)|we have (it|them|\d|in))\b/i },
  { key: 'quote_sent',    label: 'PRICE QUOTED',  re: /\b(quote(d)?|pricing|your price|priced at|the price (is|would|comes)|per (yard|roll|sq|square)|\$\s?\d)\b/i },
];
// ONLY these mean "already closed — leave them alone." 'invoiced' is intentionally NOT
// here: an unpaid invoice we sent is a warm followup target, not a closed deal. paid /
// shipped / a confirmed order still hard-block (the Samantha tripwire stays intact).
const BLOCK_SIGNAL_KEYS = new Set(['paid', 'shipped', 'ordered']);

// Phrasings the followup bot uses — to detect we've ALREADY nudged this contact.
const FU_RE = /follow[\s-]?up|following up|check(ing)? in|circl(e|ing) back|haven'?t heard|gentle (nudge|reminder)|chase up|did(n'?t| not) (you )?see/i;

// Reusable transaction-risk probe (the Samantha tripwire). Shared by the candidate
// scanner AND the draft-create guard so a client who already ordered/paid/shipped is
// NEVER surfaced as a candidate and NEVER drafted — not merely blocked at the Send
// button (a native Gmail-web send bypasses that button entirely, which is how
// already-closed clients got pitched on 2026-06-16).
async function assessRisk(email) {
  email = String(email || '').toLowerCase();
  let level = 0; const labels = new Set();
  try {
    const out = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `from:${email} OR to:${email}`, maxResults: 40 } });
    for (const m of (out.messages || [])) {
      if (m.error) continue;
      const hay = `${m.subject || ''} ${m.snippet || ''}`;
      for (const s of SIGNALS) if (s.re.test(hay)) { level = Math.max(level, s.level); labels.add(s.label); }
    }
  } catch (e) { return { level: 0, labels: [], blocked: false, error: e.message }; }
  // blocked = truly closed (paid/shipped/ordered). An invoice alone no longer blocks.
  const blocked = SIGNALS.some(s => BLOCK_SIGNAL_KEYS.has(s.key) && labels.has(s.label));
  return { level, labels: [...labels], blocked };
}

// Did WE send this contact an invoice or samples? (in:sent → OUTBOUND only, so an
// inbound "here's our invoice" from a vendor never counts.) These are the warm-lead
// signals Steve wants surfaced + sortable to find who most needs a followup. Returns
// e.g. { invoice_sent: {ts,date,label}, samples_sent: {ts,date,label} }.
async function detectWarmSent(email) {
  email = String(email || '').toLowerCase();
  const found = {};
  let quotedPrice = 0;
  try {
    // Broad warm-scan: samples / stock-checked / price-quoted. INVOICE is handled separately
    // below because Steve's rule (2026-06-30) is that an invoice ONLY counts with an attachment.
    const q = `in:sent to:${email} (sample OR samples OR swatch OR swatches OR "memo sample" OR cutting`
      + ` OR stock OR availability OR available OR "lead time" OR "we have" OR "on hand"`
      + ` OR quote OR quoted OR pricing OR price OR "per yard" OR "per roll")`;
    const r = await georgeRetry('/api/search', { query: { account: ACCOUNT, q, maxResults: 15 } });
    for (const m of (r.messages || [])) {
      if (m.error) continue;
      const hay = `${m.subject || ''} ${m.snippet || ''}`;
      const ts = Date.parse(m.date) || 0;
      for (const s of WARM_SIGNALS) {
        if (s.key === 'invoice_sent') continue;   // attachment-gated, handled below
        if (s.re.test(hay) && (!found[s.key] || ts > found[s.key].ts)) found[s.key] = { ts, date: m.date, label: s.label };
      }
      // largest $ amount we quoted this client (for the Price sort)
      const pm = hay.match(/\$\s?([\d,]+(?:\.\d{2})?)/g);
      if (pm) for (const p of pm) { const v = parseFloat(p.replace(/[^\d.]/g, '')); if (v > quotedPrice) quotedPrice = v; }
    }
    // INVOICE — only when the sent email actually carries an attachment. Gmail-native
    // has:attachment is the reliable check (search results don't expose MIME parts), so a
    // bare "invoice" word with no PDF never trips INVOICE SENT (Steve 2026-06-30).
    const ri = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `in:sent to:${email} invoice has:attachment`, maxResults: 5 } });
    for (const m of (ri.messages || [])) {
      if (m.error) continue;
      const ts = Date.parse(m.date) || 0;
      if (!found.invoice_sent || ts > found.invoice_sent.ts) found.invoice_sent = { ts, date: m.date, label: 'INVOICE SENT' };
    }
  } catch (e) { /* advisory — never block a candidate on a warm-scan failure */ }
  return { signals: found, quotedPrice };
}

// Have we already SENT this contact a followup in the last 21 days? (in:sent only —
// an unsent draft doesn't count.) Used to avoid double-nudging someone.
async function alreadyFollowedUp(email) {
  try {
    const fu = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `in:sent to:${email} newer_than:21d`, maxResults: 10 } });
    return (fu.messages || []).some(m => !m.error && FU_RE.test(`${m.subject || ''} ${m.snippet || ''}`));
  } catch { return false; }
}

// ── vendor vs client classification ──────────────────────────────────────
// The followup bot drafts "following on the quote" replies to vendor support
// desks too. Those aren't clients to pitch — they get their own section.
const SELF_DOMAINS = new Set(['designerwallcoverings.com']);
const VENDOR_ROLE = /^(cs|customerservice|customerservices|customercare|custserv|customer\.service|trade)$/i;
let VENDOR_DOMAINS = new Set(), VENDOR_SLUGS = new Set();
try {
  const v = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'vendors.json'), 'utf-8'));
  VENDOR_DOMAINS = new Set((v.domains || []).map(d => String(d).toLowerCase()));
  VENDOR_SLUGS = new Set((v.names || [])
    .map(n => String(n).toLowerCase().replace(/[^a-z0-9]/g, ''))
    .filter(s => s.length >= 4));
  console.log(`vendor list: ${VENDOR_DOMAINS.size} domains, ${VENDOR_SLUGS.size} name-slugs`);
} catch (e) { console.warn('vendors.json not loaded — every draft will count as a client'); }

function classifyRecipient(email) {
  email = String(email || '').toLowerCase();
  if (!email.includes('@')) return 'client';
  const [lp, dom] = email.split('@');
  if (SELF_DOMAINS.has(dom)) return 'vendor';        // DW-internal, not a client
  for (const d of VENDOR_DOMAINS) { if (dom === d || dom.endsWith('.' + d)) return 'vendor'; }
  const namePart = dom.split('.').slice(0, -1).join('').replace(/[^a-z0-9]/g, '');
  if (namePart.length >= 4 && VENDOR_SLUGS.has(namePart)) return 'vendor';
  if (VENDOR_ROLE.test(lp)) return 'vendor';
  return 'client';
}

// in-memory contact-scan cache (TTL 10 min)
const cache = new Map();
const TTL = 10 * 60 * 1000;
function cacheGet(k) { const e = cache.get(k); if (e && Date.now() - e.t < TTL) return e.v; cache.delete(k); return null; }
function cacheSet(k, v) { cache.set(k, { t: Date.now(), v }); }
const sleep = ms => new Promise(r => setTimeout(r, ms));

// Gmail (via George) throttles bursts → a transient 429/5xx must NOT be swallowed
// into a silently-dropped candidate (that was the root of the fluctuating count).
// Retry with backoff; only a hard, repeated failure propagates to the caller.
async function georgeRetry(p, opts, tries = 3) {
  let lastErr;
  for (let i = 0; i < tries; i++) {
    try { return await george(p, opts); }
    catch (e) { lastErr = e; await sleep(500 * (i + 1)); }
  }
  throw lastErr;
}

// ── 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;
const DRAFTS_TTL = 5 * 60 * 1000;
const DRAFT_PAGES = 4; // 4 × 100 = 400 most-recent drafts per load

// ── API: list drafts ──────────────────────────────────────────────────────
app.get('/api/drafts', async (req, res) => {
  try {
    if (draftsCache && !req.query.fresh && Date.now() - draftsCache.t < DRAFTS_TTL) {
      return res.json({ ...draftsCache.v, cached: true });
    }
    // throttled pagination — newest drafts first, capped to stay under Gmail quota
    let messages = [], pageToken, pages = 0, more = false;
    do {
      const out = await george('/api/messages', { query: { account: ACCOUNT, q: 'in:draft', maxResults: 100, pageToken } });
      messages = messages.concat(out.messages || []);
      pageToken = out.nextPageToken;
      if (++pages >= DRAFT_PAGES) { more = !!pageToken; break; }
      if (pageToken) await sleep(700);
    } while (pageToken);
    const drafts = messages.map(m => ({
      id: m.id,
      threadId: m.threadId,
      subject: m.subject || '(no subject)',
      to: m.to || '',
      toEmail: extractEmail(m.to),
      toName: displayName(m.to) || extractEmail(m.to) || '(no recipient)',
      date: m.date || '',
      snippet: m.snippet || '',
      kind: classifyRecipient(extractEmail(m.to)),
    }));
    const vendorCount = drafts.filter(d => d.kind === 'vendor').length;
    const payload = { account: `${ACCOUNT}@${OURS}`, count: drafts.length,
      clientCount: drafts.length - vendorCount, vendorCount, more, drafts };
    draftsCache = { t: Date.now(), v: payload };
    res.json(payload);
  } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
});

// ── 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.
  // Fully paginate (capped) with retry so the candidate universe is COMPLETE
  // and stable rather than a throttle-truncated slice that varies per scan.
  let sent = [], pageToken, SENT_PAGES = 4;  // 4 × 100 = up to 400 sent msgs
  for (let pg = 0; pg < SENT_PAGES; pg++) {
    const out = await georgeRetry('/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, 80);
  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;
    // did the client reply after our last message? (from: + after:YYYY/MM/DD)
    // is a followup draft to this contact already pending?
    // A throttled check must NOT silently drop the candidate (that caused the
    // 11→6 fluctuation) — retry, and if it still fails, INCLUDE it flagged
    // `uncertain` so Steve sees it (over-include is safe: the risk-gate + his
    // review catch a false positive; a silently-dropped real lead is lost revenue).
    const sd = new Date(info.ts);
    const after = `${sd.getFullYear()}/${sd.getMonth() + 1}/${sd.getDate()}`;
    let uncertain = false;
    try {
      const reply = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `from:${contact} after:${after}`, maxResults: 2 } });
      if ((reply.messages || []).some(x => !x.error)) { await sleep(150); continue; }  // replied → not ghosted
    } catch (e) { uncertain = true; }
    try {
      const dr = await georgeRetry('/api/search', { query: { account: ACCOUNT, q: `in:draft to:${contact}`, maxResults: 2 } });
      if ((dr.messages || []).some(x => !x.error)) { await sleep(150); continue; }      // already drafted
    } catch (e) { uncertain = true; }
    // transaction tripwire: if they already ordered/paid/shipped, they are NOT a
    // followup candidate — never surface them, so they can never be drafted/sent.
    const risk = await assessRisk(contact);
    if (risk.blocked) { await sleep(150); continue; }   // paid/shipped/ordered → truly closed, leave alone
    // and don't double-nudge someone we already followed up recently.
    if (await alreadyFollowedUp(contact)) { await sleep(150); continue; }
    // warm-lead signals: did WE send them an invoice or samples? Those are the
    // highest-priority follow targets (mid-funnel, about to be lost if not nudged).
    const warm = await detectWarmSent(contact);
    const wsig = warm.signals || {};
    const warmSignals = Object.values(wsig).map(w => w.label);
    candidates.push({
      threadId: info.msg.threadId,
      subject: info.msg.subject || '(no subject)',
      contact, contactName: displayName(info.msg.to) || contact,
      lastDate: info.msg.date, daysSince, uncertain,
      warmSignals,
      sentInvoiceTs: wsig.invoice_sent ? wsig.invoice_sent.ts : 0,
      sentSamplesTs: wsig.samples_sent ? wsig.samples_sent.ts : 0,
      sentStockTs:   wsig.stock_checked ? wsig.stock_checked.ts : 0,
      sentQuoteTs:   wsig.quote_sent ? wsig.quote_sent.ts : 0,
      quotedPrice:   warm.quotedPrice || 0,
    });
    await sleep(150);
  }
  // Warm leads (invoice/samples we sent) first — those most need a follow — then by how
  // long they've gone quiet. (The UI can re-sort to invoice-only / samples-only.)
  candidates.sort((a, b) =>
    ((b.warmSignals && b.warmSignals.length ? 1 : 0) - (a.warmSignals && a.warmSignals.length ? 1 : 0))
    || b.daysSince - a.daysSince);
  const payload = { count: candidates.length, scanned: contacts.length,
    uncertainCount: candidates.filter(c => c.uncertain).length,
    candidates, scannedAt: new Date().toISOString() };
  candCache = { t: Date.now(), v: payload };
  return payload;
}

// Single-flight: a fresh cache is served directly; otherwise ALL concurrent
// callers (page auto-load + manual rescans + the daily timer) coalesce onto the
// ONE in-flight scan instead of each firing its own — which is what made the
// count differ per reload (last-writer-wins on candCache). Reloads now read the
// cache and the number is stable until the 20-min TTL or an explicit ?fresh=1.
let candScanInFlight = null;
function getCandidates(fresh) {
  if (!fresh && candCache && Date.now() - candCache.t < CAND_TTL)
    return Promise.resolve({ ...candCache.v, cached: true });
  if (candScanInFlight) return candScanInFlight;
  candScanInFlight = runCandidateScan().finally(() => { candScanInFlight = null; });
  return candScanInFlight;
}

app.get('/api/followup-candidates', async (req, res) => {
  try { res.json(await getCandidates(!!req.query.fresh)); }
  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(() => { getCandidates(true).catch(e => console.warn('[followup auto-scan] startup:', e.message)); }, 30_000);
setInterval(() => { getCandidates(true).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 {
    const m = await george(`/api/messages/${encodeURIComponent(req.params.id)}`, { query: { account: ACCOUNT } });
    res.json(m);
  } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
});

// ── API: every thread + risk scan for one contact ─────────────────────────
app.get('/api/contact', async (req, res) => {
  const email = extractEmail(req.query.email);
  if (!email) return res.status(400).json({ error: 'valid email required' });
  const cached = !req.query.fresh && cacheGet(email);
  if (cached) return res.json({ ...cached, cached: true });
  try {
    const out = await george('/api/search', {
      query: { account: ACCOUNT, q: `from:${email} OR to:${email}`, maxResults: 60 },
    });
    const msgs = (out.messages || []).filter(m => !m.error).map(m => {
      const fromEmail = extractEmail(m.from);
      const inbound = fromEmail === email; // from the customer, not from us
      const hay = `${m.subject || ''} ${m.snippet || ''}`;
      const hits = SIGNALS.filter(s => s.re.test(hay)).map(s => s.key);
      return {
        id: m.id, threadId: m.threadId,
        subject: m.subject || '(no subject)',
        from: m.from || '', fromName: displayName(m.from),
        to: m.to || '', date: m.date || '',
        ts: Date.parse(m.date) || 0,
        snippet: m.snippet || '', inbound, signals: hits,
      };
    }).sort((a, b) => b.ts - a.ts);

    // group into threads
    const tmap = new Map();
    for (const m of msgs) {
      if (!tmap.has(m.threadId)) tmap.set(m.threadId, { threadId: m.threadId, subject: m.subject, messages: [], signals: new Set(), lastTs: 0 });
      const t = tmap.get(m.threadId);
      t.messages.push(m);
      m.signals.forEach(s => t.signals.add(s));
      if (m.ts > t.lastTs) { t.lastTs = m.ts; t.lastDate = m.date; }
    }
    const threads = [...tmap.values()].map(t => ({ ...t, signals: [...t.signals] })).sort((a, b) => b.lastTs - a.lastTs);

    // overall risk
    const allSignals = new Set();
    msgs.forEach(m => m.signals.forEach(s => allSignals.add(s)));
    const customerReplied = msgs.some(m => m.inbound);
    let level = 0;
    for (const s of SIGNALS) if (allSignals.has(s.key)) level = Math.max(level, s.level);
    if (threads.length > 1) level = Math.max(level, 1);
    const risk = level >= 3 ? 'high' : level >= 2 ? 'elevated' : level >= 1 ? 'caution' : 'clear';

    // Did we already send this contact a followup recently? The followup bot
    // uses several phrasings, and Gmail snippets can show quoted text instead
    // of the opening line — so match broadly and body-check when the snippet misses.
    // (in:sent only — an unsent draft doesn't count.)
    let recentFollowup = null;
    try {
      const FU_RE = /follow[\s-]?up|following up|check(ing)? in|circl(e|ing) back|haven'?t heard|gentle (nudge|reminder)|chase up|did(n'?t| not) (you )?see/i;
      const fu = await george('/api/search', { query: { account: ACCOUNT, q: `in:sent to:${email} newer_than:21d`, maxResults: 20 } });
      const cand = (fu.messages || []).filter(m => !m.error)
        .sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0));
      let bodyChecks = 0;
      for (const m of cand) {
        let isFu = FU_RE.test(`${m.subject || ''} ${m.snippet || ''}`);
        if (!isFu && bodyChecks < 6) {            // snippet missed it — confirm via the full body
          bodyChecks++;
          try {
            const full = await george('/api/messages/' + encodeURIComponent(m.id), { query: { account: ACCOUNT } });
            isFu = FU_RE.test(full.body || '');
          } catch (e) {}
        }
        if (isFu) {
          const ts = Date.parse(m.date) || 0;
          recentFollowup = { ts, date: m.date, daysAgo: Math.max(0, Math.floor((Date.now() - ts) / 86400000)) };
          break;                                  // newest-first → first match is the most recent
        }
      }
    } 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, sentLetters,
    };
    cacheSet(email, result);
    res.json(result);
  } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
});

// ── API: lightweight risk verdict (for bulk left-panel scanning) ──────────
// One targeted search instead of pulling the whole history — keeps the
// scan-all sweep under Gmail's per-minute quota.
app.get('/api/risk', async (req, res) => {
  const email = extractEmail(req.query.email);
  if (!email) return res.status(400).json({ error: 'valid email required' });
  const hit = cacheGet(email);
  if (hit) return res.json({ email, risk: hit.risk, signals: hit.signals, cached: true });
  const lite = cacheGet('risk:' + email);
  if (lite) return res.json({ ...lite, cached: true });
  try {
    const q = `(from:${email} OR to:${email}) (invoice OR paid OR payment OR shipped OR shipping OR tracking OR receipt OR "order confirmation" OR "your order")`;
    const out = await george('/api/search', { query: { account: ACCOUNT, q, maxResults: 8 } });
    const allSignals = new Set();
    for (const m of (out.messages || [])) {
      if (m.error) continue;
      const hay = `${m.subject || ''} ${m.snippet || ''}`;
      SIGNALS.filter(s => s.re.test(hay)).forEach(s => allSignals.add(s.key));
    }
    let level = 0;
    for (const s of SIGNALS) if (allSignals.has(s.key)) level = Math.max(level, s.level);
    // a keyword matched but no precise signal regex did → still worth a look
    if (!level && (out.messages || []).some(m => !m.error)) level = 2;
    const risk = level >= 3 ? 'high' : level >= 2 ? 'elevated' : 'clear';
    const v = { email, risk, signals: [...allSignals].map(k => SIGNALS.find(s => s.key === k).label) };
    cacheSet('risk:' + email, v);
    res.json(v);
  } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
});

// ── API: send the followup in-thread (Steve clicks the button) ────────────
app.post('/api/send', async (req, res) => {
  try {
    const { to, subject, body, threadId } = req.body || {};
    if (!to || !body) return res.status(400).json({ error: 'to + body required' });
    // reply to the most recent real (non-draft) message in the thread for proper headers
    let replyToMessageId;
    if (threadId) {
      try {
        const t = await george('/api/messages', { query: { account: ACCOUNT, q: `threadId:${threadId}`, maxResults: 25 } });
        const real = (t.messages || []).filter(m => !(m.labelIds || []).includes('DRAFT'));
        if (real.length) replyToMessageId = real[real.length - 1].id;
      } catch { /* fall back to plain threadId send */ }
    }
    const payload = { account: ACCOUNT, from: FROM, to, subject, body, no_source_tag: true, ...(EXT_SEND_TOKEN ? { send_approval_token: EXT_SEND_TOKEN } : {}), ...(threadId ? { threadId } : {}), ...(replyToMessageId ? { replyToMessageId } : {}) };
    if (!EXT_SEND_TOKEN) console.warn('[send] GEORGE_EXTERNAL_SEND_TOKEN not set — external sends will be blocked by George.');
    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 {
    const out = await george('/api/messages/' + encodeURIComponent(req.params.id) + '/trash',
      { method: 'POST', body: { account: ACCOUNT } });
    res.json({ ok: true, trashed: out });
  } 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' });
    // defense-in-depth: NEVER create a followup draft for someone who already
    // transacted — even if a caller (or stale UI) asks for it. The candidate
    // scanner already excludes them; this is the second wall.
    if (!req.body.override) {
      const risk = await assessRisk(to);
      if (risk.blocked)
        return res.status(409).json({ error: 'blocked: contact already transacted (' + risk.labels.join(', ') + ') — not drafting a quote followup', risk: risk.labels });
    }
    const subj = /^re:/i.test(subject || '') ? subject : 'Re: ' + (subject || 'your inquiry');
    const out = await george('/api/drafts', { method: 'POST', body: { account: ACCOUNT, from: FROM, to, subject: subj, body: FOLLOWUP_BODY, no_source_tag: true } });
    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 }); }
});

// ── API: render a draft as a front-facing HTML email page ──────────────────
app.get('/api/draft/:id/html', async (req, res) => {
  try {
    const m = await george('/api/messages/' + encodeURIComponent(req.params.id), { query: { account: ACCOUNT } });
    const body = stripJobBanner(m.body || '');
    const looksHtml = /<(br|div|p|a|span|strong|em|ul|ol|li|table|h[1-6])\b/i.test(body);
    const rendered = looksHtml ? body : escHtml(body).replace(/\n/g, '<br>');
    res.set('Content-Type', 'text/html; charset=utf-8').send(
      '<!doctype html><html><head><meta charset="utf-8">' +
      '<meta name="viewport" content="width=device-width,initial-scale=1">' +
      '<title>' + escHtml(m.subject || 'Draft') + '</title><style>' +
      'body{margin:0;background:#e7e4dc;font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#26221c}' +
      '.wrap{max-width:660px;margin:34px auto;background:#fff;border-radius:10px;box-shadow:0 3px 16px rgba(0,0,0,.13);overflow:hidden}' +
      '.hd{padding:18px 28px;border-bottom:1px solid #ece8df;background:#faf8f3}' +
      '.hd .s{font:600 18px Georgia,"Times New Roman",serif}' +
      '.hd .m{font-size:12px;color:#8a8273;margin-top:4px}' +
      '.bd{padding:28px}.bd img{max-width:100%}' +
      '</style></head><body><div class="wrap">' +
      '<div class="hd"><div class="s">' + escHtml(m.subject || '(no subject)') + '</div>' +
      '<div class="m">To: ' + escHtml(m.to || '—') + ' &nbsp;·&nbsp; From: ' + escHtml(m.from || ('info@' + OURS)) + '</div></div>' +
      '<div class="bd">' + rendered + '</div></div></body></html>');
  } catch (e) { res.status(e.status || 500).send('<pre>' + escHtml(e.message) + '</pre>'); }
});

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)`));