← back to Sample Followup Sweep

scripts/sent-stamp-core.mjs

220 lines

// 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 already stamped by an earlier send) deliberately does NOT finish a send:
// a sibling item in the same letter may not be visible in FileMaker yet, and retries are cheap.
export function isTerminal(entry) {
  if (!entry || entry.skip || Number(entry.failed) > 0) return false;
  return Number(entry.stamped) >= 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;
    // stamp_aliases: extra recipient addresses recognised as this vendor for STAMPING ONLY — send
    // routing never reads this field, so adding one cannot change who a letter goes to (TK-12351).
    for (const field of [c.sample_email, c.main_email, ...(Array.isArray(c.stamp_aliases) ? c.stamp_aliases : [])]) {
      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.filter(m => m && m.id && !m.error)   // metadata fetch failed — try again next pass
    .sort((a, b) => sendDateOf(a, now).date - sendDateOf(b, now).date);
  const decision = new Map(ordered.map(m => [m.id, shouldProcess(seen[m.id], { now, retrySecs, dry })]));
  // An older non-terminal send in backoff must not let a newer send to the same VENDOR take the
  // chase date first: if any newer send is due, pull every older unfinished one forward with it.
  // TK-12351: "same vendor" = any shared resolved vid, not the literal to: address — one vendor can
  // be written to at two addresses (Lincrusta sample_email vs main_email). Address set is the
  // fallback only when the recipient resolves to no vid.
  const ownerKeys = m => {
    const vids = vidsForRecipient(deps.emailVid, m.to);
    if (vids.length) return vids.map(v => `vid:${v}`);
    return [`to:${(String(m.to || '').toLowerCase().match(/[\w.+-]+@[\w.-]+\.\w+/g) || []).sort().join(',')}`];
  };
  const dueOwners = new Set(ordered.filter(m => decision.get(m.id).go).flatMap(ownerKeys));
  for (const m of ordered) {
    const d = decision.get(m.id);
    if (!d.go && d.why === 'backoff' && ownerKeys(m).some(k => dueOwners.has(k))) decision.set(m.id, { go: true, why: 'retry-ahead-of-newer' });
  }
  for (const m of ordered) {
    const prev = seen[m.id];
    const d = decision.get(m.id);
    if (!d.go) continue;
    const vids = vidsForRecipient(deps.emailVid, m.to);
    const sd = sendDateOf(m, now);
    const sentOn = fmt(sd.date);
    if (!vids.length) {
      unmapped.add(String(m.to || '(no to)'));
      // sent_on kept on skips too, so the dead-letter heartbeat can age an entry after it leaves the search.
      if (!dry) { seen[m.id] = mergeEntry(prev, { skip: 'no vid for recipient', to: m.to || '', subject: m.subject || '', sent_on: sentOn, sent_on_source: sd.source }, now); changed = true; }
      results.push({ id: m.id, why: d.why, skip: 'no vid for recipient', to: m.to || '' });
      continue;
    }
    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) { results.push({ id: m.id, why: d.why, bodyFailed: true, to: m.to || '' }); continue; }   // not an outcome — retried 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 };
}

// ── Dead-letter heartbeat (TK-12351) ────────────────────────────────────────────────────────────
// A send is only retried while the George search (newer_than:SEARCH_DAYS) still returns it. A
// non-terminal entry that ages out is dropped silently — so this reports, for the fleet-health
// rollup, which unresolved sends are about to fall off (or just did), and refuses PASS whenever the
// pass could not see the whole population. Three states, never two: an unmeasured pass is WARN.
const DAY = 86400e3;
const parseSentOn = s => { const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(String(s || '')); return m ? new Date(+m[3], +m[1] - 1, +m[2]).getTime() : NaN; };
const entryState = e => !e ? 'unprocessed' : isTerminal(e) ? 'terminal' : e.skip ? 'skip' : Number(e.failed) > 0 ? 'failed' : 'stamped:0';

// graceMs: a partial pass (Gmail quota 500s hit this watcher several times an hour) is WARN only when no
// FULLY-measured pass has completed within graceMs — the loop re-measures every 30s, so a lone transient
// miss inside the grace is not a finding, while a persistent one (no full measure for 30 min) is.
// Grace covers only the George/Gmail-side misses (search, metadata, body). FileMaker errors, an empty
// population and an unreadable ledger get none: those are not the known-transient quota class.
export function buildHeartbeat({ ledger = {}, msgs = [], results = [], now, searchDays, nearDays = 5, recentDays = 7, searchErr = null, ledgerErr = null, dry = false, lastFullMeasureAt = null, graceMs = 30 * 60e3 }) {
  const good = msgs.filter(m => m && m.id && !m.error);
  const metaFailed = msgs.filter(m => m && m.error).length;
  const seenIds = new Set(good.map(m => m.id));
  const pop = { sends_seen: good.length, terminal: 0, non_terminal: 0, unprocessed: 0, ledger_entries: Object.keys(ledger).length };
  const near_expiry = [];
  for (const m of good) {
    const e = ledger[m.id], state = entryState(e);
    if (state === 'terminal') { pop.terminal++; continue; }
    pop.non_terminal++; if (state === 'unprocessed') pop.unprocessed++;
    const sd = sendDateOf(m, now);
    const daysLeft = searchDays - (now - sd.date.getTime()) / DAY;
    if (daysLeft <= nearDays) near_expiry.push({ id: m.id, to: m.to || e?.to || '', vids: e?.vids || [], sent_on: fmt(sd.date), days_left: +daysLeft.toFixed(1), state, attempts: Number(e?.attempts) || (e ? 1 : 0) });
  }
  const not_measured = [], hard = [];
  if (searchErr) not_measured.push(`George search: ${searchErr}`);
  if (metaFailed) not_measured.push(`${metaFailed} message metadata fetch(es) failed`);
  const bodyFailed = results.filter(r => r.bodyFailed).length;
  if (bodyFailed) not_measured.push(`${bodyFailed} letter body fetch(es) failed`);
  const fmFailed = results.filter(r => Number(r.failed) > 0).length;
  if (fmFailed) hard.push(`${fmFailed} send(s) hit a FileMaker lookup/commit error`);
  if (ledgerErr) hard.push(`stamp-ledger unreadable: ${ledgerErr}`);
  if (!searchErr && good.length === 0) hard.push(`search returned 0 sends in ${searchDays}d — empty population, query or mailbox suspect`);
  const fullMeasure = !not_measured.length && !hard.length;
  const lastFull = fullMeasure ? now : lastFullMeasureAt;
  const withinGrace = lastFull != null && now - lastFull <= graceMs;
  not_measured.push(...hard);
  // Just-expired: only knowable when the search was complete (else "not returned" means nothing).
  const recently_expired = [];
  if (!searchErr && !metaFailed) {
    for (const [id, e] of Object.entries(ledger)) {
      if (seenIds.has(id) || isTerminal(e)) continue;
      const t = Number.isFinite(parseSentOn(e.sent_on)) ? parseSentOn(e.sent_on) : Date.parse(e.first_try || e.at || '');
      if (!Number.isFinite(t)) continue;                   // legacy undated entry — can't age it
      const age = (now - t) / DAY;
      if (age > searchDays && age <= searchDays + recentDays) recently_expired.push({ id, to: e.to || '', vids: e.vids || [], sent_on: e.sent_on || fmt(new Date(t)), state: entryState(e), attempts: Number(e.attempts) || 1 });
    }
  }
  const unmeasuredWarn = hard.length > 0 || (!fullMeasure && !withinGrace);
  const verdict = (unmeasuredWarn || near_expiry.length || recently_expired.length) ? 'WARN' : 'PASS';
  const bits = [`${pop.terminal}/${pop.sends_seen} sends in ${searchDays}d resolved`];
  if (near_expiry.length) bits.push(`${near_expiry.length} unresolved within ${nearDays}d of aging out`);
  if (recently_expired.length) bits.push(`${recently_expired.length} aged out unresolved in last ${recentDays}d`);
  if (not_measured.length) bits.push(`${unmeasuredWarn ? 'NOT MEASURED' : 'partial pass (full measure ' + Math.round((now - lastFull) / 1000) + 's ago)'}: ${not_measured.join('; ')}`);
  return {
    skill: 'sample-followup', source: 'watch-sent-stamp', generated_at: new Date(now).toISOString(), dry,
    verdict, status: verdict, summary: bits.join(' · '),
    full_measure: fullMeasure, last_full_measure_at: lastFull == null ? null : new Date(lastFull).toISOString(),
    search_days: searchDays, near_days: nearDays, population: pop, near_expiry, recently_expired, not_measured,
  };
}

export function failHeartbeat(err, now) {
  return { skill: 'sample-followup', source: 'watch-sent-stamp', generated_at: new Date(now).toISOString(), dry: false,
    verdict: 'FAIL', status: 'FAIL', summary: `pass threw: ${err}`, error: String(err) };
}