← back to Sample Followup Sweep
scripts/scheduled-run.mjs
468 lines
#!/usr/bin/env node
// Scheduled follow-up sender — Tue/Wed/Thu/Fri 7:00am PT.
//
// Sends the >10-day sample follow-up letter to each vendor that has outstanding
// memos NOT yet chased, then stamps "Date Email Sent to Vendor after 10 Days" so
// the same request never goes twice. The stamp IS the dedup: a SKU chased on the
// previous run (e.g. last Friday) is already filled and is excluded here, so each
// run only sends the follow-ups that are NEW since the last run.
//
// Rules enforced:
// • entered in a rolling 3-day band ending exactly at the 10-day mark (Steve 8/20 REVISED: "10 days old exactly, for the past 3 days" = only memos that JUST crossed 10d, NOT the whole back-catalog) • Date WP Sample Sent empty (still outstanding)
// • Date Email Sent to Vendor after 10 Days EMPTY → the "do not duplicate" guard
// • vendor must have a sample email + account (else skip + report — can't send)
//
// DRY-RUN by default (prints what WOULD send). The launchd job runs it with --send.
// node scripts/scheduled-run.mjs # dry run
// node scripts/scheduled-run.mjs --send # actually send + stamp
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import slugAliases from '../lib/slug-aliases.cjs'; // TK-11255: single source of truth
const { slugForVid } = slugAliases;
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { createRequire } from 'node:module';
import http from 'node:http';
const require = createRequire(import.meta.url);
const __dir = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dir, '..');
const { compose } = require(join(ROOT, 'lib', 'compose.js'));
const { georgeRequest } = require(join(ROOT, 'lib', 'george-transport.js'));
const { suppressChase, followupWindow } = require(join(ROOT, 'lib', 'sweep.js')); // TK-12105 guard + TK-12091 shared window
const SEND = process.argv.includes('--send');
// Steve 8/20: DRAFT mode — create a Gmail draft per vendor in info@ Drafts for human review,
// instead of sending directly. The draft IS the human gate; Steve sends from Drafts.
const DRAFT = process.argv.includes('--draft');
// Steve 9/01: draft the anti-dup-SUPPRESSED vendors ANYWAY (for review) instead of skipping them.
// Each such draft's subject is flagged so Steve can see the team already emailed that vendor recently.
// Nothing sends — the draft is still the human gate, Steve's review is the real dedup. DRAFT-mode only.
// Retained as a no-op for DRAFT (which now always includes suppressed vendors, per DTD verdict C)
// so existing callers and the launchd plists keep working unchanged.
const INCLUDE_SUPPRESSED = process.argv.includes('--include-suppressed') || process.env.INCLUDE_SUPPRESSED === '1';
// Rolling anti-dup window, in days. This is the REAL rule — the old reason string said
// "already emailed since 8/15 (manual team send)", which was a stale hardcoded label that
// misled everyone into thinking it was a one-time 8/15 cutoff. It is not: it re-arms daily.
const RECENT_DAYS = Number(process.env.RECENT_DAYS) || 14;
const DB = 'WALLPAPER';
const LAYOUT = 'Report for old memo samples';
const FIELD_SENT = 'Date Email Sent to Vendor after 10 Days';
// Steve 8/25: on a real send, ALSO stamp the 2nd-request date (chosen target — no dedicated
// "2nd request date" field is exposed to the Data API; this real date field is on the same layout).
const FIELD_2ND_DATE = 'Date Sample Request Letter Sent';
const MIN_AGE_DAYS = 10;
// Steve 2026-09-22 (TK-12013/TK-12014): the ACTIVE follow-up cohort is EVERY outstanding memo aged
// [MIN_AGE_DAYS .. MAX_AGE_DAYS] inclusive — not a narrow "just crossed 10d since last run" band. The
// old auto-widen anchor only reached back to the last SUCCESSFUL run, so any older still-outstanding,
// still-unchased memo fell outside the window and was NEVER chased (and a run of missed days made the
// hole permanent). We now query the full active window every run; the FileMaker Sent-stamp set-difference
// (B) is the dedup, so re-covering already-chased memos is a no-op and a missed run day can never drop
// the older cohort. >MAX_AGE_DAYS = dead lead (Steve's rule), excluded by the lo floor.
const MAX_AGE_DAYS = Number(process.env.MAX_AGE) || 60;
// Steve 9/01 (FINAL RULE — weekday-aware catch-up): the schedule is Tue–Fri (Mon/Sat/Sun never run).
// A memo is chased on the day it turns EXACTLY 10 days old (entered today-10) — chased TODAY, not
// deferred. The only wrinkle is the weekend: Sat/Sun/Mon have no run, so their turns-10 cohorts wait
// and get folded into the following TUESDAY's single letter per vendor.
// • Wed/Thu/Fri: chase the one cohort that turns 10 today → entered today-10 (single day). (Steve:
// "Run ... to include wed, th, friday 10 days back.")
// • Tuesday: the last run was Friday and Sat/Sun/Mon didn't run → ONE letter covering Sat, Sun, Mon
// AND today (Tue) → entered band [Sat-10 .. Tue-10] = [today-13 .. today-10]. (Steve, verbatim:
// "on tuesday send letter for sat, sun, monday, and today tuesday in 1 letter to vendors.")
// General form: cover the turns-10 cohorts for the days (last-successful-run .. today] inclusive of
// today — anchored to real RUN HISTORY, not the calendar, so a missed run auto-widens the next one.
// Coverage is gapless and non-overlapping week-to-week (verified). The old code IGNORED the weekday
// and applied a flat CATCHUP_DAYS band every run — that's the bug this replaces.
// Env CATCHUP (manual override): if set, revert to the old fixed rolling band [today-10-(N-1)..today-10]
// as an explicit wide recovery net. Dedup makes any overlap safe.
const RUN_WEEKDAYS = new Set([2, 3, 4, 5]); // Tue(2) Wed(3) Thu(4) Fri(5)
const CATCHUP_DAYS = Number(process.env.CATCHUP) || 0; // 0 = use the auto-widen band (default)
// Auto-widen recovery (belt-and-suspenders, Steve 9/01): each run starts the day AFTER the newest
// entered-date the last SUCCESSFUL run covered (its report in data/runs, mode DRAFT/SEND). So a
// silently-missed Thursday is swept up by Friday automatically — no manual CATCHUP needed. In the
// healthy case this reproduces the weekday band exactly (on Tue the last success was Fri → Sat/Sun/
// Mon+Tue). Capped at MAX_CATCHUP days so a long outage can't blast the whole back-catalog; dedup
// (FileMaker Sent-stamp + draft-ledger) makes any re-covered overlap harmless.
const MAX_CATCHUP = Number(process.env.MAX_CATCHUP) || 14;
const SHIP_TO = { name: 'Designer Wallcoverings', line1: '15442 Ventura Blvd. #102', city_state_zip: 'Sherman Oaks, CA 91403', phone: '1-888-373-4564' };
// --- FileMaker client (reuse the connector; creds from ~/.claude.json) ---
function loadFmEnv() {
const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
const env = cfg?.mcpServers?.filemaker?.env || {};
for (const k of ['FM_CLOUD_HOST', 'FM_CLARIS_EMAIL', 'FM_CLARIS_PASSWORD']) process.env[k] = env[k];
process.env.FM_READONLY = '0';
}
loadFmEnv();
const fm = await import('/Users/macstudio3/Projects/filemaker-mcp/src/fm-client.js');
const pad = (n) => String(n).padStart(2, '0');
const fmtDate = (d) => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`;
// Most recent SCHEDULED run day strictly before `today` (walks back over weekend / off days).
// Cold-start fallback only — the live anchor is real run history (lastCoveredHi).
function prevScheduledRun(today) {
const d = new Date(today);
do { d.setDate(d.getDate() - 1); } while (!RUN_WEEKDAYS.has(d.getDay()));
return d;
}
const parseMDY = (s) => { const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(String(s || '').trim()); return m ? new Date(+m[3], +m[1] - 1, +m[2]) : null; };
// Newest entered-date covered by a prior SUCCESSFUL run (mode DRAFT/SEND) strictly before `today`.
// Dry-runs cover nothing (excluded), so they never advance the anchor. Returns a Date or null.
function lastCoveredHi(today) {
let dir, files;
try { dir = join(ROOT, 'data', 'runs'); files = readdirSync(dir); } catch { return null; }
const midToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
let best = null;
for (const f of files) {
if (!f.startsWith('scheduled-') || !f.endsWith('.json')) continue;
let rep; try { rep = JSON.parse(readFileSync(join(dir, f), 'utf8')); } catch { continue; }
if (rep.mode !== 'DRAFT' && rep.mode !== 'SEND') continue; // dry-runs cover nothing
const stamp = parseMDY(rep.stampDate); if (!stamp || stamp >= midToday) continue; // strictly prior
const hi = parseMDY((rep.window || '').split('...')[1]);
if (hi && (!best || hi > best)) best = hi;
}
return best;
}
// TK-12091 REGRESSION FIX (DTD panel verdict A, 2026-09-24): delegate to the SHARED followupWindow in
// lib/sweep.js — the SINGLE source of truth the coverage canary also uses, so production and audit can
// never drift again. Default = FULL active band [today-MAX_AGE_DAYS .. today-MIN_AGE_DAYS]; WIN/CATCHUP
// env overrides are honored inside followupWindow. TK-12091 had narrowed this to a day-of-week band,
// silently reopening TK-12013/14's permanent-hole bug (15 stragglers, some 56d old, incl. DWTT70793,
// never drafted). The dedup (FileMaker Sent-stamp + recordId draft-ledger) makes re-querying an
// already-chased memo a no-op, so the wide window catches stragglers without ever re-chasing.
function windowRange(today = new Date()) {
return followupWindow(today, { minAge: MIN_AGE_DAYS, maxAge: MAX_AGE_DAYS });
}
const norm = (s) => String(s || '').toLowerCase().trim();
const readJSON = (f, d) => { try { return JSON.parse(readFileSync(join(ROOT, f), 'utf8')); } catch { return d; } };
// vid -> contact (sample_email + account), built from fleet(slug↔vid) + contacts(slug→email)
function vidContactMap(liveVids = []) {
const fleet = readJSON('data/fleet.json', { vendors: [] }).vendors;
const contacts = readJSON('data/contacts.json', {});
const map = {};
// TK-11255: fleet.json does DOUBLE DUTY -- it is both the outstanding-items
// snapshot (which legitimately shrinks as samples arrive) and the vid->contact
// bridge (which must stay complete). Building the bridge only from fleet rows
// means a vendor with no CURRENT overdue items has no contact, so the next time
// one goes overdue it is silently skipped until someone regenerates the file.
// Measured 2026-09-12: the fleet was 29 days stale and 11 of 129 live overdue
// records were unroutable for exactly this reason; a naive regeneration would
// have ADDED 4 vids while dropping 37 others out of the bridge.
// slug is derivable from vid, so derive the bridge directly from contacts.json
// and treat fleet as an ADDITIVE source. Union only -- this can never make a
// vendor that resolves today stop resolving.
// SLUG_ALIASES + slugForVid come from lib/slug-aliases.cjs (one copy, see header there).
const put = (key, slug) => {
if (!key || map[key]) return;
const c = contacts[slug]; if (!c) return;
if (c.disposition === 'no-chase') { map[key] = { noChase: true, name: c.name || slug }; return; }
const email = c.sample_email || c.main_email;
if (!email || !c.account_number) return;
map[key] = { slug, name: c.name || slug, sample_email: c.sample_email, main_email: c.main_email,
account_number: c.account_number, needs_confirm: !c.sample_email || !!c.needs_confirm };
};
for (const v of fleet) {
const c = contacts[v.slug] || {};
const key = String(v.vid || '').toUpperCase(); // FileMaker vid case varies (yor vs YOR)
if (c.disposition === 'no-chase') { map[key] = { noChase: true, name: v.name }; continue; }
// Steve 8/20: recipient = sample_email, else fall back to the vendor's main_email.
// A main_email fallback (or an entry flagged needs_confirm) rides a CONFIRM flag —
// it was resolved from recent correspondence, not a hard sample desk.
const email = c.sample_email || c.main_email;
if (email && c.account_number && !map[key]) {
map[key] = {
slug: v.slug, name: c.name || v.name,
sample_email: c.sample_email, main_email: c.main_email, account_number: c.account_number,
needs_confirm: !c.sample_email || !!c.needs_confirm,
};
}
}
// Additive second pass: any vid in the LIVE overdue set whose slug resolves in
// contacts.json but which is absent from (or stale in) fleet.json. Derived from
// the live vid, not from the slug -- slug->vid is lossy ('1838-wallcoverings'
// could come from '1838 WALLCOVERINGS' or '1838-WALLCOVERINGS').
for (const raw of liveVids) {
const vid = String(raw || '').replace(/[\r\n]/g, '').trim().toUpperCase();
if (vid) put(vid, slugForVid(vid));
}
return map;
}
// --- George send (same path/token as the console) ---
function georgeCreds() {
const files = ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-MCP/.env', '/Users/macstudio3/Projects/george-gmail/.env'];
const g = (k) => { for (const f of files) { try { const m = readFileSync(f, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {} } return ''; };
let auth = g('GEORGE_BASIC_AUTH'); if (!auth.includes(':')) auth = 'admin:' + g('GEORGE_BASIC_AUTH_PASS');
return { auth: 'Basic ' + Buffer.from(auth).toString('base64'), token: g('GEORGE_EXTERNAL_SEND_TOKEN') };
}
function georgeSend(payload) {
const { auth, token } = georgeCreds();
return georgeRequest({ path: '/api/send', payload, headers: { Authorization: auth, 'X-Send-Approval': token } })
.then(({ body }) => { try { const j = JSON.parse(body); return { ok: !!j.success, id: j.messageId || '', detail: body.slice(0, 200) }; } catch { return { ok: false, detail: body.slice(0, 200) }; } });
}
// TK-11409: does a chase draft for this vendor ACTUALLY still exist in info@ Drafts?
// The draft-ledger is only a PROXY for "we already drafted this" and nothing ever verified it.
// Osborne & Little, Thibaut and York were all ledgered as drafted (08/21-09/01) while ZERO drafts
// for them existed — so `if (!fresh.length) continue` skipped them SILENTLY, with no report line,
// making those memos permanently invisible: never re-drafted, never reported, never chased.
// Same failure family as the other two bugs in this ticket — a proxy trusted without verification.
// Returns true/false; on ANY error returns true (assume it exists) so a George hiccup can never
// cause a burst of duplicate drafts. Fail-safe direction is deliberate.
function georgeHasDraft(to) {
const addr = String(to || '').split(/[,;]/)[0].trim();
if (!addr) return Promise.resolve(true);
const { auth } = georgeCreds();
const q = encodeURIComponent(`in:drafts to:${addr}`);
return new Promise((resolve) => {
const req = http.request({ host: '127.0.0.1', port: 9850, path: `/api/messages?account=info&maxResults=5&q=${q}`,
method: 'GET', headers: { Authorization: auth } },
(r) => { let b = ''; r.on('data', (d) => b += d); r.on('end', () => {
try { const j = JSON.parse(b); resolve((j.messages || []).length > 0); } catch { resolve(true); }
}); });
req.on('error', () => resolve(true));
req.end();
});
}
// Create a Gmail draft in info@ Drafts (POST /api/drafts — no send-approval token needed).
function georgeDraft(payload) {
const { auth } = georgeCreds();
return georgeRequest({ path: '/api/drafts', payload, headers: { Authorization: auth } })
.then(({ body }) => { try { const j = JSON.parse(body); return { ok: !!j.success, id: j.draftId || '', detail: body.slice(0, 200) }; } catch { return { ok: false, detail: body.slice(0, 200) }; } });
}
// Live suppression source: recipients the team already emailed a "New Sample Request" to recently
// (harvested straight from info@ Sent via George). Self-updating — no manual list to maintain.
function georgeRecentRecipients(days = 14) {
const { auth } = georgeCreds();
// DTD 6/7 verdict B (2026-09-10, TK-11409). The old query matched subject:"New Sample Request"
// — a brand-new ORDER, a DIFFERENT message type that happens to go to the same vendor desk. So a
// chase was suppressed because we'd placed an order. Our busiest accounts (Kravet, Thibaut,
// Osborne, York) get orders almost daily, so they were effectively un-chaseable: 33 overdue memos
// went unchased for weeks. Now it matches the CHASE letter's own subject.
// The `-subject:"Re:"` exclusion is NOT cosmetic — verified against 90 days of info@ Sent, a bare
// subject:"Sample Follow-Up" also matches (a) humans replying inside a chase thread and (b) the
// "HAPPY LABOR DAY FROM DW! Re: Sample Follow-Up …" auto-responder. Without it this fix would
// trade one over-suppression bug for a new one.
const q = encodeURIComponent(`in:sent subject:"Sample Follow-Up — Outstanding Memos" -subject:"Re:" newer_than:${days}d`);
return new Promise((resolve) => {
const req = http.request({ host: '127.0.0.1', port: 9850, path: `/api/messages?account=info&maxResults=100&q=${q}`, method: 'GET',
headers: { Authorization: auth } },
(r) => { let b = ''; r.on('data', (d) => b += d); r.on('end', () => {
try {
const j = JSON.parse(b); const set = new Set();
for (const m of (j.messages || [])) for (const e of String(m.to || '').match(/[\w.+-]+@[\w.-]+\.\w+/g) || []) set.add(e.toLowerCase());
resolve(set);
} catch { resolve(new Set()); }
}); });
req.on('error', () => resolve(new Set()));
req.end();
});
}
// Steve 9/01: enrich each item with the vendor's OWN order/confirmation # or shipment tracking #
// from their reply that mentions THIS sku (shown as "Your Ref / Tracking"), and — when the mfr SKU
// is blank — the pattern NAME. Best-effort + conservative: a ref is attached only when the vendor's
// inbound mail actually names the sku AND carries a tracking/order number, so we never invent data.
const _UPS = /\b1Z[0-9A-Z]{16}\b/, _FEDEX = /\b\d{12}\b|\b\d{15}\b|\b\d{20}\b/, _USPS = /\b9\d{21}\b/;
const _ORDERNO = /\b(order|confirmation|conf\.?|sales ?order|web ?order|ref(?:erence)?)\s*#?\s*:?\s*([A-Za-z0-9][A-Za-z0-9\-]{3,})\b/i;
const _reEsc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function extractRef(text) {
const t = ' ' + text + ' ';
if (/track|fedex|ups|usps|shipped|tracking/i.test(t)) { const m = t.match(_UPS) || t.match(_FEDEX) || t.match(_USPS); if (m) return m[0] + ' (tracking)'; }
const o = t.match(_ORDERNO); if (o) return o[1].replace(/\.$/, '') + ' #' + o[2];
return '';
}
function georgeGet(path) {
const { auth } = georgeCreds();
return new Promise((res) => { http.get({ host: '127.0.0.1', port: 9850, path, headers: { Authorization: auth } }, (r) => { let b = ''; r.on('data', (d) => b += d); r.on('end', () => { try { res(JSON.parse(b)); } catch { res({}); } }); }).on('error', () => res({})); });
}
async function enrichRows(recipient, rows) {
const domain = String(recipient || '').split('@')[1] || '';
if (!domain) return;
for (const r of rows) {
if (!r.mfr || r.ref) continue; // (blank-mfr name resolution needs a key we don't have without the sku)
try {
const j = await georgeGet(`/api/messages?account=info&maxResults=4&q=${encodeURIComponent(`in:inbox from:${domain} "${r.mfr}"`)}`);
for (const m of (j.messages || [])) {
const d = await georgeGet(`/api/messages/${m.id}?account=info`);
const text = String(d.body || '').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ');
if (!new RegExp(_reEsc(r.mfr), 'i').test(text)) continue; // must name THIS sku
const ref = extractRef(text); if (ref) { r.ref = ref; break; }
}
} catch {}
}
}
const run = async () => {
const win = windowRange();
const today = fmtDate(new Date());
// Two-read set-difference: no single API layout has BOTH `vid` and FIELD_SENT.
// A = fresh outstanding overdue list (REPORT ON SAMPLES ORDERED — has vid + Mfr Pattern)
// B = already-chased recordIds (Report for old memo samples — has FIELD_SENT, filter non-empty "*")
// un-chased = A minus B (recordId is stable across layouts — one WALLPAPER2 table)
const catchAll = (e) => { if (e.fmCode === '401') return { records: [] }; throw e; };
const A = (await fm.findRecords(DB, 'REPORT ON SAMPLES ORDERED',
{ 'Date WP Sample Sent': '=', 'today for client': win }, { limit: 1200 }).catch(catchAll)).records;
const B = (await fm.findRecords(DB, LAYOUT,
{ 'Date WP Sample Sent': '=', [FIELD_SENT]: '*', 'today for client': win }, { limit: 1200 }).catch(catchAll)).records;
const chasedIds = new Set(B.map((r) => String(r.recordId)));
// Group the un-chased by vid.
const byVid = {};
for (const r of A) {
if (chasedIds.has(String(r.recordId))) continue; // already requested → do not duplicate
// TK-11255: FileMaker vid values carry stray CR/whitespace (measured live: a
// Kravet record's vid is literally "kra\r"). Without stripping it the key never
// matches cmap and that vendor's overdue sample is SILENTLY skipped -- no error,
// no log, it just never gets chased. build-fleet.js already strips \r from other
// FileMaker fields; vid was the one that was missed.
const vid = String(r.fieldData.vid || '').replace(/[\r\n]/g, '').trim().toUpperCase(); if (!vid) continue;
(byVid[vid] = byVid[vid] || []).push({
recordId: r.recordId, mfr: (r.fieldData['Mfr Pattern'] || '').trim(),
sku: r.fieldData['combo sku'] || '', requested: r.fieldData['today for client'] || '',
});
}
// TK-12105 NEVER-FALSE-CHASE guard — before chasing any sku, pull ALL FileMaker records for that
// combo sku (LAYOUT has entered + Date WP Sample Sent + Date Sample Request Letter Sent) and drop
// the row if the sample already arrived on a duplicate same-entered-date record, or a 10-day letter
// was already sent. Fail-open (empty siblings => keep) — the human batch-send gate + 14-day Gmail
// anti-dup are backstops, so an FM hiccup can never silently drop a real chase.
const guardSuppressed = [];
{
const skus = [...new Set(Object.values(byVid).flat().map((r) => r.sku).filter(Boolean))];
const sib = {};
for (const sku of skus) {
const rr = await fm.findRecords(DB, LAYOUT, { 'combo sku': sku }, { limit: 50 }).catch(catchAll);
sib[sku] = (rr.records || []).map((x) => ({
entered: x.fieldData['today for client'] || '',
wpSampleSent: x.fieldData['Date WP Sample Sent'] || '',
letterSent: x.fieldData[FIELD_2ND_DATE] || '',
}));
}
for (const [vid, rows] of Object.entries(byVid)) {
const kept = [];
for (const r of rows) {
const g = suppressChase(r.requested, sib[r.sku] || []);
if (g.suppress) guardSuppressed.push({ vid, sku: r.sku, mfr: r.mfr, reason: g.reason });
else kept.push(r);
}
if (kept.length) byVid[vid] = kept; else delete byVid[vid];
}
}
const cmap = vidContactMap(Object.keys(byVid));
// DRAFT dedup ledger — drafting ≠ sending, so we do NOT false-stamp FileMaker; we track which
// recordIds already have a draft locally so the daily run never piles up duplicate drafts.
const LEDGER = 'data/draft-ledger.json';
const draftLedger = DRAFT ? readJSON(LEDGER, {}) : {};
// Recently-contacted suppression: vendors the human desk already emailed a "New Sample Request"
// for (harvested from info@ Sent into data/recently-contacted.json). Stops the sweep from
// duplicating what the team already sent manually — the whole point of the 2026-08-20 fix.
// TK-11409: this static file was a PERMANENT blocklist. Hand-written, last edited 2026-08-20,
// 26 addresses, nothing ever expiring — and it held the biggest accounts (Kravet, Thibaut,
// Osborne, York, WallQuest, Sancar, Innovations, MDC, Maharam...). So even after the Gmail query
// was corrected to match the chase subject, those vendors stayed suppressed forever by a snapshot
// nobody had looked at in three weeks. Same failure family as the rest of this ticket: a record
// taken once and trusted indefinitely.
// It is now a { address: 'YYYY-MM-DD' } map and entries EXPIRE after RECENT_DAYS. A bare-array
// file is still read (every entry treated as expired-unknown -> ignored) so an old copy or a
// hand-edit can't resurrect the permanent-blocklist behaviour.
const RECENT = new Set();
{
const raw = readJSON('data/recently-contacted.json', {});
const cutoff = new Date(Date.now() - RECENT_DAYS * 864e5);
let kept = 0, expired = 0;
if (Array.isArray(raw)) {
expired = raw.length; // undated legacy format: no evidence of recency, so it suppresses nothing
} else {
for (const [addr, when] of Object.entries(raw || {})) {
const d = new Date(when);
if (!isNaN(d) && d >= cutoff) { RECENT.add(String(addr).toLowerCase().trim()); kept++; } else expired++;
}
}
if (expired) console.log(`(static contact list: ${kept} still within ${RECENT_DAYS}d, ${expired} expired and no longer suppressing)`);
}
try { const live = await georgeRecentRecipients(RECENT_DAYS); for (const e of live) RECENT.add(e); if (live.size) console.log(`(anti-dup pool: ${live.size} recipient(s) emailed in last ${RECENT_DAYS}d — enforced on SEND, drafted-with-flag on DRAFT)`); } catch {}
const sent = [], skipped = [], needsConfirm = [], drafted = [], suppressed = [];
// TK-11409: make the two previously-SILENT draft-mode outcomes visible in the report.
const alreadyDrafted = [], staleLedger = [];
for (const [vid, rows] of Object.entries(byVid)) {
const c = cmap[vid];
if (!c || c.noChase) { skipped.push({ vid, n: rows.length, reason: c?.noChase ? 'no-chase vendor' : 'no sample email/account on file' }); continue; }
const recips = String((c.sample_email || c.main_email) || '').toLowerCase().split(/[,;]\s*/).map((s) => s.trim()).filter(Boolean);
const recentlyEmailed = recips.length > 0 && recips.some((e) => RECENT.has(e));
// DTD 5/5 verdict C (2026-09-10, TK-11409): the anti-dup filter now sits ONLY next to the
// irreversible act. SEND stays strictly suppressed — a duplicate chase to a vendor we just
// ordered from is a real embarrassment. DRAFT always includes them, flagged for review,
// because a draft sends nothing, leaves no mailbox, writes no record: suppressing it prevented
// no harm and only hid work. That hiding is what let 33 overdue memos across 10 vendors sit
// invisible for weeks, surfacing only because Steve happened to ask. The old --include-suppressed
// opt-in is why: a safety step that must be REMEMBERED is not a control, and nobody ever typed it.
// (INCLUDE_SUPPRESSED is retained as a no-op for DRAFT so existing callers/launchd keep working.)
if (recentlyEmailed && !DRAFT) { suppressed.push({ vid, name: c.name, to: (c.sample_email || c.main_email), n: rows.length, reason: `already emailed in the last ${RECENT_DAYS}d (rolling anti-dup, SEND mode)` }); continue; }
// In SEND mode, an unconfirmed (resolved/fallback) recipient is HELD — never fires. In DRAFT
// mode the draft itself IS the review, so everything is drafted for Steve to check + send.
if (c.needs_confirm && !DRAFT) { needsConfirm.push({ vid, name: c.name, to: (c.sample_email || c.main_email), n: rows.length, skus: rows.map((r) => r.sku), reason: 'main-email fallback — CONFIRM person vs recent emails before send' }); continue; }
if (DRAFT) {
let fresh = rows.filter((r) => !draftLedger[r.recordId]); // only newly-outstanding SKUs
if (!fresh.length) {
// TK-11409: the ledger says "already drafted" — VERIFY that before trusting it. If the
// draft is gone (sent, deleted, or purged by the 30-day drain), the ledger is stale and
// these memos would otherwise be suppressed forever with no report line at all.
const stillThere = await georgeHasDraft(c.sample_email || c.main_email);
if (stillThere) { alreadyDrafted.push({ vid, name: c.name, n: rows.length }); continue; }
staleLedger.push({ vid, name: c.name, to: (c.sample_email || c.main_email), n: rows.length });
for (const r of rows) delete draftLedger[r.recordId]; // drop the stale claim
fresh = rows; // and re-draft
}
await enrichRows(c.sample_email || c.main_email, fresh); // vendor ref/tracking when they responded
const draft = compose({ name: c.name, account_number: c.account_number, sample_email: c.sample_email, main_email: c.main_email, ship_to: SHIP_TO }, fresh.map((r) => ({ mfr: r.mfr, sku: r.sku, requested: r.requested, name: r.name, ref: r.ref })));
// Steve 9/01: the "recently emailed" flag stays in the RUN REPORT only — never in the subject
// (it must not reach the vendor). The console still marks these drafts for review.
const res = await georgeDraft({ account: 'info', to: draft.to, subject: draft.subject, body: draft.html });
if (res.ok) {
for (const r of fresh) draftLedger[r.recordId] = today; // dedup marker (NOT a FileMaker stamp)
drafted.push({ vid, name: c.name, to: draft.to, n: fresh.length, draftId: res.id, confirm: !!c.needs_confirm, flagged: recentlyEmailed });
} else { skipped.push({ vid, n: fresh.length, reason: 'George draft failed: ' + res.detail }); }
continue;
}
await enrichRows(c.sample_email || c.main_email, rows); // vendor ref/tracking when they responded
const draft = compose({ name: c.name, account_number: c.account_number, sample_email: c.sample_email, main_email: c.main_email, ship_to: SHIP_TO }, rows.map((r) => ({ mfr: r.mfr, sku: r.sku, requested: r.requested, name: r.name, ref: r.ref })));
if (!SEND) { sent.push({ vid, name: c.name, to: draft.to, n: rows.length, skus: rows.map((r) => r.sku), dryRun: true }); continue; }
const res = await georgeSend({ account: 'info', to: draft.to, subject: draft.subject, body: draft.html });
if (res.ok) {
for (const r of rows) { try { await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: today, [FIELD_2ND_DATE]: today }, { dryRun: false }); } catch (e) { console.warn(` ⚠ FM stamp failed after send — recordId ${r.recordId} (${r.sku}): ${e.message}`); } }
sent.push({ vid, name: c.name, to: draft.to, n: rows.length, messageId: res.id });
} else {
skipped.push({ vid, n: rows.length, reason: 'George send failed: ' + res.detail });
}
}
if (DRAFT) writeFileSync(join(ROOT, LEDGER), JSON.stringify(draftLedger, null, 2));
const mode = DRAFT ? 'DRAFT' : SEND ? 'SEND' : 'DRY-RUN';
const report = { ranAt: new Date().toISOString(), mode, window: win, stampDate: today,
vendorsSent: sent.length, vendorsDrafted: drafted.length, vendorsSkipped: skipped.length, vendorsNeedsConfirm: needsConfirm.length, vendorsSuppressed: suppressed.length, vendorsAlreadyDrafted: alreadyDrafted.length, vendorsStaleLedger: staleLedger.length, skusGuardSuppressed: guardSuppressed.length, sent, drafted, needsConfirm, skipped, suppressed, alreadyDrafted, staleLedger, guardSuppressed };
mkdirSync(join(ROOT, 'data', 'runs'), { recursive: true });
writeFileSync(join(ROOT, 'data', 'runs', `scheduled-${today.replace(/\//g, '-')}.json`), JSON.stringify(report, null, 2));
const bandDesc = CATCHUP_DAYS > 0 ? `${CATCHUP_DAYS}-day manual catch-up band` : 'auto-widen band (resumes after last successful run)';
console.log(`Scheduled follow-up — ${mode} · window ${win} (>=${MIN_AGE_DAYS}d, ${bandDesc})\n`);
if (DRAFT) {
console.log(`Created ${drafted.length} draft(s) in info@ Drafts for review:`);
drafted.forEach((s) => console.log(` ✎ ${s.name.padEnd(30)} [${s.vid}] → ${s.to} (${s.n} SKU${s.n > 1 ? 's' : ''})${s.confirm ? ' [CONFIRM recipient]' : ''}${s.flagged ? ' [⚠ team emailed recently — REVIEW]' : ''}`));
} else {
console.log(`Would ${SEND ? 'HAVE SENT' : 'send'} to ${sent.length} vendor(s):`);
sent.forEach((s) => console.log(` ${SEND ? '✓' : '·'} ${s.name.padEnd(30)} [${s.vid}] → ${s.to} (${s.n} SKU${s.n > 1 ? 's' : ''})`));
if (needsConfirm.length) { console.log(`\n⚠ ${needsConfirm.length} main-email fallback(s) — HELD for person-confirmation (never auto-sent):`); needsConfirm.forEach((s) => console.log(` ? ${s.name.padEnd(30)} [${s.vid}] → ${s.to} (${s.n} SKU${s.n > 1 ? 's' : ''}) — ${s.reason}`)); }
}
if (suppressed.length) { console.log(`\n⊘ Suppressed ${suppressed.length} (emailed within ${RECENT_DAYS}d — SEND mode only; DRAFT would surface these flagged):`); suppressed.forEach((s) => console.log(` ⊘ ${(s.name||'').padEnd(30)} [${s.vid}] → ${s.to}`)); }
if (alreadyDrafted.length) { console.log(`\n= Already drafted ${alreadyDrafted.length} (draft VERIFIED still in Drafts — not duplicated):`); alreadyDrafted.forEach((s) => console.log(` = ${(s.name||'').padEnd(30)} [${s.vid}] (${s.n} SKU${s.n>1?'s':''})`)); }
if (staleLedger.length) { console.log(`\n\u26a0 STALE LEDGER ${staleLedger.length} — ledger claimed a draft that NO LONGER EXISTS; re-drafted:`); staleLedger.forEach((s) => console.log(` \u26a0 ${(s.name||'').padEnd(30)} [${s.vid}] \u2192 ${s.to} (${s.n} SKU${s.n>1?'s':''})`)); }
if (guardSuppressed.length) { console.log(`\n\u{1F6E1} Never-false-chase guard suppressed ${guardSuppressed.length} SKU(s) (already arrived / 10-day letter already sent):`); guardSuppressed.forEach((s) => console.log(` ⊘ [${s.vid}] ${s.sku} ${s.mfr} — ${s.reason}`)); }
if (skipped.length) { console.log(`\nSkipped ${skipped.length} (no contact / no-chase / error):`); skipped.forEach((s) => console.log(` - [${s.vid}] ${s.n} SKU(s): ${s.reason}`)); }
console.log(`\n→ data/runs/scheduled-${today.replace(/\//g, '-')}.json`);
};
run().catch((e) => { console.error('scheduled-run error:', e.message); process.exit(1); });