← back to Dw Pitch Followup
server.js
866 lines
// dw-pitch-followup viewer — READ-ONLY against FileMaker, with ONE deliberate
// exception: POST /api/notes prepends a dated line to the client's
// Invoice::Internal notes (the notes box on the FM Pro client screen).
// GET / → the 3-tab pitch viewer (public/index.html)
// GET /healthz → liveness
// GET /api/lists → data/lists.json (the 3 built lists)
// POST /api/letter → compose a CUSTOMIZED follow-up letter for one row,
// grounded in the client's last Gmail thread (George READ)
// + all the project facts we hold. Returns { subject, body,
// context, variant, engine }. NEVER sends/drafts.
//
// HARD RULE (Steve 2026-06-30): NEVER expose a manufacturer number (mfr SKU /
// article code) in the UI or in a CLIENT-facing letter unless explicitly asked
// (the "show mfr#" toggle). Vendor-chase notes (list3) may name the SKU because
// the recipient IS the vendor. mfr scrubbing is applied defensively on both the
// inputs and the generated output for client lists.
import express from 'express';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 9768);
const DATA_DIR = path.join(__dirname, 'data');
const LISTS = path.join(DATA_DIR, 'lists.json');
const SENT_LOG = path.join(DATA_DIR, 'sent-log.jsonl'); // permanent record of every email we send
const SUPPRESS_FILE = path.join(DATA_DIR, 'suppress.json'); // unsubscribed addresses — never email these
function loadSuppress() { try { return new Set(JSON.parse(fs.readFileSync(SUPPRESS_FILE, 'utf8')).map((e) => String(e).toLowerCase())); } catch { return new Set(); } }
function addSuppress(emails) {
const set = loadSuppress(); let added = 0;
for (const e of emails) { const lc = String(e || '').trim().toLowerCase(); if (lc && !set.has(lc)) { set.add(lc); added++; } }
try { fs.writeFileSync(SUPPRESS_FILE, JSON.stringify([...set], null, 0)); } catch {}
return { added, total: set.size };
}
const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
// Resolve George secrets: prefer our own env, else read them straight from george-gmail/.env
// (same fallback George itself uses — launchd/pm2 don't load .env into process.env).
const GEORGE_ENV = process.env.GEORGE_ENV_PATH || '/Users/macstudio3/Projects/george-gmail/.env';
// The canonical fleet cred lives in the secrets-manager master (GEORGE_AUTH = "user:pass").
const SECRETS_ENV = process.env.SECRETS_ENV_PATH || '/Users/macstudio3/Projects/secrets-manager/.env';
function envFrom(file, key) {
try { const m = fs.readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); return m ? m[1].trim().replace(/^["']|["']$/g, '') : ''; } catch { return ''; }
}
function envFromGeorge(key) { return envFrom(GEORGE_ENV, key); }
// George Basic auth. Prefer the canonical GEORGE_AUTH ("user:pass") from the secrets master so
// the cred survives `pm2 restart --update-env` (the process-env password used to vanish on a
// bare restart → every George call silently 401'd, killing sends + threading + last-corr).
function resolveGeorgeAuth() {
const direct = process.env.GEORGE_AUTH || envFrom(SECRETS_ENV, 'GEORGE_AUTH') || envFromGeorge('GEORGE_AUTH');
if (direct) return direct.startsWith('Basic ') ? direct : ('Basic ' + Buffer.from(direct.includes(':') ? direct : ('admin:' + direct)).toString('base64'));
const pass = process.env.GEORGE_BASIC_AUTH_PASS || envFromGeorge('GEORGE_BASIC_AUTH_PASS');
return 'Basic ' + Buffer.from('admin:' + pass).toString('base64');
}
const GEORGE_AUTH = resolveGeorgeAuth();
// Human-approval token for EXTERNAL sends. George fail-closes without it (blocks any
// non-@designerwallcoverings.com recipient). Attaching it = the Send-now click is the
// human approval; George still stamps a source footer on every message.
const GEORGE_EXT_SEND_TOKEN = process.env.GEORGE_EXTERNAL_SEND_TOKEN || envFromGeorge('GEORGE_EXTERNAL_SEND_TOKEN');
// Local LLM for genuine customization ($0). Falls back to deterministic compose if unreachable.
const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const LETTER_MODEL = process.env.LETTER_MODEL || 'hermes3:8b';
const app = express();
app.use(express.json({ limit: '512kb' }));
// Baseline security response headers for a public endpoint (contrarian Engineer: "no helmet").
// nosniff = no MIME-sniffing; DENY = can't be iframed (anti-clickjacking on an action tool);
// no-referrer = don't leak the internal URL. Cheap, reversible hardening.
app.use((req, res, next) => {
res.set('X-Content-Type-Options', 'nosniff');
res.set('X-Frame-Options', 'DENY');
res.set('Referrer-Policy', 'no-referrer');
next();
});
// Basic auth (admin / DW2024!) — every route except /healthz.
// EXCEPTION: skip auth for DIRECT local-machine access (loopback socket, not
// forwarded through the cloudflared tunnel). The server binds 127.0.0.1 only,
// so a direct-loopback request can only come from someone already at this Mac —
// requiring a password there just breaks browsers (Safari drops user:pass@ in
// URLs, and inline creds don't propagate to the /api/lists fetch → blank grid).
// Tunnel/remote requests still require auth: cloudflared stamps proxied requests
// with cf-connecting-ip / x-forwarded-for, so they don't get the loopback pass.
// Local secrets file for THIS app (pm2/launchd don't load .env into process.env, so read it
// directly — same pattern as envFromGeorge above). Lets us rotate the public tunnel cred off
// the shared fleet password (DW2024!) without hardcoding it. .env is gitignored.
const LOCAL_ENV = path.join(__dirname, '.env');
function envLocal(key) {
try { const m = fs.readFileSync(LOCAL_ENV, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); return m ? m[1].trim().replace(/^["']|["']$/g, '') : ''; } catch { return ''; }
}
const BASIC_AUTH = process.env.BASIC_AUTH || envLocal('BASIC_AUTH') || 'admin:DW2024!';
const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
// Brute-force lockout (contrarian FIX FIRST #2): the public tunnel exposes Basic Auth,
// which without a lockout is guessable at line speed. Track failed auths per client IP;
// after FAIL_MAX failures inside FAIL_WINDOW_MS, that IP is 429'd for LOCK_MS. A correct
// auth clears the record. In-memory only (tiny traffic); pruned so the map can't grow.
const authFails = new Map(); // ip -> { count, first, until }
const FAIL_MAX = 10, FAIL_WINDOW_MS = 15 * 60 * 1000, LOCK_MS = 15 * 60 * 1000;
function clientIp(req) {
return req.get('cf-connecting-ip')
|| (req.get('x-forwarded-for') || '').split(',')[0].trim()
|| (req.socket && req.socket.remoteAddress) || 'unknown';
}
app.use((req, res, next) => {
if (req.path === '/healthz' || req.path === '/favicon.ico') return next();
const remote = req.socket && req.socket.remoteAddress;
const proxied = req.get('cf-connecting-ip') || req.get('x-forwarded-for');
if (LOOPBACK.has(remote) && !proxied) return next(); // direct local access
const ip = clientIp(req), now = Date.now();
let rec = authFails.get(ip);
if (rec && rec.until && now < rec.until) { // currently locked out
res.set('Retry-After', String(Math.ceil((rec.until - now) / 1000)));
return res.status(429).send('too many failed attempts — try again later');
}
if (req.get('authorization') === AUTH_HEADER) {
if (rec) authFails.delete(ip); // success clears the record
return next();
}
// failed auth → record + maybe lock
if (!rec || (now - rec.first) > FAIL_WINDOW_MS) rec = { count: 0, first: now, until: 0 };
rec.count++;
if (rec.count >= FAIL_MAX) rec.until = now + LOCK_MS;
authFails.set(ip, rec);
if (authFails.size > 2000) { // prune stale entries
for (const [k, v] of authFails) if ((!v.until || now >= v.until) && (now - v.first) > FAIL_WINDOW_MS) authFails.delete(k);
}
res.set('WWW-Authenticate', 'Basic realm="dw-pitch-followup"');
res.status(401).send('auth required');
});
app.get('/healthz', (_req, res) => res.json({ ok: true, ts: new Date().toISOString() }));
// No favicon asset shipped — return 204 (auth-exempt) so browsers stop 404ing on
// the automatic /favicon.ico request (kills a console error on every page load).
app.get('/favicon.ico', (_req, res) => res.status(204).end());
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
app.get('/api/lists', (_req, res) => {
if (!fs.existsSync(LISTS)) return res.status(503).json({ error: 'lists not built yet — run npm run build' });
res.type('application/json').send(fs.readFileSync(LISTS, 'utf8'));
});
// --- mfr-number scrub (HARD RULE) -----------------------------------
// Drop any token that looks like a manufacturer SKU / article number: an
// alphanumeric run (optionally hyphenated) that CONTAINS a digit. Keeps real
// words (pattern + brand names like "Oxel Cork", "Phillipe Romano").
function stripMfr(s) {
if (!s) return '';
return String(s)
.replace(/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/g, (tok) => (/\d/.test(tok) ? '' : tok))
.replace(/\s*[|·•—–-]+\s*/g, ' · ')
.replace(/(?:^[\s·]+|[\s·]+$)/g, '')
.replace(/(?:·\s*){2,}/g, '· ')
.replace(/\s{2,}/g, ' ')
.replace(/\bSample\b\s*$/i, '')
.trim().replace(/[·\s]+$/, '');
}
// Clean a pattern/detail label for client eyes (name only). showMfr bypasses.
const clientLabel = (s, showMfr) => (showMfr ? String(s || '').trim() : stripMfr(s));
// --- George read-only context ---------------------------------------
async function georgeContext(email) {
if (!email) return { found: false };
const q = encodeURIComponent(`from:${email} OR to:${email}`);
for (const account of ['info', 'steve-office']) {
try {
const r = await fetch(`${GEORGE}/api/search?q=${q}&account=${account}&maxResults=3`, {
headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(8000),
});
if (!r.ok) continue;
const j = await r.json();
const msgs = (j.messages || []).filter((m) => m.subject || m.snippet);
if (msgs.length) {
const m = msgs[0];
return { found: true, account, subject: m.subject || '', snippet: (m.snippet || '').slice(0, 240), date: m.date || '' };
}
} catch { /* try next account */ }
}
return { found: false };
}
// --- structured facts we hand to the writer (mfr-scrubbed for clients) ----
function firstName(company) {
// Drop parenthetical prefixes like "(Fedex Please)" and shipping notes, then take the
// first real word; de-shout ALL-CAPS (THOM → Thom). Falls back to "there".
const s = String(company || '').replace(/\([^)]*\)/g, ' ').replace(/\b(fedex|ups|please|attn|c\/o)\b/gi, ' ').trim();
let t = (s.split(/[\s,]+/).find((w) => w && !/^\d+$/.test(w))) || '';
if (!t || /^account$/i.test(t)) return 'there';
if (t === t.toUpperCase() && t.length > 1) t = t[0] + t.slice(1).toLowerCase();
return t;
}
function buildFacts(list, row, ctx, showMfr) {
const f = [];
const name = firstName(row.company);
let allowed = []; // the ONLY product names the letter may use — the client's actual samples
if (list === 'list1') {
const pats = (row.patterns || []).map((p) => clientLabel(p, showMfr)).filter(Boolean);
allowed = pats;
f.push(`Client: ${row.company || 'this client'}`);
f.push(`They received ${row.sent} of ${row.ordered} sample(s) they requested${row.missing ? ` (${row.missing} still in transit)` : ' — all in hand'}.`);
if (pats.length) f.push(`Pattern(s) by name: ${pats.slice(0, 4).join(', ')}.`);
f.push(`Goal: warmly convert sampling into an order; offer to confirm stock/pricing or send coordinating options.`);
} else if (list === 'list2') {
const dets = (row.details || []).map((d) => clientLabel(d, showMfr)).filter(Boolean);
allowed = dets;
f.push(`Client: ${row.company || 'this client'}`);
f.push(`History: ${row.invoice_count} sample-only invoice(s); never placed a firm order.`);
if (dets.length) f.push(`Sampled (by name): ${dets.slice(0, 4).join('; ')}.`);
f.push(`Goal: re-engage warmly, see if samples landed, offer next step (stock, pricing, more options).`);
}
if (ctx && ctx.found) {
f.push(`Most recent email thread${ctx.subject ? ` was titled "${clientLabel(ctx.subject, showMfr)}"` : ''}${ctx.date ? ` (${new Date(ctx.date).toLocaleDateString()})` : ''}.`);
if (ctx.snippet) f.push(`That thread snippet: "${clientLabel(ctx.snippet, showMfr)}".`);
}
return { name, facts: f, allowed: allowed.filter(Boolean) };
}
// --- LLM letter (genuinely customized, varied by seed) ---------------
async function composeLLM(list, row, ctx, seed, showMfr) {
const { name, facts, allowed } = buildFacts(list, row, ctx, showMfr);
const allowList = allowed || [];
// The whitelist the writer is bound to. Empty → the model must name nothing specific.
const allowedLine = allowList.length
? `ALLOWED PATTERN NAMES (the ONLY product names you may write): ${allowList.slice(0, 6).join(', ')}.`
: `ALLOWED PATTERN NAMES: none — do NOT name any specific product, pattern, or collection; refer to them generically as "the samples" / "the pieces you received".`;
const sys = `You write short, warm, genuinely personal follow-up emails for Designer Wallcoverings, a luxury wallcovering house, to design clients. Voice: gracious, specific, never pushy, never templated. HARD RULES: (1) NEVER include any manufacturer SKU, article number, or product code — refer to products by NAME only. (2) You may ONLY name a specific product/pattern/collection if that exact name is in the ALLOWED PATTERN NAMES list; otherwise speak generically ("the samples", "the pieces you received") — NEVER invent, guess, embellish, trademark (no ™/®), or append the word "Collection" to a name. (3) 110-160 words. (4) No placeholders or brackets. (5) Open the BODY with "Hi ${name}," and close with "Warm regards,\\nDesigner Wallcoverings". (6) The SUBJECT is a short standalone subject line (5-9 words) — NOT a greeting; never start it with "Hi", "Hello", or the client's name. Return STRICT JSON: {"subject": "...", "body": "..."} and nothing else.`;
const usr = `Write variant #${seed} (make it distinct from other variants in opening + framing).\n${allowedLine}\nFacts:\n- ${facts.join('\n- ')}`;
const body = {
model: LETTER_MODEL, stream: false, format: 'json',
keep_alive: '30m', // pin the model resident so back-to-back drafts don't cold-reload past the timeout
options: { temperature: 0.55 + ((seed - 1) % 4) * 0.13, seed: 1000 + seed },
messages: [{ role: 'system', content: sys }, { role: 'user', content: usr }],
};
const r = await fetch(`${OLLAMA}/api/chat`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), signal: AbortSignal.timeout(90000), // headroom for a cold model reload; keep_alive keeps subsequent calls fast
});
if (!r.ok) throw new Error(`ollama ${r.status}`);
const j = await r.json();
const out = JSON.parse(j.message?.content || '{}');
let subject = String(out.subject || '').trim();
let letter = String(out.body || '').trim();
if (!letter) throw new Error('empty LLM letter');
// Anti-hallucination guard: reject any product/collection name not on the whitelist. Signals =
// a ™/® glyph (the house never uses them → the model embellished) or a Capitalized phrase followed
// by "Collection". Any candidate whose core isn't allow-listed → throw, and the caller falls back
// to the accurate deterministic draft. Never lets an invented pattern name reach a client draft.
const allowLC = new Set(allowList.map((s) => s.toLowerCase().trim()));
const candidates = [];
for (const m of letter.matchAll(/([A-Z][A-Za-z'’-]+(?:\s+[A-Z][A-Za-z'’-]+){0,3})\s*(?:™|®|\bCollection\b)/g)) candidates.push(m[1]);
const bad = candidates.map((c) => c.trim()).find((c) => c && !allowLC.has(c.toLowerCase()));
if (bad) throw new Error(`named an unverified pattern ("${bad}")`);
// Cleanup: strip stray trademark glyphs, fix subject-greeting leak, ensure the body greeting.
const noTM = (s) => s.replace(/[™®©]/g, '').replace(/[ \t]{2,}/g, ' ').trim();
subject = noTM(subject); letter = noTM(letter);
if (/^\s*(hi|hello|dear|hey)\b/i.test(subject)) subject = 'Following up on your Designer Wallcoverings samples';
if (!/^\s*hi\b/i.test(letter)) letter = `Hi ${name},\n\n${letter}`;
if (!showMfr) { subject = stripMfr(subject) || subject; letter = scrubBodyMfr(letter); }
return { subject: subject || 'Following up on your Designer Wallcoverings samples', body: letter, engine: LETTER_MODEL };
}
// line-safe scrub for a full letter body (only nukes SKU-ish tokens, keeps prose)
function scrubBodyMfr(text) {
return String(text).replace(/\b(?=[A-Za-z]*\d)[A-Za-z]{1,5}[A-Za-z0-9]*-?\d[A-Za-z0-9-]*\b/g, '').replace(/ {2,}/g, ' ');
}
// --- deterministic fallback (rich + seed-varied) ---------------------
function composeDeterministic(list, row, ctx, seed, showMfr) {
const { name } = buildFacts(list, row, ctx, showMfr);
const ref = ctx && ctx.found
? ` When we last connected${ctx.subject ? ` about "${clientLabel(ctx.subject, showMfr)}"` : ''}, I wanted to make sure nothing slipped through the cracks.` : '';
const opens = [
`I wanted to follow up on the samples we sent your way`,
`I've been thinking about your project and wanted to check back in on the samples we sent`,
`Just circling back on the samples now in your hands`,
];
const closes = ['Warm regards,', 'With thanks,', 'Always happy to help —'];
const op = opens[(seed - 1) % opens.length];
const cl = closes[(seed - 1) % closes.length];
if (list === 'list3') { // vendor chase — SKU is appropriate (recipient is the vendor)
const vendor = row.vendor || 'team';
return {
subject: `Sample memo follow-up — ${row.pattern || row.sku || 'pending request'}`,
body: `Hi ${vendor},\n\nFollowing up on a sample memo we requested ${row.req_iso ? `on ${new Date(row.req_iso).toLocaleDateString()}` : 'recently'} — it's now ${row.days_overdue} days out and we haven't received it.\n\n • Pattern: ${row.pattern || '—'}\n • SKU: ${row.sku || '—'}\n • For our client: ${row.company || `account ${row.account}`}\n\nCould you confirm whether this shipped, and if not, the expected ship date? Our client is waiting on it.\n\nThank you,\nDesigner Wallcoverings`,
engine: 'deterministic',
};
}
// ---- list1 / list2 client follow-up — Steve's spec (2026-07-07) ----
// Concise (~3-sentence core), witty, NEVER mentions a mfr#, never the new year.
// Identifies client type; rotates the 3-name sign-off; lists the actual samples w/ ship
// dates; asks for project details before a closing question; always ends the ask with a
// question; adds a PS. designerwallcoverings.com + phone are linkified when SENT (HTML).
// Prefer the real contact first name from FileMaker's Name field; fall back to parsing company.
const greetName = (row.first_name && String(row.first_name).trim()) ? String(row.first_name).trim() : name;
const type = String(row.client_type || '').toLowerCase();
const isTrade = /interior designer|architect|designer/.test(type);
// The client's ACTUAL samples by their DW sku/name (combo sku is a DW sku, NOT a mfr#), + ship date.
const sentArr = Array.isArray(row.samples_sent) ? row.samples_sent : [];
let sampleItems = sentArr.map((s) => {
const nm = String(typeof s === 'string' ? s : (s.label || s.sku || '')).trim();
const d = (s && s.date) ? new Date(s.date) : null;
return nm ? ` • ${nm}${d && !isNaN(d) ? ` — sent ${d.toLocaleDateString()}` : ''}` : null;
}).filter(Boolean);
if (!sampleItems.length) {
const alt = (list === 'list1' ? row.patterns : row.details) || [];
sampleItems = alt.map((p) => clientLabel(p, showMfr)).filter(Boolean).slice(0, 8).map((n) => ` • ${n}`);
}
// Only treat `project` as a real project name — reject dimension/spec strings that leak in from
// the invoice line (e.g. '19.6" wide x 32.8 ft. per bolt') so the letter never says "your … project".
let proj = String(row.project || '').trim();
if (!/[a-z]{3}/i.test(proj) || /\b(wide|ft|bolt|inch|yard|sq|roll|panel|per\b)\b/i.test(proj) || /\d\s*["'”]/.test(proj)) proj = '';
const projRef = proj ? ` for your ${proj} project` : '';
// seed-varied flavor so each Regenerate reads fresh
const greets = ['Hope your day is going well!', 'Hope all is well on your end!', 'Hope your week is off to a great start!', 'Hope you’re having a good one!'];
const wits = [
'If you’d like a few more colorways to play with — or a quick stock check before anything wanders off — just say the word.',
'Want more samples to compare, or should we confirm your favorites are still in stock before they sell through?',
'Happy to send more options or run a stock check on anything that caught your eye — no arm-twisting, promise.',
'Need another texture, a coordinating option, or a stock check? I’m one reply away.',
];
const questions = ['What’s the next step you’d like us to take?', 'How can we help you get this across the finish line?', 'What would be most useful from us right now?', 'Where would you like to go from here?'];
const g = greets[(seed - 1) % greets.length];
const wit = wits[(seed - 1) % wits.length];
const q = questions[(seed - 1) % questions.length];
// sign-off rotates Steve / Samantha / Marcus every regenerate
const perms = [['Steve', 'Samantha', 'Marcus'], ['Samantha', 'Marcus', 'Steve'], ['Marcus', 'Steve', 'Samantha'], ['Steve', 'Marcus', 'Samantha'], ['Samantha', 'Steve', 'Marcus'], ['Marcus', 'Samantha', 'Steve']];
const nm = perms[(seed - 1) % perms.length];
const signNames = `${nm[0]}, ${nm[1]} and ${nm[2]}`;
const tradeLine = isTrade ? ' And since custom work and sourcing are our specialty, we can tailor anything here for your project or client.' : '';
// CONVERTED: if they BOOKED an order since we sent samples, thank them — don't chase a purchase.
const bookedPost = (Array.isArray(row.post_sample_invoices) ? row.post_sample_invoices : []).filter((i) => i.booked);
if (bookedPost.length) {
const orderRef = bookedPost.length === 1 ? ` (order #${bookedPost[0].invoice})` : '';
const tyBody =
`Hi ${greetName},\n\n` +
`${g} Thank you so much for your recent order${projRef}${orderRef} — we’re so glad it’s on its way, and we’re right here if you need anything to bring the space together.${tradeLine} If you’d like coordinating options or a quick stock check on anything else, just say the word.\n\n` +
`And if another project is on your desk, we’d love to help you source or customize whatever you need. ${q}\n\n` +
`${signNames} — Designer Wallcoverings\n` +
`designerwallcoverings.com · 1-888-373-4564\n\n` +
`P.S. If you’d like to hop on a quick call, just let us know a good time.`;
return { subject: 'Thank you for your order — Designer Wallcoverings', body: tyBody, engine: 'thank-you' };
}
// LIST 2 — "Quoted · No Order": the letter is about the SPECIFIC quote (number, total, items)
// and asks to move it to an order — not the generic "how did the samples go" note.
const fmtMoney = (n) => '$' + (Number(n) || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (list === 'list2') {
const quotes = (Array.isArray(row.merch_invoices) ? row.merch_invoices : []).filter((i) => !i.booked);
const qtotal = row.quote_total || quotes.reduce((s, i) => s + (Number(i.total) || 0), 0);
const qnums = quotes.map((i) => (i.invoice ? `#${i.invoice}` : '')).filter(Boolean);
const qRef = qnums.length === 1 ? ` (quote ${qnums[0]})` : (qnums.length > 1 ? ` (quotes ${qnums.slice(0, 3).join(', ')})` : '');
const qItems = quotes.map((i) => { const it = clientLabel(i.item || '', showMfr).replace(/\(\s*Ref\.?\s*\)/gi, '').replace(/\s{2,}/g, ' ').trim(); return (it && /[a-z]/i.test(it)) ? ` • ${it}${i.total ? ` — ${fmtMoney(i.total)}` : ''}` : null; }).filter(Boolean);
const totalStr = qtotal ? ` totaling ${fmtMoney(qtotal)}` : '';
const qbody =
`Hi ${greetName},\n\n` +
`${g} I wanted to follow up on the quote we put together for you${projRef}${qRef}${totalStr}. We'd love to help you bring it to life — if you're ready to move forward, we'll confirm current pricing and stock and get your order placed right away.${tradeLine}\n\n` +
(qItems.length ? `Here's what we quoted…\n\n${qItems.slice(0, 12).join('\n')}\n\n` : '') +
`If anything's changed — quantities, colorways, or timing — just let me know and I'll update it. ${q}\n\n` +
`${signNames} — Designer Wallcoverings\n` +
`designerwallcoverings.com · 1-888-373-4564\n\n` +
`P.S. Happy to hold pricing or walk through options on a quick call — just say the word.`;
return { subject: `Following up on your Designer Wallcoverings quote${qnums.length === 1 ? ` ${qnums[0]}` : ''}`, body: qbody, engine: 'quote-followup' };
}
// ~3 tight sentences: (1) follow-up + final-purchase ask, (2) witty samples/stock + we're-here-to-help
// [+trade], (3) project-details line then a closing question. Then the sample reminder + sign-off + PS.
const body =
`Hi ${greetName},\n\n` +
`${g} Just checking in on the samples we sent${projRef} — any favorites so far, and when are you thinking of making your final purchase? ${wit} We’re here to help however we can.${tradeLine}\n\n` +
`If a sample has been specified for a project, please let us know the Project Name and city/state as well as who will be purchasing the item. ${q}\n\n` +
(sampleItems.length ? `Here’s a quick reminder of your recent samples…\n\n${sampleItems.slice(0, 12).join('\n')}\n\n` : '') +
`${signNames} — Designer Wallcoverings\n` +
`designerwallcoverings.com · 1-888-373-4564\n\n` +
`P.S. If you’d like to hop on a quick call, just let us know a good time.`;
return { subject: 'Following up on your Designer Wallcoverings samples', body, engine: 'deterministic-v2' };
}
app.post('/api/letter', async (req, res) => {
try {
const { list, row, seed = 1, showMfr = false } = req.body || {};
if (!list || !row) return res.status(400).json({ error: 'list + row required' });
const ctx = list === 'list3' ? { found: false } : await georgeContext(row.email);
let result;
if (list === 'list3') {
result = composeDeterministic(list, row, ctx, seed, showMfr); // vendor note stays deterministic
} else {
// Deterministic-primary: Steve's client-letter spec has too many hard rules
// (rotating sign-off, exact project line, PS, phone/URL, no new-year, no mfr#,
// sample list, end-with-question) to trust a small local model to hit every time.
result = composeDeterministic(list, row, ctx, seed, showMfr);
}
res.json({ subject: result.subject, body: result.body, context: ctx, variant: seed, engine: result.engine, engineNote: result.engineNote, cost: '$0 (local + George read)' });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Plain letter → simple HTML for sending (George sends text/html). Keeps the textarea
// clean plain-text for editing, but the delivered email gets a real href on the domain +
// a tel: link on the phone, and newlines become <br>. Everything is HTML-escaped first.
const _esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
// A thumbnail + product-link gallery for the "recent samples" block (email-safe <table>).
function sampleGallery(samples) {
const rows = samples.slice(0, 12).map((s) => {
const label = _esc(s.label || s.sku || 'sample');
const date = s.date ? `<span style="color:#999"> · sent ${_esc(new Date(s.date).toLocaleDateString())}</span>` : '';
const img = s.image
? `<img src="${_esc(s.image)}" width="48" height="48" alt="" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid #e2ddd4;display:block">`
: `<div style="width:48px;height:48px;background:#efece6;border-radius:6px"></div>`;
const name = s.url ? `<a href="${_esc(s.url)}" style="color:#8a6d3b;text-decoration:none;font-weight:600">${label}</a>` : `<span style="font-weight:600">${label}</span>`;
return `<tr><td style="padding:5px 12px 5px 0;vertical-align:middle">${img}</td><td style="padding:5px 0;vertical-align:middle;font-size:14px;color:#2b2b2b">${name}${date}</td></tr>`;
}).join('');
return `<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;margin:2px 0 14px">${rows}</table>`;
}
// Plain letter → styled HTML. If `samples` (with url/image) are supplied, the "• sample" block
// is replaced by a thumbnail+link gallery; otherwise it renders as a plain bulleted list.
function bodyToHtml(text, samples) {
const link = (s) => s
.replace(/\bdesignerwallcoverings\.com\b/gi, '<a href="https://designerwallcoverings.com" style="color:#8a6d3b;text-decoration:underline">designerwallcoverings.com</a>')
.replace(/1-888-373-4564/g, '<a href="tel:+18883734564" style="color:#8a6d3b;text-decoration:none">1-888-373-4564</a>');
const gallery = Array.isArray(samples) && samples.length ? samples : null;
const paras = String(text).split(/\n{2,}/).map((p) => {
const lines = p.split('\n');
if (lines.some((l) => /^\s*•/.test(l))) {
if (gallery) return sampleGallery(gallery);
const items = lines.filter((l) => l.trim()).map((l) => `<li style="margin:2px 0">${link(_esc(l.replace(/^\s*•\s*/, '')))}</li>`).join('');
return `<ul style="margin:2px 0 14px;padding-left:20px;color:#555">${items}</ul>`;
}
const isSig = /designerwallcoverings\.com/.test(p) || /^P\.S\./.test(p.trim());
const style = isSig ? 'margin:0 0 10px;color:#555;font-size:14px' : 'margin:0 0 13px';
return `<p style="${style}">${link(_esc(p)).replace(/\n/g, '<br>')}</p>`;
}).join('');
// CAN-SPAM footer: valid physical postal address + a working unsubscribe mechanism.
const footer = `<div style="margin-top:20px;padding-top:10px;border-top:1px solid #e5e0d8;font-size:11px;color:#9a948a;line-height:1.55;font-family:Georgia,serif">`
+ `Designer Wallcoverings · 15442 Ventura Blvd, #102, Sherman Oaks, CA 91403 · 1-888-373-4564<br>`
+ `You're receiving this because you requested samples from Designer Wallcoverings. `
+ `To stop receiving these, reply with <strong>UNSUBSCRIBE</strong> and we'll remove you.`
+ `</div>`;
return `<div style="font-family:Georgia,'Times New Roman',serif;font-size:15px;line-height:1.6;color:#2b2b2b;max-width:600px">${paras}${footer}</div>`;
}
// GET /api/lastcorr?email= — newest email we exchanged with this address (George read-only).
// Lazy per-card lookup (fired only when a card's details is expanded) so we never fan 600+
// George searches at build time. Reuses georgeContext (info@ then steve-office).
app.get('/api/lastcorr', async (req, res) => {
try {
const email = String(req.query.email || '').trim();
if (!email) return res.json({ found: false });
const ctx = await georgeContext(email);
res.json({ found: !!ctx.found, date: ctx.date || '', subject: ctx.subject || '', account: ctx.account || '' });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ALL emails exchanged with a client address (both directions), newest first — George read-only.
// Deduped across the info@ + steve-office mailboxes; direction inferred from the From header.
async function georgeEmails(email, limit = 25) {
if (!email) return [];
const q = encodeURIComponent(`from:${email} OR to:${email}`);
const out = [], seen = new Set(), lc = String(email).toLowerCase();
for (const account of ['info', 'steve-office']) {
try {
const r = await fetch(`${GEORGE}/api/search?q=${q}&account=${account}&maxResults=${limit}`, {
headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(10000),
});
if (!r.ok) continue;
const j = await r.json();
for (const m of (j.messages || [])) {
if (!m.id || seen.has(m.id)) continue; seen.add(m.id);
const from = String(m.from || '');
out.push({ id: m.id, date: m.date || '', subject: m.subject || '', snippet: String(m.snippet || '').slice(0, 180), from, mailbox: account, dir: from.toLowerCase().includes(lc) ? 'in' : 'out' });
}
} catch { /* try next mailbox */ }
}
out.sort((a, b) => (Date.parse(b.date) || 0) - (Date.parse(a.date) || 0));
return out.slice(0, limit);
}
// GET /api/emails?email= — full recent email history with one client (lazy, per-drawer).
app.get('/api/emails', async (req, res) => {
try {
const email = String(req.query.email || '').trim();
if (!email) return res.json({ email: '', count: 0, emails: [] });
const emails = await georgeEmails(email, Math.min(50, Number(req.query.limit) || 25));
res.json({ email, count: emails.length, emails });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Persistent follow-up cache so the CARDS can show a Yes/No at a glance without re-running the
// 8s local-LLM analysis every load. Filled as clients get analyzed (drawer opens + batch triage).
const FU_CACHE_FILE = path.join(DATA_DIR, 'followup-cache.json');
let FU_CACHE = {};
try { FU_CACHE = JSON.parse(fs.readFileSync(FU_CACHE_FILE, 'utf8')) || {}; } catch { FU_CACHE = {}; }
let _fuSaveTimer = null;
function fuSave() { if (_fuSaveTimer) return; _fuSaveTimer = setTimeout(() => { _fuSaveTimer = null; try { fs.writeFileSync(FU_CACHE_FILE, JSON.stringify(FU_CACHE)); } catch { /* best effort */ } }, 1500); }
// Read the client's recent thread + decide whether to follow up, with operational notes.
// Local LLM (Ollama) → $0. maxAgeMs>0 returns a cached verdict if fresh enough (skips the LLM).
async function analyzeFollowup(email, context = {}, maxAgeMs = 0) {
const key = String(email).toLowerCase();
if (maxAgeMs > 0) { const c = FU_CACHE[key]; if (c && Date.now() - (c.at || 0) < maxAgeMs) return c; }
const emails = await georgeEmails(email, 12);
if (!emails.length) { const r = { followUp: 'unknown', reason: 'no email history with this client', notes: [], deadline: '', count: 0, at: Date.now() }; FU_CACHE[key] = r; fuSave(); return r; }
const today = new Date().toISOString().slice(0, 10);
const thread = emails.map((m) => `[${m.dir === 'in' ? 'CLIENT→us' : 'us→CLIENT'} ${String(m.date).slice(0, 16)}] ${m.subject} :: ${m.snippet}`).join('\n');
const sys = `You are a sales follow-up triage assistant for Designer Wallcoverings. Given recent email correspondence with a client, decide whether the sales team should PROACTIVELY FOLLOW UP with the client now, and extract operational notes. Today is ${today}. Return STRICT JSON: {"followUp":"yes"|"no","reason":"<=12 words","notes":["short bullet"],"deadline":"YYYY-MM-DD or empty"}.
Decide "yes" (nudge the client) when: the client asked something we haven't answered; we sent a quote/samples and they went quiet; we promised an update and time passed; a deadline is near; or an open thread has gone cold.
Decide "no" when: the ball is clearly in OUR court and we acted very recently; the client just replied and we owe the next step within normal time; an order was just placed or shipped (no chase needed); or they declined/unsubscribed.
notes: 2-4 terse, concrete bullets on delays, deadlines, promises made, or current status. No fluff.`;
const usr = `Client: ${context.company || email} <${email}>${context.quote ? ` — has an OPEN QUOTE ${context.quote} with no order` : ''}\nCorrespondence (newest first):\n${thread}`;
try {
const r = await fetch(`${OLLAMA}/api/chat`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: LETTER_MODEL, stream: false, format: 'json', keep_alive: '30m', options: { temperature: 0.2 }, messages: [{ role: 'system', content: sys }, { role: 'user', content: usr }] }),
signal: AbortSignal.timeout(90000),
});
if (!r.ok) throw new Error(`ollama ${r.status}`);
const out = JSON.parse((await r.json()).message?.content || '{}');
const decision = /^y/i.test(String(out.followUp || '')) ? 'yes' : (/^n/i.test(String(out.followUp || '')) ? 'no' : 'unknown');
const result = {
followUp: decision,
reason: String(out.reason || '').replace(/\s+/g, ' ').slice(0, 160),
notes: Array.isArray(out.notes) ? out.notes.map((n) => String(n).replace(/\s+/g, ' ').slice(0, 160)).filter(Boolean).slice(0, 5) : [],
deadline: (String(out.deadline || '').match(/\d{4}-\d{2}-\d{2}/) || [''])[0], // real date or nothing (LLM sometimes echoes "or empty")
count: emails.length,
at: Date.now(),
};
FU_CACHE[key] = result; fuSave();
return result;
} catch (e) {
// don't cache transient failures — leave any prior cached verdict intact
return { followUp: 'unknown', reason: 'analysis unavailable', notes: [], deadline: '', count: emails.length, error: e.message };
}
}
// GET /api/followup-analysis?email=&company="e=&force= — Yes/No + notes (local $0).
// Returns a cached verdict if <6h old unless force=1 (the drawer's ↻ button).
app.get('/api/followup-analysis', async (req, res) => {
try {
const email = String(req.query.email || '').trim();
if (!email) return res.json({ followUp: 'unknown', reason: 'no email on file', notes: [] });
const maxAge = req.query.force === '1' ? 0 : 6 * 3600e3;
res.json(await analyzeFollowup(email, { company: String(req.query.company || ''), quote: String(req.query.quote || '') }, maxAge));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// GET /api/followup-cache — the lightweight decision map for the CARD badges (no LLM run).
app.get('/api/followup-cache', (_req, res) => {
const map = {};
for (const [k, v] of Object.entries(FU_CACHE)) map[k] = { followUp: v.followUp, reason: v.reason, deadline: v.deadline || '', at: v.at || 0 };
res.json({ count: Object.keys(map).length, map });
});
// POST /api/followup-batch {clients:[{email,company,quote}]} — triage a batch (e.g. one column),
// skipping any verdict fresh <24h. Bounded fan-out with small concurrency so Ollama isn't swamped.
app.post('/api/followup-batch', async (req, res) => {
try {
const clients = (Array.isArray(req.body?.clients) ? req.body.clients : []).filter((c) => c && c.email).slice(0, 80);
const DAY = 24 * 3600e3, CONC = 3, out = {};
let i = 0;
async function worker() {
while (i < clients.length) {
const c = clients[i++];
try { const v = await analyzeFollowup(String(c.email).trim(), { company: c.company || '', quote: c.quote || '' }, DAY); out[String(c.email).toLowerCase()] = { followUp: v.followUp, reason: v.reason, deadline: v.deadline || '', at: v.at || Date.now() }; }
catch { /* skip one bad client */ }
}
}
await Promise.all(Array.from({ length: Math.min(CONC, clients.length) }, worker));
res.json({ analyzed: Object.keys(out).length, map: out });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// GET /api/email?id=&account= — one full message, HTML stripped to readable text (expand-in-place).
app.get('/api/email', async (req, res) => {
try {
const id = String(req.query.id || '').trim();
const account = String(req.query.account || 'info').trim();
if (!id) return res.status(400).json({ error: 'id required' });
const r = await fetch(`${GEORGE}/api/messages/${encodeURIComponent(id)}?account=${encodeURIComponent(account)}`, {
headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(12000),
});
if (!r.ok) return res.status(r.status).json({ error: `George ${r.status}` });
const m = await r.json();
const text = String(m.body || m.snippet || '')
.replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|tr|li|h[1-6])>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>').replace(/'|'/gi, "'").replace(/"/gi, '"')
.replace(/\n{3,}/g, '\n\n').replace(/[ \t]{2,}/g, ' ').trim();
res.json({ id, subject: m.subject || '', from: m.from || '', to: m.to || '', date: m.date || '', text: text.slice(0, 8000) });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// POST /api/send — actually EMAIL a generated letter, FROM info@designerwallcoverings.com,
// via George's /api/send. Best-effort: reply into the client's last info@ thread so it lands
// as a follow-up, not a cold email. George applies its own external-send guard; if it blocks,
// we pass that back verbatim. Each send is a deliberate per-recipient click in the UI.
app.post('/api/send', async (req, res) => {
try {
// TUNNEL-ORIGIN SEND GUARD (contrarian FIX FIRST #1): this endpoint emails as
// info@designerwallcoverings.com with the George external-send token auto-attached.
// When reached through the PUBLIC cloudflared tunnel, cloudflared stamps the request
// with cf-connecting-ip / x-forwarded-for. Refuse the actual send for those — remote
// users may view + draft, but firing client email is restricted to a direct
// local-loopback operator (someone physically at this Mac). Basic Auth alone is not a
// strong enough gate to hand the internet a send-as-info@ button.
// Anchor on the UNFORGEABLE socket origin, not on client-settable headers (contrarian
// Hole #1): only a direct local-loopback connection may fire a send. Tunnel requests
// arrive from cloudflared (non-loopback socket + cf-connecting-ip); loopback callers
// never carry those proxy headers. Header check kept as belt-and-suspenders.
const sendRemote = req.socket && req.socket.remoteAddress;
if (!LOOPBACK.has(sendRemote) || req.get('cf-connecting-ip') || req.get('x-forwarded-for')) {
return res.status(403).json({ error: 'Sending is disabled over the public tunnel. Open this tool locally to send — drafting and preview still work remotely.', tunnelBlocked: true });
}
const { to, subject, body, samples, list, account: acctNo, company } = req.body || {};
if (!to || !subject || !body) return res.status(400).json({ error: 'to, subject and body are required' });
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(to).trim())) return res.status(400).json({ error: `not a valid recipient email: ${to}` });
// SUPPRESSION: never email someone who unsubscribed (CAN-SPAM — honored, not overridable).
if (loadSuppress().has(String(to).trim().toLowerCase())) return res.status(403).json({ error: 'recipient has unsubscribed — not sending', suppressed: true });
// FREQUENCY GUARD: the same client can appear in both list1 and list2, so guard against
// double-emailing — block a repeat send to the same address within 30 days unless force:true.
try {
if (!req.body.force && fs.existsSync(SENT_LOG)) {
const cutoff = Date.now() - 30 * 86400000, toLC = String(to).trim().toLowerCase();
for (const l of fs.readFileSync(SENT_LOG, 'utf8').split('\n')) {
if (!l.trim()) continue;
try { const o = JSON.parse(l); if (String(o.to || '').toLowerCase() === toLC && Date.parse(o.ts) > cutoff) {
return res.status(409).json({ error: `already emailed ${new Date(o.ts).toLocaleDateString()} (within 30 days) — use “Send anyway” to override`, alreadySent: true, lastSent: o.ts });
} } catch { /* skip bad line */ }
}
}
} catch { /* guard must never crash a send */ }
// Best-effort: find the newest message in the info@ mailbox for this address to thread into.
let replyToMessageId = null;
try {
const q = encodeURIComponent(`from:${to} OR to:${to}`);
const r = await fetch(`${GEORGE}/api/search?q=${q}&account=info&maxResults=1`, {
headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(8000),
});
if (r.ok) { const j = await r.json(); const m = (j.messages || [])[0]; if (m && m.id) replyToMessageId = m.id; }
} catch { /* thread best-effort only — fall through to a fresh send */ }
// no_source_tag: DO NOT stamp George's internal "From job: dw-pitch-followup" banner on
// client-facing email — that's internal tooling info. We keep our own record in sent-log.jsonl.
const payload = { account: 'info', from: 'info@designerwallcoverings.com', to: String(to).trim(), subject, body: bodyToHtml(body, samples), no_source_tag: true };
if (replyToMessageId) payload.replyToMessageId = replyToMessageId;
const headers = { 'Content-Type': 'application/json', Authorization: GEORGE_AUTH };
if (GEORGE_EXT_SEND_TOKEN) headers['X-Send-Approval'] = GEORGE_EXT_SEND_TOKEN; // human-approved external send
const r = await fetch(`${GEORGE}/api/send`, {
method: 'POST', headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(20000),
});
const j = await r.json().catch(() => ({}));
if (!r.ok || j.error) return res.status(r.status === 403 ? 403 : (r.status || 500)).json({ error: j.error || 'send failed', blocked: !!j.blocked, external: j.external });
// KEEP a permanent record of every email we send (append-only log; the full letter too).
const sentAt = new Date().toISOString();
try {
fs.appendFileSync(SENT_LOG, JSON.stringify({ ts: sentAt, to: String(to).trim(), subject, list: list || '', account: acctNo || '', company: company || '', messageId: j.messageId || '', threadId: j.threadId || '', inThread: !!(j.inThread || replyToMessageId), body }) + '\n');
_sentCache.at = 0; // invalidate so the Sent log + chips show this send immediately
} catch (e) { /* logging must never block a successful send */ }
res.json({ success: true, account: 'info', messageId: j.messageId, threadId: j.threadId, inThread: !!(j.inThread || replyToMessageId), sentAt, cost: '$0 (Gmail relay via George)' });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// The local append-only record of every letter sent THROUGH this tool.
function readSentLog() {
if (!fs.existsSync(SENT_LOG)) return [];
return fs.readFileSync(SENT_LOG, 'utf8').split('\n').filter((l) => l.trim())
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
}
// LIVE view of follow-up letters actually sent from info@ (Gmail Sent), scoped to our
// follow-up subject — so the Sent log + per-card chips reflect reality even for sends made
// outside this tool instance (the local jsonl only sees sends fired from THIS process).
async function georgeSentLetters(maxResults = 40) {
try {
const q = encodeURIComponent('in:sent subject:(Designer Wallcoverings samples)');
const r = await fetch(`${GEORGE}/api/search?q=${q}&account=info&maxResults=${maxResults}`, {
headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(10000),
});
if (!r.ok) return [];
const ids = ((await r.json()).messages || []).map((m) => m.id).filter(Boolean).slice(0, maxResults);
// Gmail search omits the recipient; fetch the full message (has `to`) for each — parallel, bounded.
const msgs = await Promise.all(ids.map((id) =>
fetch(`${GEORGE}/api/messages/${id}?account=info`, { headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(8000) })
.then((x) => (x.ok ? x.json() : null)).catch(() => null)));
return msgs.filter(Boolean).map((m) => {
const toM = String(m.to || '').match(/[\w.+-]+@[\w.-]+\.\w+/);
let ts = ''; try { ts = m.date ? new Date(m.date).toISOString() : ''; } catch { ts = ''; }
return { ts, to: toM ? toM[0].toLowerCase() : '', subject: m.subject || '', messageId: m.id || '', inThread: /^\s*re:/i.test(m.subject || ''), live: true };
}).filter((x) => x.ts && x.to);
} catch { return []; }
}
// Merge local tool log + live Gmail Sent; dedup by recipient+day; newest first. 60s cache so
// a page load (sent-index) + a log open don't each pay the George round-trip.
let _sentCache = { at: 0, data: null };
async function collectSent() {
if (_sentCache.data && Date.now() - _sentCache.at < 60000) return _sentCache.data;
const local = readSentLog().map((o) => ({ ...o, to: String(o.to || '').toLowerCase() }));
const live = await georgeSentLetters(60); // 60 newest from Gmail; the local log covers history
const key = (x) => `${x.to}|${String(x.ts).slice(0, 10)}`;
const map = new Map();
for (const x of live) map.set(key(x), x);
for (const x of local) {
const k = key(x), cur = map.get(k);
if (cur) map.set(k, { ...cur, company: x.company || cur.company, list: x.list || cur.list, subject: x.subject || cur.subject, inThread: x.inThread || cur.inThread });
else map.set(k, x);
}
const data = [...map.values()].sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
_sentCache = { at: Date.now(), data };
return data;
}
// GET /api/sent — every follow-up email sent (LIVE: local log ∪ Gmail Sent), newest first.
app.get('/api/sent', async (_req, res) => {
try {
const sent = await collectSent();
res.json({ count: sent.length, sent: sent.slice(0, 800), live: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// GET /api/sent-index — { emailLowercased → newest sent ISO } for the per-card "emailed" chip.
app.get('/api/sent-index', async (_req, res) => {
try {
const sent = await collectSent();
const idx = {};
for (const s of sent) { const t = s.to; if (t && (!idx[t] || String(s.ts) > String(idx[t]))) idx[t] = s.ts; }
res.json({ count: Object.keys(idx).length, idx });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// GET /api/suppress — the unsubscribe list. POST {email} — add one manually.
app.get('/api/suppress', (_req, res) => res.json({ suppressed: [...loadSuppress()] }));
app.post('/api/suppress', (req, res) => {
const email = String(req.body?.email || '').trim();
if (!email) return res.status(400).json({ error: 'email required' });
const result = addSuppress([email]);
// Audit trail (contrarian Hole #2): manual suppression is a mutating action any authenticated
// user can reach — record who/when so a silent suppress of a real buyer is traceable.
try { fs.appendFileSync(path.join(DATA_DIR, 'suppress-log.jsonl'), JSON.stringify({ ts: new Date().toISOString(), email: email.toLowerCase(), ip: clientIp(req), added: result.added }) + '\n'); } catch {}
res.json(result);
});
// GET /api/scan-unsubscribes — find clients who replied "UNSUBSCRIBE" to info@ and suppress them.
// Honors CAN-SPAM opt-outs. Read-only against Gmail; only writes the suppression list.
app.get('/api/scan-unsubscribes', async (req, res) => {
try {
// Only messages sent TO us (replies), containing genuine opt-out language — NOT the
// "unsubscribe" link that rides in every marketing email we merely receive.
const q = encodeURIComponent('to:info@designerwallcoverings.com ("unsubscribe" OR "remove me" OR "opt out" OR "opt-out" OR "take me off" OR "stop emailing") newer_than:90d');
const r = await fetch(`${GEORGE}/api/search?q=${q}&account=info&maxResults=50`, {
headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(10000),
});
if (!r.ok) return res.status(502).json({ error: `george ${r.status}` });
const j = await r.json();
// Strong signal only: the opt-out phrase appears at the START of the reply (first ~70 chars),
// i.e. it's the point of the message — not a word buried in a forwarded thread or signature.
const OPT = /^(re:\s*)?[\s"']*(unsubscribe|remove me|please remove|remove us|opt[- ]?out|take me off|stop emailing|stop sending|no thank)/i;
const emails = [];
for (const m of (j.messages || [])) {
const from = (m.from || '').match(/[\w.+-]+@[\w.-]+\.\w+/);
const snip = (m.snippet || '').slice(0, 70);
if (from && !/designerwallcoverings\.com/i.test(from[0]) && (OPT.test(snip) || OPT.test(m.subject || ''))) emails.push(from[0]);
}
const uniq = [...new Set(emails)];
// ?apply=1 suppresses them; default is a DRY report so a false match never silently drops a client.
const result = req.query.apply ? addSuppress(uniq) : { added: 0, dryRun: true };
res.json({ scanned: (j.messages || []).length, candidates: uniq, ...result });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ---- FileMaker Internal notes (the one deliberate FM write path in this tool) ----
// GET /api/notes?account=N → { account, invoice, notes } — the Invoice::Internal notes
// shown on the client's Projects screen in FM Pro.
// POST /api/notes {account, note} → prepends "MM/DD/YYYY - note" to that field (rep 1).
// lib/fm-notes.js is lazy-loaded so a missing FM cred can't take the whole viewer down.
let fmNotesMod = null;
async function fmNotes() { if (!fmNotesMod) fmNotesMod = await import('./lib/fm-notes.js'); return fmNotesMod; }
app.get('/api/notes', async (req, res) => {
try {
const account = String(req.query.account || '').trim();
if (!/^\d+$/.test(account)) return res.status(400).json({ error: 'account required' });
const out = await (await fmNotes()).getNotes(account);
if (!out) return res.status(404).json({ error: `no client record for account ${account}` });
res.json({ account: out.account, invoice: out.invoice, notes: out.notes, extraReps: out.extraReps || [] });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/notes', async (req, res) => {
try {
const account = String(req.body?.account || '').trim();
const note = String(req.body?.note || '').trim();
if (!/^\d+$/.test(account)) return res.status(400).json({ error: 'account required' });
if (!note) return res.status(400).json({ error: 'note required' });
if (note.length > 2000) return res.status(400).json({ error: 'note too long (2000 max)' });
const out = await (await fmNotes()).addNote(account, note);
notesCache.set(account, { ts: Date.now(), data: { invoice: out.invoice, notes: out.notes } }); // keep the chip fresh
// Audit trail — every FM write from this tool is traceable (who/when/what).
try { fs.appendFileSync(path.join(DATA_DIR, 'notes-log.jsonl'), JSON.stringify({ ts: new Date().toISOString(), account, invoice: out.invoice, ip: clientIp(req), added: out.added }) + '\n'); } catch {}
res.json({ account: out.account, invoice: out.invoice, notes: out.notes, added: out.added });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// POST /api/notes-batch {accounts:[...]} → { "<acct>": {invoice, notes} } — feeds the
// per-card invoice#+note line. Cached 10 min per account so scrolling doesn't re-hit FM.
const notesCache = new Map(); // account -> { ts, data }
const NOTES_TTL = 10 * 60 * 1000;
app.post('/api/notes-batch', async (req, res) => {
try {
const accounts = [...new Set((Array.isArray(req.body?.accounts) ? req.body.accounts : [])
.map((a) => String(a).trim()).filter((a) => /^\d+$/.test(a)))].slice(0, 200);
if (!accounts.length) return res.json({});
const out = {}; const misses = [];
const now = Date.now();
for (const a of accounts) {
const hit = notesCache.get(a);
if (hit && now - hit.ts < NOTES_TTL) out[a] = hit.data; else misses.push(a);
}
if (misses.length) {
const fresh = await (await fmNotes()).getNotesBatch(misses);
for (const a of misses) {
const data = fresh[a] || { invoice: null, notes: '' };
notesCache.set(a, { ts: now, data });
out[a] = data;
}
if (notesCache.size > 5000) { // prune oldest so the cache can't grow unbounded
for (const [k, v] of notesCache) { if (now - v.ts > NOTES_TTL) notesCache.delete(k); }
}
}
res.json(out);
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.listen(PORT, '127.0.0.1', () => {
console.log(`[dw-pitch-followup] listening on http://127.0.0.1:${PORT} (auth ${BASIC_AUTH.split(':')[0]} / …) letters via ${LETTER_MODEL}`);
});