← back to Sample Followup Sweep
sample-followup: rewrite sent-poller to match by Acct# from subject + rolling 10-60d window + idempotent (skip already-stamped); install persistent launchd job (30s poll) per Steve — auto-stamps 2nd-request date for Gmail-sent follow-ups
d5a63daa3969603db36abcaaa883c5dba9569232 · 2026-08-25 10:45:22 -0700 · Steve Abrams
Files touched
M data/stamp-ledger.jsonA launchd/com.steve.sample-followup-sent-stamp.plistM scripts/watch-sent-stamp.mjs
Diff
commit d5a63daa3969603db36abcaaa883c5dba9569232
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 25 10:45:22 2026 -0700
sample-followup: rewrite sent-poller to match by Acct# from subject + rolling 10-60d window + idempotent (skip already-stamped); install persistent launchd job (30s poll) per Steve — auto-stamps 2nd-request date for Gmail-sent follow-ups
---
data/stamp-ledger.json | 30 +++++++++
launchd/com.steve.sample-followup-sent-stamp.plist | 31 +++++++++
scripts/watch-sent-stamp.mjs | 74 ++++++++++++----------
3 files changed, 102 insertions(+), 33 deletions(-)
diff --git a/data/stamp-ledger.json b/data/stamp-ledger.json
index 291d44a..cba29ce 100644
--- a/data/stamp-ledger.json
+++ b/data/stamp-ledger.json
@@ -34,5 +34,35 @@
"1a020672852c691a": {
"skip": "no vendor match",
"to": ""
+ },
+ "1a039e35e02f1160": {
+ "acct": "1062465",
+ "stamped": 3,
+ "patterns": [
+ "Rd1583Fr",
+ "RD1975FR",
+ "Rd1956Frlin"
+ ],
+ "to": "Rebecca Cragg <rebecca.cragg@lincrusta.com>",
+ "at": "2026-08-25T17:31:01.882Z"
+ },
+ "1a039e32b7a0e4bd": {
+ "acct": "1062427",
+ "stamped": 2,
+ "patterns": [
+ "MSA6008",
+ "BBPW08"
+ ],
+ "to": "MDC Customer Service <cs@mdcwall.com>",
+ "at": "2026-08-25T17:31:02.623Z"
+ },
+ "1a039e2eb5fc5bed": {
+ "acct": "1062120",
+ "stamped": 1,
+ "patterns": [
+ "RM 410 83"
+ ],
+ "to": "Elitis USA - Annalisa DOMINGUEZ <ANNALISA@elitis.us>",
+ "at": "2026-08-25T17:31:03.003Z"
}
}
\ No newline at end of file
diff --git a/launchd/com.steve.sample-followup-sent-stamp.plist b/launchd/com.steve.sample-followup-sent-stamp.plist
new file mode 100644
index 0000000..c7ad767
--- /dev/null
+++ b/launchd/com.steve.sample-followup-sent-stamp.plist
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>com.steve.sample-followup-sent-stamp</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/opt/homebrew/bin/node</string>
+ <string>/Users/macstudio3/Projects/sample-followup-sweep/scripts/watch-sent-stamp.mjs</string>
+ <string>--loop</string>
+ </array>
+ <key>EnvironmentVariables</key>
+ <dict>
+ <key>PATH</key>
+ <string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+ <key>POLL_SECS</key>
+ <string>30</string>
+ </dict>
+ <key>WorkingDirectory</key>
+ <string>/Users/macstudio3/Projects/sample-followup-sweep</string>
+ <key>StandardOutPath</key>
+ <string>/Users/macstudio3/Projects/sample-followup-sweep/out/watch-sent-stamp.log</string>
+ <key>StandardErrorPath</key>
+ <string>/Users/macstudio3/Projects/sample-followup-sweep/out/watch-sent-stamp.log</string>
+ <key>RunAtLoad</key>
+ <true/>
+ <key>KeepAlive</key>
+ <true/>
+</dict>
+</plist>
diff --git a/scripts/watch-sent-stamp.mjs b/scripts/watch-sent-stamp.mjs
index d9b9366..43a25b6 100644
--- a/scripts/watch-sent-stamp.mjs
+++ b/scripts/watch-sent-stamp.mjs
@@ -12,16 +12,11 @@ const FIELD_SENT = 'Date Email Sent to Vendor after 10 Days';
// Steve 8/25: stamp the 2nd-request DATE on a real send. No dedicated "2nd request date" field is
// exposed to the Data API (only text "Dear Vendor 2nd Request"); this real date field is the chosen target.
const FIELD_2ND_DATE = 'Date Sample Request Letter Sent';
-const WIN = process.env.WIN || '08/06/2026...08/10/2026';
-const POLL_SECS = Number(process.env.POLL_SECS || 45);
-
-// recipient (or a fragment) → the FileMaker vids that vendor's memos live under
-const TO_VIDS = [
- ['bmwallpaper.com', ['BM', 'GREEN']], ['romo.com', ['ROMO']], ['brewp.com', ['BRE']],
- ['oalusa.com', ['OSB']], ['yorkwall.com', ['YOR']], ['kravet.com', ['KRA']],
- ['gimmersta.com', ['SANDB']], ['wallquest.com', ['WQ']], ['thibautdesign.com', ['THIB']],
- ['egg-and-dart.com', ['ARTE']],
-];
+const POLL_SECS = Number(process.env.POLL_SECS || 30);
+// Steve 8/25: match on the ACCOUNT # in the subject ("…(Acct NNNNN)") — works for EVERY vendor with
+// zero hardcoded map, and covers follow-ups sent by hand from Gmail (not just the console/scheduler).
+const MIN_AGE_DAYS = 10; // only chaseable memos (>=10d) — matches the letter contents
+const MAX_AGE_DAYS = 60; // >60 = dead lead, never chased
const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
const fenv = cfg?.mcpServers?.filemaker?.env || {};
@@ -30,7 +25,15 @@ process.env.FM_READONLY = '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 today = (d => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`)(new Date());
+const pad = n => String(n).padStart(2, '0');
+const fmt = d => `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${d.getFullYear()}`;
+const today = fmt(new Date());
+// 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@
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 ''; };
@@ -38,36 +41,41 @@ let auth = genv('GEORGE_BASIC_AUTH'); if (!auth.includes(':')) auth = 'admin:' +
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(); }); }
-async function stampVids(vids) {
- // outstanding (Date WP Sample Sent empty) for these vids in the window → recordIds → stamp
- let n = 0;
- for (const vid of vids) {
- const rows = (await fm.findRecords(DB, 'REPORT ON SAMPLES ORDERED', { 'Date WP Sample Sent': '=', vid, 'today for client': WIN }, { limit: 200 }).catch(() => ({ records: [] }))).records;
- for (const r of rows) {
- // Steve 8/25: on a real send stamp BOTH the dedup guard (Date Email Sent…) AND the
- // 2nd-request date (Date Sample Request Letter Sent — the chosen API-reachable date field).
- await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: today, [FIELD_2ND_DATE]: today }, { dryRun: false }).catch(() => {});
- n++;
- }
+// Stamp the outstanding, NOT-YET-STAMPED memos for one account, in the rolling chase window.
+// Idempotent: FIELD_SENT empty ('=') filter means a memo already stamped is never re-stamped
+// (so re-processing / overlapping emails can't move the 2nd-request date forward). LAYOUT carries
+// account + Date WP Sample Sent + FIELD_SENT + FIELD_2ND_DATE, so no cross-layout hop is needed.
+async function stampAccount(acct, win) {
+ const rows = (await fm.findRecords(DB, LAYOUT,
+ { account: `==${acct}`, 'Date WP Sample Sent': '=', [FIELD_SENT]: '=', 'today for client': win },
+ { limit: 300 }).catch(() => ({ records: [] }))).records;
+ const stamped = [];
+ for (const r of rows) {
+ // Steve 8/25: on a real send stamp BOTH the dedup guard (Date Email Sent…) AND the
+ // 2nd-request date (Date Sample Request Letter Sent — the chosen API-reachable date field).
+ const res = await fm.updateRecord(DB, LAYOUT, r.recordId, { [FIELD_SENT]: today, [FIELD_2ND_DATE]: today }, { dryRun: false }).catch(() => ({}));
+ if (res.committed) stamped.push((r.fieldData['Mfr Pattern'] || r.recordId));
}
- return n;
+ return stamped;
}
async function pass() {
const seen = readJSON(LEDGER, {});
+ const win = chaseWindow();
// NB: the em-dash in the full subject breaks Gmail matching over HTTP — match the ASCII prefix.
- const msgs = await gsearch('in:sent subject:"Sample Follow-Up" newer_than:1d');
- if (process.env.DEBUG) console.log(` [debug] gsearch → ${msgs.length} msg(s); ledger has ${Object.keys(seen).length}`);
+ // newer_than:2d so a poll gap never loses a send; the per-message ledger guards against re-stamping.
+ const msgs = await gsearch('in:sent subject:"Sample Follow-Up" newer_than:2d');
+ if (process.env.DEBUG) console.log(` [debug] gsearch → ${msgs.length} msg(s); win ${win}; ledger has ${Object.keys(seen).length}`);
let acted = 0;
for (const m of msgs) {
if (seen[m.id]) continue;
- const to = String(m.to || m.snippet || '').toLowerCase();
- const hit = TO_VIDS.find(([frag]) => to.includes(frag.toLowerCase()));
- if (!hit) { seen[m.id] = { skip: 'no vendor match', to: m.to || '' }; continue; }
- const stamped = await stampVids(hit[1]);
- seen[m.id] = { stamped, vids: hit[1], to: m.to || '', at: new Date().toISOString() };
- acted += stamped;
- console.log(` ✓ SENT → ${m.to || hit[0]} → stamped ${stamped} memo(s) [${hit[1].join('+')}] 2nd Request=${today}`);
+ const acctM = String(m.subject || '').match(/Acct\s*(\d+)/i); // subject = "…(Acct NNNNN)"
+ if (!acctM) { seen[m.id] = { skip: 'no acct in subject', subject: m.subject || '', to: m.to || '' }; continue; }
+ const acct = acctM[1];
+ const stamped = await stampAccount(acct, win);
+ seen[m.id] = { acct, stamped: stamped.length, patterns: stamped, to: m.to || '', at: new Date().toISOString() };
+ acted += stamped.length;
+ console.log(` ✓ SENT → ${m.to || ''} (Acct ${acct}) → stamped ${stamped.length} memo(s) [${stamped.join(' + ')}] 2ndReq=${today}`);
}
writeFileSync(LEDGER, JSON.stringify(seen, null, 2));
if (!acted) console.log(` (no new sent follow-ups to stamp) — ${new Date().toLocaleTimeString()}`);
@@ -75,6 +83,6 @@ async function pass() {
}
const LOOP = process.argv.includes('--loop');
-console.log(`watch-sent-stamp — window ${WIN} — ${LOOP ? `LOOP every ${POLL_SECS}s` : 'one pass'}`);
+console.log(`watch-sent-stamp — chase window ${chaseWindow()} — ${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)); }
← 29048f5 sample-followup: correct Elitis contact to US rep ANNALISA@e
·
back to Sample Followup Sweep
·
chore: sent-poller stamps per-pass date (midnight-safe for 2 541e2af →