[object Object]

← 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

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 →