← back to Sample Followup Sweep
sent-stamp watcher: retry skipped/zero sends, stamp the real send date
a6b53a07430cd90177386382ea378185bca9c7ea · 2026-09-26 08:38:29 -0700 · Steve Abrams
A ledger entry now only suppresses re-processing once the send actually
stamped (or found its letter items already stamped) with no failed commit.
skip / stamped:0 sends are retried (15 min backoff) inside a 21-day Sent
scan, so sends that landed before their email->vid mapping existed are no
longer poisoned forever. The chase date is the message's send date
(internalDate), sends are processed oldest-first so the first letter owns
the date, FM/George lookup errors no longer masquerade as 'nothing to
stamp', and contact-only vendors (Phillip Jeffries) map via the
slug-derived vid. Pure logic moved to sent-stamp-core.mjs with a negative
test. TK-12320
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDCCv6ZcKPKfWxof41UzvC
Files touched
A scripts/sent-stamp-core.mjsM scripts/watch-sent-stamp.mjsA test/sent-stamp-retry.test.js
Diff
commit a6b53a07430cd90177386382ea378185bca9c7ea
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Sep 26 08:38:29 2026 -0700
sent-stamp watcher: retry skipped/zero sends, stamp the real send date
A ledger entry now only suppresses re-processing once the send actually
stamped (or found its letter items already stamped) with no failed commit.
skip / stamped:0 sends are retried (15 min backoff) inside a 21-day Sent
scan, so sends that landed before their email->vid mapping existed are no
longer poisoned forever. The chase date is the message's send date
(internalDate), sends are processed oldest-first so the first letter owns
the date, FM/George lookup errors no longer masquerade as 'nothing to
stamp', and contact-only vendors (Phillip Jeffries) map via the
slug-derived vid. Pure logic moved to sent-stamp-core.mjs with a negative
test. TK-12320
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDCCv6ZcKPKfWxof41UzvC
---
scripts/sent-stamp-core.mjs | 129 ++++++++++++++++++++++++++++++++++++++++
scripts/watch-sent-stamp.mjs | 133 +++++++++++++++++++++---------------------
test/sent-stamp-retry.test.js | 117 +++++++++++++++++++++++++++++++++++++
3 files changed, 311 insertions(+), 68 deletions(-)
diff --git a/scripts/sent-stamp-core.mjs b/scripts/sent-stamp-core.mjs
new file mode 100644
index 0000000..5cad9e1
--- /dev/null
+++ b/scripts/sent-stamp-core.mjs
@@ -0,0 +1,129 @@
+// Pure decision logic for watch-sent-stamp.mjs (TK-12320). No FileMaker, no network, no fs —
+// every side effect is injected so test/sent-stamp-retry.test.js can drive it with fixtures.
+
+const pad = n => String(n).padStart(2, '0');
+export const fmt = d => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`;
+
+// Memos a follow-up letter covers: ordered minAge..maxAge days before `asOf`.
+export function chaseWindow(asOf, minAgeDays, maxAgeDays) {
+ const hi = new Date(asOf); hi.setDate(hi.getDate() - minAgeDays);
+ const lo = new Date(asOf); lo.setDate(lo.getDate() - maxAgeDays);
+ return `${fmt(lo)}...${fmt(hi)}`;
+}
+
+// The chase date is the day the letter LEFT info@, not the day this poller noticed it — a retried
+// send must never be stamped with a later date than the vendor actually received the letter.
+export function sendDateOf(msg, now) {
+ const ms = Number(msg?.internalDate);
+ if (Number.isFinite(ms) && ms > 0) return { date: new Date(ms), source: 'internalDate' };
+ const hdr = msg?.date ? new Date(msg.date) : null;
+ if (hdr && !Number.isNaN(hdr.getTime())) return { date: hdr, source: 'date-header' };
+ return { date: new Date(now), source: 'fallback-today' };
+}
+
+// Only a send that actually stamped something, with no failed commits, is finished. A skip
+// (no vid mapping yet) or stamped:0 (mapping was wrong / records not visible yet) must be retried,
+// otherwise a send that landed before its mapping existed is poisoned forever and re-chased.
+// `covered` = letter items found already stamped (by an earlier send of the same letter or another
+// path) — nothing left for this send to do, so it is finished too.
+export function isTerminal(entry) {
+ if (!entry || entry.skip || Number(entry.failed) > 0) return false;
+ return Number(entry.stamped) >= 1 || Number(entry.covered) >= 1;
+}
+
+export function shouldProcess(entry, { now, retrySecs, dry }) {
+ if (!entry) return { go: true, why: 'new' };
+ if (isTerminal(entry)) return { go: false, why: 'terminal' };
+ if (dry) return { go: true, why: 'retry' };
+ const last = Date.parse(entry.last_try || entry.at || '') || 0;
+ if (now - last < retrySecs * 1000) return { go: false, why: 'backoff' };
+ return { go: true, why: 'retry' };
+}
+
+// Keep history instead of overwriting: attempts, first/last try, and the prior terminal-ish outcome.
+export function mergeEntry(prev, outcome, now) {
+ const iso = new Date(now).toISOString();
+ const attempts = (Number(prev?.attempts) || (prev ? 1 : 0)) + 1;
+ const first_try = prev?.first_try || prev?.at || iso;
+ const next = { ...outcome, attempts, first_try, last_try: iso };
+ if (prev && (prev.skip || Number(prev.stamped) === 0) && !outcome.skip && outcome.stamped >= 1) {
+ next.recovered_from = prev.skip ? `skip: ${prev.skip}` : 'stamped:0';
+ }
+ return next;
+}
+
+// email → candidate vids. fleet.json alone is not a complete bridge (it only lists vendors with
+// CURRENT outstanding items — TK-11255), so a vendor like Phillip Jeffries whose contact exists but
+// who had dropped out of fleet.json was unmappable. Union in the vid(s) derivable from each contact
+// slug (the inverse of lib/slug-aliases.cjs slugForVid). Additive only; body-Mfr# scoping in the
+// stamper keeps a wrong candidate from ever stamping anything.
+export function buildEmailVid(fleetVendors, contacts, { slugAliases = {}, slugForVid } = {}) {
+ const slugVids = {};
+ const add = (slug, vid) => { const v = String(vid || '').toUpperCase().trim(); if (slug && v) (slugVids[slug] = slugVids[slug] || new Set()).add(v); };
+ for (const v of fleetVendors || []) add(v.slug, v.vid);
+ for (const [vid, slug] of Object.entries(slugAliases)) add(slug, vid);
+ if (slugForVid) {
+ for (const slug of Object.keys(contacts || {})) {
+ const vid = slug.toUpperCase().replace(/-/g, ' ');
+ if (slugForVid(vid) === slug) add(slug, vid);
+ }
+ }
+ const map = {};
+ for (const [slug, c] of Object.entries(contacts || {})) {
+ const vids = slugVids[slug]; if (!vids || !c) continue;
+ for (const field of [c.sample_email, c.main_email]) {
+ for (const one of String(field || '').toLowerCase().split(/[,;]\s*/)) {
+ const t = one.trim(); if (!t) continue;
+ const set = (map[t] = map[t] || new Set()); for (const v of vids) set.add(v);
+ }
+ }
+ }
+ return map;
+}
+
+export function vidsForRecipient(emailVid, to) {
+ const out = new Set();
+ for (const e of (String(to || '').toLowerCase().match(/[\w.+-]+@[\w.-]+\.\w+/g) || [])) {
+ if (emailVid[e]) for (const v of emailVid[e]) out.add(v);
+ }
+ return [...out];
+}
+
+// One pass over the sent follow-ups. deps: { emailVid, getBody(id), stamp(vids, body, win, dateStr) → {stamped:[], failed:n} }.
+export async function runPass({ msgs, ledger, now, dry, retrySecs, minAgeDays, maxAgeDays, deps, log = () => {} }) {
+ const seen = { ...ledger };
+ const unmapped = new Set();
+ const results = [];
+ let acted = 0, changed = false;
+ // Oldest send first: the FIRST letter that listed a memo owns its chase date; later resends then
+ // find the record already stamped instead of overwriting the slot with a later date.
+ const ordered = [...msgs].sort((a, b) => sendDateOf(a, now).date - sendDateOf(b, now).date);
+ for (const m of ordered) {
+ if (!m || !m.id || m.error) continue; // metadata fetch failed — try again next pass
+ const prev = seen[m.id];
+ const d = shouldProcess(prev, { now, retrySecs, dry });
+ if (!d.go) continue;
+ const vids = vidsForRecipient(deps.emailVid, m.to);
+ if (!vids.length) {
+ unmapped.add(String(m.to || '(no to)'));
+ if (!dry) { seen[m.id] = mergeEntry(prev, { skip: 'no vid for recipient', to: m.to || '', subject: m.subject || '' }, now); changed = true; }
+ results.push({ id: m.id, why: d.why, skip: 'no vid for recipient', to: m.to || '' });
+ continue;
+ }
+ const sd = sendDateOf(m, now);
+ const sentOn = fmt(sd.date);
+ if (sd.source === 'fallback-today') log(` ⚠ ${m.id}: no internalDate/Date header — stamping today ${sentOn}`);
+ const win = chaseWindow(sd.date, minAgeDays, maxAgeDays);
+ const body = await deps.getBody(m.id);
+ if (body == null) continue; // body fetch failed — not an outcome, try next pass
+ const r = await deps.stamp(vids, body, win, sentOn);
+ const stamped = r.stamped || [], failed = Number(r.failed) || 0, covered = Number(r.covered) || 0;
+ if (!dry) {
+ seen[m.id] = mergeEntry(prev, { vids, stamped: stamped.length, covered, failed, patterns: stamped, to: m.to || '', sent_on: sentOn, sent_on_source: sd.source, at: new Date(now).toISOString() }, now);
+ changed = true;
+ }
+ acted += stamped.length;
+ results.push({ id: m.id, why: d.why, vids, stamped, covered, failed, sentOn, to: m.to || '' });
+ }
+ return { ledger: seen, changed, acted, unmapped: [...unmapped].sort(), results };
+}
diff --git a/scripts/watch-sent-stamp.mjs b/scripts/watch-sent-stamp.mjs
index 8b8383b..271fc46 100644
--- a/scripts/watch-sent-stamp.mjs
+++ b/scripts/watch-sent-stamp.mjs
@@ -7,6 +7,8 @@
import { readFileSync, writeFileSync } 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 } from './sent-stamp-core.mjs';
+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
@@ -17,7 +19,11 @@ const POLL_SECS = Number(process.env.POLL_SECS || 30);
// 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
-const SEARCH_DAYS = Number(process.env.SEARCH_DAYS) || 3; // how far back to scan Sent (widen to backfill a month)
+// 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;
const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
const fenv = cfg?.mcpServers?.filemaker?.env || {};
@@ -27,70 +33,65 @@ 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; } };
-const pad = n => String(n).padStart(2, '0');
-const fmt = d => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`;
-// NB: today is computed PER PASS (not at module load) so the 24/7 --loop stamps the real
-// current date after crossing midnight, instead of freezing the date the process started.
-// rolling chase window: memos aged MIN_AGE_DAYS..MAX_AGE_DAYS (what any follow-up letter would cover)
-function chaseWindow(now = new Date()) {
- const hi = new Date(now); hi.setDate(hi.getDate() - MIN_AGE_DAYS); // newest chaseable
- const lo = new Date(now); lo.setDate(lo.getDate() - MAX_AGE_DAYS); // oldest chaseable
- return `${fmt(lo)}...${fmt(hi)}`;
-}
-// George /api/search (HTTP) — find follow-ups that actually left info@
+// 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 gsearch(q) { return new Promise(res => { const p = '/api/messages?account=info&maxResults=30&q=' + encodeURIComponent(q); const rq = http.request({ host: '127.0.0.1', port: 9850, path: p, method: 'GET', headers: { Authorization: auth } }, r => { let d = ''; r.on('data', x => d += x); r.on('end', () => { try { const j = JSON.parse(d); res(j.messages || (Array.isArray(j) ? j : [])); } catch { res([]); } }); }); rq.on('error', () => res([])); rq.end(); }); }
-function gget(id) { return new Promise(res => { const rq = http.request({ host: '127.0.0.1', port: 9850, path: `/api/messages/${id}?account=info`, method: 'GET', headers: { Authorization: auth } }, r => { let d = ''; r.on('data', x => d += x); r.on('end', () => { try { res(JSON.parse(d)); } catch { res({}); } }); }); rq.on('error', () => res({})); rq.end(); }); }
-
-// Steve 9/01 FIX: identify the vendor by the follow-up's RECIPIENT EMAIL → vid, NOT the subject
-// account #. FileMaker's `account` field is a DW-internal sequence #, not the vendor account, so the
-// old account-match stamped nothing (ledger: 6/25 matched 0). Email→vid also covers "On File" vendors.
-// One vendor email can map to MULTIPLE vid variants (Thibaut THI/THIB, Osborne DGD/OSB); the records
-// may sit under any of them, so map email → the SET of candidate vids and stamp across all of them.
-function buildEmailVid() {
- const fleet = readJSON(join(ROOT, 'data/fleet.json'), { vendors: [] }).vendors;
- const contacts = readJSON(join(ROOT, 'data/contacts.json'), {});
- // a slug can map to MULTIPLE fleet vids (thib → THIB, ANNA, ANNA FRENCH); keep ALL, not last-wins.
- const slugVids = {}; for (const v of fleet) { if (!v.slug) continue; (slugVids[v.slug] = slugVids[v.slug] || new Set()).add(String(v.vid || '').toUpperCase()); }
- const map = {};
- for (const [slug, c] of Object.entries(contacts)) {
- const vids = slugVids[slug]; if (!vids || !c) continue;
- for (const field of [c.sample_email, c.main_email]) for (const one of String(field || '').toLowerCase().split(/[,;]\s*/)) { const t = one.trim(); if (t) { const set = (map[t] = map[t] || new Set()); for (const v of vids) set.add(v); } }
+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 map;
-}
-const EMAIL_VID = buildEmailVid();
-function vidsForRecipient(to) {
- const out = new Set();
- for (const e of (String(to || '').toLowerCase().match(/[\w.+-]+@[\w.-]+\.\w+/g) || [])) if (EMAIL_VID[e]) for (const v of EMAIL_VID[e]) out.add(v);
- return [...out];
+ 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).
-async function stampVids(vids, bodyText, win, today) {
+// 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();
- const Bs = (await fm.findRecords(DB, LAYOUT,
- { [FIELD_SENT]: '*', 'Date WP Sample Sent': '=', 'today for client': win }, { limit: 800 }).catch(() => ({ records: [] }))).records;
+ // 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 }; }
const already = new Set(Bs.map(r => String(r.recordId)));
- const stamped = [], done = new Set();
+ 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) {
- const A = (await fm.findRecords(DB, 'REPORT ON SAMPLES ORDERED',
- { vid: `==${vid}`, 'Date WP Sample Sent': '=', 'today for client': win }, { limit: 300 }).catch(() => ({ records: [] }))).records;
+ 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 (already.has(id) || done.has(id)) continue; // already stamped / dedup
+ 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 (DRY_RUN) { stamped.push(mfr + ' [dry]'); continue; }
+ if (already.has(id)) { covered++; continue; } // already carries a chase date — never overwrite
+ if (DRY_RUN) { dryStamped.add(id); stamped.push(mfr + ' [dry]'); 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
@@ -100,39 +101,35 @@ async function stampVids(vids, bodyText, win, today) {
// 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]: today }, { dryRun: false }).catch((e) => ({ err: e.message }));
+ 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 console.warn(` ⚠ FM stamp not committed for ${mfr} (recordId ${r.recordId})${res.err ? ': ' + res.err : ''}`);
+ else { failed++; console.warn(` ⚠ FM stamp not committed for ${mfr} (recordId ${r.recordId})${res.err ? ': ' + res.err : ''}`); }
}
}
- return stamped;
+ return { stamped, failed, covered };
}
+let lastUnmappedKey = '';
async function pass() {
- const seen = readJSON(LEDGER, {});
- const win = chaseWindow();
- const today = fmt(new Date()); // per-pass so a midnight-crossing loop stamps the real date
+ const ledger = readJSON(LEDGER, {});
// NB: the em-dash in the full subject breaks Gmail matching over HTTP — match the ASCII prefix.
- // newer_than:2d so a poll gap never loses a send; the per-message ledger guards against re-work.
- const msgs = await gsearch(`in:sent subject:"Outstanding Memos" newer_than:${SEARCH_DAYS}d`); // no hyphen → less flaky than "Sample Follow-Up"
- if (process.env.DEBUG) console.log(` [debug] gsearch → ${msgs.length} msg(s); win ${win}; ledger has ${Object.keys(seen).length}${DRY_RUN ? ' · DRY-RUN' : ''}`);
- let acted = 0;
- for (const m of msgs) {
- if (seen[m.id] && !DRY_RUN) continue;
- const vids = vidsForRecipient(m.to); // recipient email → candidate vendor vids
- if (!vids.length) { if (!DRY_RUN) seen[m.id] = { skip: 'no vid for recipient', to: m.to || '', subject: m.subject || '' }; console.log(` · no vid mapping for ${m.to || '(no to)'}`); continue; }
- const full = await gget(m.id); // fetch the sent letter body
- const stamped = await stampVids(vids, full.body || '', win, today);
- if (!DRY_RUN) seen[m.id] = { vids, stamped: stamped.length, patterns: stamped, to: m.to || '', at: new Date().toISOString() };
- acted += stamped.length;
- console.log(` ${DRY_RUN ? '·' : '✓'} SENT → ${m.to || ''} [${vids.join('/')}] → ${DRY_RUN ? 'WOULD stamp' : 'stamped'} ${stamped.length} memo(s) [${stamped.join(' + ')}] chaseDate=${today}`);
- }
- if (!DRY_RUN) writeFileSync(LEDGER, JSON.stringify(seen, null, 2));
- if (!acted) console.log(` (no ${DRY_RUN ? 'stampable' : 'new sent'} follow-ups) — ${new Date().toLocaleTimeString()}`);
- return acted;
+ 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()}`);
+ return r.acted;
}
const LOOP = process.argv.includes('--loop');
-console.log(`watch-sent-stamp — chase window ${chaseWindow()} — ${LOOP ? `LOOP every ${POLL_SECS}s` : 'one pass'}`);
+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'}`);
if (!LOOP) { await pass(); process.exit(0); }
for (;;) { try { await pass(); } catch (e) { console.error('pass err:', e.message); } await new Promise(r => setTimeout(r, POLL_SECS * 1000)); }
diff --git a/test/sent-stamp-retry.test.js b/test/sent-stamp-retry.test.js
new file mode 100644
index 0000000..c61226d
--- /dev/null
+++ b/test/sent-stamp-retry.test.js
@@ -0,0 +1,117 @@
+'use strict';
+// TK-12320: a sent follow-up whose stamp was skipped / zero must be retried; a stamped one must not;
+// and the chase date written is the message's SEND date, never the pass date. No FM, no network.
+const assert = require('node:assert/strict');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+
+(async () => {
+ const core = await import(pathToFileURL(path.join(__dirname, '../scripts/sent-stamp-core.mjs')).href);
+ const NOW = new Date(2026, 8, 26, 9, 0, 0).getTime(); // 09/26/2026 local
+ const SENT_1838 = new Date(2026, 8, 16, 8, 15, 0).getTime(); // 09/16/2026 local
+ const SENT_VAH = new Date(2026, 8, 13, 0, 5, 0).getTime(); // 09/13/2026 local
+ const SENT_KRA = new Date(2026, 8, 24, 12, 0, 0).getTime();
+ const longAgo = new Date(NOW - 86400e3).toISOString(); // past any backoff
+
+ const emailVid = core.buildEmailVid(
+ [{ slug: '1838', vid: '1838 WALLCOVERINGS' }, { slug: 'vahallan', vid: 'VAH' }, { slug: 'kravet', vid: 'KRA' }],
+ { '1838': { sample_email: 'scott.myer@gmail.com' }, vahallan: { main_email: 'info@vahallan.com' }, kravet: { sample_email: 'matt.schoffman@kravet.com' } });
+
+ const msgs = [
+ { id: 'm1838', to: '"scott.myer" <scott.myer@gmail.com>', internalDate: String(SENT_1838) },
+ { id: 'mvah', to: 'info <info@vahallan.com>', internalDate: String(SENT_VAH) },
+ { id: 'mkra', to: 'Matt <matt.schoffman@kravet.com>', internalDate: String(SENT_KRA) },
+ { id: 'mnew', to: 'nobody@unknown.example', internalDate: String(NOW) },
+ ];
+ const ledger = {
+ m1838: { vids: ['1838 WALLCOVERINGS'], stamped: 0, patterns: [], at: longAgo }, // poisoned stamped:0
+ mvah: { skip: 'no vid for recipient', to: 'info@vahallan.com' }, // poisoned skip (legacy: no timestamps)
+ mkra: { vids: ['KRA'], stamped: 2, patterns: ['A', 'B'], at: longAgo }, // terminal success
+ };
+
+ const stampCalls = [];
+ const deps = {
+ emailVid,
+ getBody: async id => `letter ${id}`,
+ stamp: async (vids, body, win, sentOn) => { stampCalls.push({ vids, body, win, sentOn }); return { stamped: ['X-1'], failed: 0 }; },
+ };
+ const r = await core.runPass({ msgs, ledger, now: NOW, dry: false, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60, deps });
+
+ // (a) stamped:0 and skip entries ARE re-processed
+ const byBody = Object.fromEntries(stampCalls.map(c => [c.body, c]));
+ assert.ok(byBody['letter m1838'], '(a) stamped:0 entry must be re-processed');
+ assert.ok(byBody['letter mvah'], '(a) skip entry must be re-processed once its recipient maps');
+ assert.equal(r.ledger.m1838.stamped, 1);
+ assert.equal(r.ledger.m1838.recovered_from, 'stamped:0');
+ assert.equal(r.ledger.mvah.recovered_from, 'skip: no vid for recipient');
+ assert.equal(r.ledger.m1838.attempts, 2, 'history kept: attempts counts the prior try');
+ console.log('(a) PASS — stamped:0 and skip entries re-processed; attempts/recovered_from recorded');
+
+ // (b) stamped>=1 is NOT re-processed, and its ledger entry is untouched
+ assert.ok(!byBody['letter mkra'], '(b) terminal stamped>=1 entry must NOT be re-processed');
+ assert.deepEqual(r.ledger.mkra, ledger.mkra);
+ console.log('(b) PASS — stamped>=1 entry skipped and left intact');
+
+ // (c) stamp date = message send date, not today; window anchored on the send date too
+ assert.equal(byBody['letter m1838'].sentOn, '09/16/2026', '(c) 1838 must stamp its send date');
+ assert.equal(byBody['letter mvah'].sentOn, '09/13/2026', '(c) Vahallan must stamp its send date');
+ assert.notEqual(byBody['letter m1838'].sentOn, core.fmt(new Date(NOW)));
+ assert.equal(byBody['letter m1838'].win, '07/18/2026...09/06/2026');
+ assert.equal(r.ledger.m1838.sent_on_source, 'internalDate');
+ console.log('(c) PASS — chase date = send date (09/16, 09/13), not today (09/26)');
+
+ // unmapped recipient: ledgered as skip, NOT terminal, reported once
+ assert.deepEqual(r.unmapped, ['nobody@unknown.example']);
+ assert.equal(core.isTerminal(r.ledger.mnew), false);
+ assert.equal(r.ledger.mnew.attempts, 1);
+
+ // backoff: an immediate second pass does not re-query FM for the still-unmapped skip
+ stampCalls.length = 0;
+ const r2 = await core.runPass({ msgs, ledger: r.ledger, now: NOW + 30e3, dry: false, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60, deps });
+ assert.equal(stampCalls.length, 0, 'terminal entries skipped; unmapped one in backoff');
+ assert.equal(r2.changed, false, 'no ledger churn inside the backoff window');
+ // …but after the backoff it is retried and picks up a newly-added mapping
+ const emailVid2 = core.buildEmailVid([{ slug: 'u', vid: 'UNK' }], { u: { sample_email: 'nobody@unknown.example' } });
+ const r3 = await core.runPass({ msgs, ledger: r.ledger, now: NOW + 901e3, dry: false, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60, deps: { ...deps, emailVid: emailVid2 } });
+ assert.equal(r3.ledger.mnew.stamped, 1);
+ assert.equal(r3.ledger.mnew.attempts, 2);
+ console.log('(d) PASS — backoff suppresses churn; retry after backoff picks up a new mapping');
+
+ // oldest send first: the earlier letter owns the chase date
+ const order = [];
+ await core.runPass({ msgs: [msgs[2], msgs[1], msgs[0]], ledger: {}, now: NOW, dry: true, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60,
+ deps: { ...deps, stamp: async (v, body) => { order.push(body); return { stamped: [] }; } } });
+ assert.deepEqual(order, ['letter mvah', 'letter m1838', 'letter mkra'], 'must process oldest send first');
+ // every letter item already stamped (covered) is finished; stamped:0 with nothing covered is not
+ assert.equal(core.isTerminal({ vids: ['X'], stamped: 0, covered: 1, failed: 0 }), true);
+ assert.equal(core.isTerminal({ vids: ['X'], stamped: 0, covered: 0, failed: 0 }), false);
+ // failed FM commit is NOT terminal even with stamped>=1
+ assert.equal(core.isTerminal({ stamped: 2, failed: 1 }), false);
+ // send-date fallbacks
+ assert.equal(core.sendDateOf({ date: 'Wed, 16 Sep 2026 08:15:00 -0700' }, NOW).source, 'date-header');
+ assert.equal(core.sendDateOf({}, NOW).source, 'fallback-today');
+ // a fetch-failed metadata row is ignored, not ledgered as a skip
+ const r4 = await core.runPass({ msgs: [{ id: 'bad', error: 'fetch-failed' }], ledger: {}, now: NOW, dry: false, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60, deps });
+ assert.equal(r4.ledger.bad, undefined);
+ // a failed body fetch is not an outcome: nothing ledgered, nothing stamped
+ const r6 = await core.runPass({ msgs: [msgs[0]], ledger: {}, now: NOW, dry: false, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60, deps: { ...deps, getBody: async () => null } });
+ assert.equal(r6.ledger.m1838, undefined);
+ // dry run never writes the ledger
+ const r5 = await core.runPass({ msgs, ledger, now: NOW, dry: true, retrySecs: 900, minAgeDays: 10, maxAgeDays: 60, deps });
+ assert.equal(r5.changed, false);
+ assert.deepEqual(r5.ledger, ledger);
+ console.log('(e) PASS — failed commits retried, date fallbacks, fetch-failed ignored, dry never writes');
+
+ // contact whose vendor is absent from fleet.json still maps via the slug-derived vid (Phillip Jeffries)
+ const aliases = require('../lib/slug-aliases.cjs');
+ const pjMap = core.buildEmailVid([], { pj: { sample_email: 'jballard@phillipjeffries.com' } }, { slugAliases: aliases.SLUG_ALIASES, slugForVid: aliases.slugForVid });
+ assert.deepEqual([...pjMap['jballard@phillipjeffries.com']], ['PJ']);
+ assert.deepEqual(core.buildEmailVid([], { pj: { sample_email: 'jballard@phillipjeffries.com' } })['jballard@phillipjeffries.com'], undefined);
+ console.log('(f) PASS — contact-only vendor (PJ) maps via slug-derived vid');
+
+ // NEGATIVE: the pre-fix rule ("any ledger entry suppresses") would have left 1838/Vahallan unstamped
+ const oldRule = e => !!e;
+ assert.equal(oldRule(ledger.m1838) && oldRule(ledger.mvah), true, 'old rule suppresses both poisoned sends');
+ assert.equal(core.shouldProcess(ledger.m1838, { now: NOW, retrySecs: 900 }).go, true);
+ console.log('sent-stamp-retry: ALL PASS');
+})().catch(e => { console.error('FAIL:', e.message); process.exit(1); });
← f631b44 auto-data-snapshot: 2026-09-26T08:19:11 (1 data files) — dat
·
back to Sample Followup Sweep
·
auto-data-snapshot: 2026-09-26T08:50:16 (1 data files) — dat a884b80 →