← back to Sample Followup Sweep
scripts/watch-sent-stamp.mjs
182 lines
#!/usr/bin/env node
// FAST Sent-poller (Steve 8/20): watch info@ Sent for sample follow-ups that ACTUALLY LEFT, and
// stamp FileMaker "Date Email Sent to Vendor after 10 Days" (the chase date) on that
// vendor's outstanding memos. Drafting never stamps — only a real send does. Idempotent via a ledger.
// node scripts/watch-sent-stamp.mjs # one pass
// node scripts/watch-sent-stamp.mjs --loop # poll every POLL_SECS (default 45s)
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import http from 'node:http';
const ROOT = join(homedir(), 'Projects/sample-followup-sweep');
const LEDGER = join(ROOT, 'data/stamp-ledger.json');
import { chaseWindow, buildEmailVid, runPass, buildHeartbeat, failHeartbeat } from './sent-stamp-core.mjs';
// TK-12351: fleet-health heartbeat (fleet-health-rollup globs ~/.claude/skills/*/data/latest.json).
// Written only by a LIVE pass, after its ledger write. A --dry pass prints what it would say and
// never writes, so a dry/manual run can never masquerade as the live watcher's heartbeat.
const HB_DIR = join(homedir(), '.claude/skills/sample-followup/data');
const HB = join(HB_DIR, 'latest.json');
const NEAR_DAYS = 5;
import slugAliases from '../lib/slug-aliases.cjs';
const DB = 'WALLPAPER', LAYOUT = 'Report for old memo samples';
const FIELD_SENT = 'Date Email Sent to Vendor after 10 Days';
// The 2nd-request date field. TK-11409: this poller only ever makes a FIRST stamp, so it no longer
// writes this field — pass 2 of fmpro.mjs owns it. Kept here for reference//future pass-2 support.
const FIELD_2ND_DATE = 'Date Sample Request Letter Sent';
const POLL_SECS = Number(process.env.POLL_SECS || 30);
// Steve 8/25: match on the ACCOUNT # in the subject ("…(Acct NNNNN)") — works for EVERY vendor with
// zero hardcoded map, and covers follow-ups sent by hand from Gmail (not just the console/scheduler).
const MIN_AGE_DAYS = 10; // only chaseable memos (>=10d) — matches the letter contents
const MAX_AGE_DAYS = Number(process.env.MAX_AGE) || 60; // >60 = dead lead; widen (e.g. 100) to BACKFILL older chases
// TK-12320: 21d so a send whose stamp was skipped/zero (mapping missing or wrong at the time) keeps
// being retried for three weeks — long enough to span the 3-business-day resend cadence twice.
const SEARCH_DAYS = Number(process.env.SEARCH_DAYS) || 21;
const RETRY_SECS = Number(process.env.RETRY_SECS) || 900; // re-examine a non-terminal send at most every 15 min
const PAGE_SIZE = 50, MAX_PAGES = 10;
// TK-12320: a pass with no flags writes live, so an unknown flag (e.g. --help) must not fall through to one.
const badArgs = process.argv.slice(2).filter((a) => !['--loop', '--dry'].includes(a));
if (badArgs.length) { console.error(`unknown arg(s): ${badArgs.join(' ')}\nusage: watch-sent-stamp.mjs [--dry] [--loop] (no --dry = LIVE FileMaker stamps)`); process.exit(2); }
const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
const fenv = cfg?.mcpServers?.filemaker?.env || {};
for (const k of ['FM_CLOUD_HOST', 'FM_CLARIS_EMAIL', 'FM_CLARIS_PASSWORD']) process.env[k] = fenv[k];
const DRY_RUN = process.argv.includes('--dry') || process.env.DRY_RUN === '1';
process.env.FM_READONLY = DRY_RUN ? '1' : '0';
const fm = await import('/Users/macstudio3/Projects/filemaker-mcp/src/fm-client.js');
const readJSON = (f, d) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return d; } };
// George /api/messages (HTTP) — find follow-ups that actually left info@. Paged so a busy window
// is never silently truncated at one page.
const genv = k => { for (const f of ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-MCP/.env', '/Users/macstudio3/Projects/george-gmail/.env']) { try { const m = readFileSync(f, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {} } return ''; };
let auth = genv('GEORGE_BASIC_AUTH'); if (!auth.includes(':')) auth = 'admin:' + genv('GEORGE_BASIC_AUTH_PASS');
auth = 'Basic ' + Buffer.from(auth).toString('base64');
function gjson(path) { return new Promise(res => { const rq = http.request({ host: '127.0.0.1', port: 9850, path, method: 'GET', headers: { Authorization: auth } }, r => { let d = ''; r.on('data', x => d += x); r.on('end', () => { if (r.statusCode < 200 || r.statusCode >= 300) return res({ __err: `HTTP ${r.statusCode}` }); try { res(JSON.parse(d)); } catch { res({ __err: 'bad JSON' }); } }); }); rq.on('error', e => res({ __err: e.message })); rq.end(); }); }
async function gsearch(q) {
const out = []; let token = '';
for (let page = 0; page < MAX_PAGES; page++) {
const j = await gjson(`/api/messages?account=info&maxResults=${PAGE_SIZE}&q=${encodeURIComponent(q)}${token ? '&pageToken=' + encodeURIComponent(token) : ''}`);
if (j.__err) return { msgs: out, err: j.__err };
out.push(...(j.messages || []));
if (!j.nextPageToken) return { msgs: out };
token = j.nextPageToken;
}
return { msgs: out, err: `truncated at ${MAX_PAGES} pages` };
}
async function gbody(id) { const j = await gjson(`/api/messages/${id}?account=info`); if (j.__err) { console.warn(` ⚠ George body fetch ${id}: ${j.__err}`); return null; } return j.body || ''; }
// Steve 9/01 FIX: identify the vendor by the follow-up's RECIPIENT EMAIL → vid, NOT the subject
// account #. One vendor email can map to MULTIPLE vid variants (Thibaut THI/THIB, Osborne DGD/OSB).
// Rebuilt every pass so a contacts.json mapping fix takes effect without restarting the 24/7 loop.
const loadEmailVid = () => buildEmailVid(readJSON(join(ROOT, 'data/fleet.json'), { vendors: [] }).vendors, readJSON(join(ROOT, 'data/contacts.json'), {}), { slugAliases: slugAliases.SLUG_ALIASES, slugForVid: slugAliases.slugForVid });
// Stamp a vendor's outstanding, NOT-YET-STAMPED memos by VID. Set-difference over recordId (stable
// across layouts — the proven scheduled-run pattern): layout A 'REPORT ON SAMPLES ORDERED' has vid;
// layout B 'Report for old memo samples' has the stamp fields. Idempotent (already-stamped excluded).
// FileMaker answers "no records match" with error 401 — that is an empty result, not a failure.
async function findAll(layout, query, limit) {
try { return (await fm.findRecords(DB, layout, query, { limit })).records; }
catch (e) { if (e.fmCode === '401') return []; throw e; }
}
const dryStamped = new Set();
async function stampVids(vids, bodyText, win, sentOn) {
// PRECISE scope: stamp a vendor record ONLY if its Manufacturer # actually appears in the SENT
// letter body. This is immune to shared-desk aliasing (Christian Lacroix via Osborne's desk, Anna
// French via Thibaut's desk) — a sibling vid's item is stamped only if it was really in the letter.
const bodyNorm = String(bodyText || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').toLowerCase();
const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
// A failed find must never look like "nothing here": without the already-stamped set we could
// overwrite an existing chase date, so a B-side error aborts this send (retried next time).
let Bs;
try { Bs = await findAll(LAYOUT, { [FIELD_SENT]: '*', 'Date WP Sample Sent': '=', 'today for client': win }, 800); }
catch (e) { console.warn(` ⚠ FM already-stamped lookup failed (${win}): ${e.message}`); return { stamped: [], failed: 1, covered: 0 }; }
if (Bs.length >= 800) { console.warn(` ⚠ already-stamped lookup hit its 800 cap (${win}) — not stamping this send`); return { stamped: [], failed: 1, covered: 0 }; }
const already = new Set(Bs.map(r => String(r.recordId)));
const stamped = [], done = new Set(); let failed = 0, covered = 0;
for (const id of dryStamped) already.add(id); // dry: mirror what earlier sends in this pass would have stamped
for (const vid of vids) {
let A;
try { A = await findAll('REPORT ON SAMPLES ORDERED', { vid: `==${vid}`, 'Date WP Sample Sent': '=', 'today for client': win }, 300); }
catch (e) { failed++; console.warn(` ⚠ FM lookup failed for vid ${vid}: ${e.message}`); continue; }
for (const r of A) {
const id = String(r.recordId); if (done.has(id)) continue;
const mfr = (r.fieldData['Mfr Pattern'] || '').trim(); if (!mfr) continue;
if (!bodyNorm.includes(norm(mfr))) continue; // only items PRINTED IN the sent letter
done.add(id);
if (already.has(id)) { covered++; continue; } // already carries a chase date — never overwrite
if (DRY_RUN) { dryStamped.add(id); stamped.push(mfr + ' [dry]'); continue; }
// Last-line guard, independent of the set above: never overwrite an existing chase date.
let cur;
try { cur = await fm.getRecord(DB, LAYOUT, r.recordId); }
catch (e) { failed++; console.warn(` ⚠ FM read failed for ${mfr} (recordId ${r.recordId}): ${e.message}`); continue; }
if (String(cur?.fieldData?.[FIELD_SENT] || '').trim()) { covered++; continue; }
// Steve 8/25 asked for BOTH fields here, and at the time that was right: the 2nd-request
// field had no separate meaning, so writing it alongside was the only way to populate it.
// TK-11409 (Steve, 2026-09-10) gave it a real meaning — "a 2nd request was sent" — and this
// block only ever runs on a FIRST stamp (records already carrying FIELD_SENT are excluded
// above via the `already` set). So writing both here pre-fills the 2nd-request slot on the
// first chase, and pass 2 then skips the record forever because that field is non-empty —
// silently defeating the very tracking Steve asked for. Writing only the chase date keeps
// the slot free for an actual 2nd request. This SERVES the 8/25 intent rather than reversing
// it; fmpro.mjs pass 1 was aligned the same way.
const res = await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: sentOn }, { dryRun: false }).catch((e) => ({ err: e.message }));
if (res.committed) stamped.push(mfr);
else { failed++; console.warn(` ⚠ FM stamp not committed for ${mfr} (recordId ${r.recordId})${res.err ? ': ' + res.err : ''}`); }
}
}
return { stamped, failed, covered };
}
function emitHeartbeat(hb) {
if (hb.dry) { console.log(` [heartbeat · dry — NOT written] ${hb.verdict}: ${hb.summary}`); return; }
try {
mkdirSync(HB_DIR, { recursive: true });
const tmp = `${HB}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify(hb, null, 2)); renameSync(tmp, HB); // atomic: rollup never reads a torn file
} catch (e) { console.warn(` ⚠ heartbeat write failed: ${e.message}`); }
}
function readLedger() {
try { return { ledger: JSON.parse(readFileSync(LEDGER, 'utf8')) }; }
catch (e) { return { ledger: {}, err: e.code === 'ENOENT' ? null : e.message }; }
}
let lastUnmappedKey = '';
let lastHeartbeat = null, lastFullMeasureAt = null;
async function pass() {
const { ledger, err: ledgerErr } = readLedger();
if (ledgerErr) {
// Fail closed: an empty stand-in ledger would re-process every send and then OVERWRITE the real
// (merely unreadable) ledger, destroying its attempt history. Report, don't act.
console.warn(` ⚠ stamp-ledger unreadable (${ledgerErr}) — skipping this pass, ledger left untouched`);
lastHeartbeat = buildHeartbeat({ ledger: {}, msgs: [], now: Date.now(), searchDays: SEARCH_DAYS, nearDays: NEAR_DAYS, ledgerErr, dry: DRY_RUN, lastFullMeasureAt });
emitHeartbeat(lastHeartbeat);
return 0;
}
// NB: the em-dash in the full subject breaks Gmail matching over HTTP — match the ASCII prefix.
const { msgs, err } = await gsearch(`in:sent subject:"Outstanding Memos" newer_than:${SEARCH_DAYS}d`);
if (err) console.warn(` ⚠ George search: ${err} (${msgs.length} msg(s) read)`);
if (process.env.DEBUG) console.log(` [debug] gsearch → ${msgs.length} msg(s); ledger has ${Object.keys(ledger).length}${DRY_RUN ? ' · DRY-RUN' : ''}`);
const r = await runPass({
msgs, ledger, now: Date.now(), dry: DRY_RUN, retrySecs: RETRY_SECS, minAgeDays: MIN_AGE_DAYS, maxAgeDays: MAX_AGE_DAYS,
deps: { emailVid: loadEmailVid(), getBody: gbody, stamp: stampVids }, log: s => console.log(s),
});
for (const x of r.results) if (x.vids) console.log(` ${DRY_RUN ? '·' : '✓'} SENT ${x.id} (${x.why}) → ${x.to} [${x.vids.join('/')}] → ${DRY_RUN ? 'WOULD stamp' : 'stamped'} ${x.stamped.length} memo(s) [${x.stamped.join(' + ')}]${x.covered ? ` · ${x.covered} already stamped` : ''}${x.failed ? ` · ${x.failed} FAILED` : ''} chaseDate=${x.sentOn}`);
const key = r.unmapped.join('|');
if (key && (DRY_RUN || key !== lastUnmappedKey)) console.log(` · no vid mapping for ${r.unmapped.length} recipient(s): ${r.unmapped.join(', ')}`);
lastUnmappedKey = key;
if (r.changed) writeFileSync(LEDGER, JSON.stringify(r.ledger, null, 2));
if (!r.acted) console.log(` (no ${DRY_RUN ? 'stampable' : 'new sent'} follow-ups) — ${new Date().toLocaleTimeString()}`);
lastHeartbeat = buildHeartbeat({ ledger: r.ledger, msgs, results: r.results, now: Date.now(), searchDays: SEARCH_DAYS, nearDays: NEAR_DAYS, searchErr: err || null, dry: DRY_RUN, lastFullMeasureAt });
if (lastHeartbeat.full_measure) lastFullMeasureAt = Date.parse(lastHeartbeat.last_full_measure_at);
emitHeartbeat(lastHeartbeat);
return r.acted;
}
const LOOP = process.argv.includes('--loop');
console.log(`watch-sent-stamp — chase window ${chaseWindow(new Date(), MIN_AGE_DAYS, MAX_AGE_DAYS)} (per-send, from its send date) · Sent scan ${SEARCH_DAYS}d — ${LOOP ? `LOOP every ${POLL_SECS}s` : 'one pass'}`);
const failed = e => { console.error('pass err:', e.message); if (!DRY_RUN) emitHeartbeat(failHeartbeat(e.message, Date.now())); };
if (!LOOP) {
try { await pass(); } catch (e) { failed(e); process.exit(1); }
if (DRY_RUN) console.log(JSON.stringify(lastHeartbeat, null, 2));
process.exit(0);
}
for (;;) { try { await pass(); } catch (e) { failed(e); } await new Promise(r => setTimeout(r, POLL_SECS * 1000)); }