← back to Sample Followup Sweep
scripts/poll-replies.mjs
125 lines
#!/usr/bin/env node
// Reply poller + 3-business-day resend queue for the sample follow-up loop.
//
// For every already-sent vendor:
// 1. Search the info@ mailbox (via George HTTP) for a reply FROM the vendor's
// sample email since the follow-up went out.
// • reply found → record it in data/replies.json (snippet kept so a plain-language
// "Priority Memo Notes" summary can be written to FileMaker).
// • no reply → if ≥3 BUSINESS days have passed (weekends excluded), add the vendor
// to data/resend-queue.json so the console can offer a ONE-CLICK resend.
//
// Read-only against mail; it never sends. The resend itself stays the human's one click
// on the console (/api/send-one). Run on a schedule (launchd) or ad hoc:
// node scripts/poll-replies.mjs
// node scripts/poll-replies.mjs --biz 3 # override the business-day threshold
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import http from 'node:http';
const __dir = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dir, '..');
const GEORGE = { host: '127.0.0.1', port: 9850 };
const BIZ_THRESHOLD = Number((process.argv.find((a, i) => process.argv[i - 1] === '--biz')) || 3);
const readJSON = (f, d) => { try { return JSON.parse(readFileSync(join(ROOT, f), 'utf8')); } catch { return d; } };
const norm = (s) => String(s || '').toLowerCase().trim();
// --- George Basic auth, from the same .env files server.js reads ---
function georgeAuth() {
const files = ['/Users/macstudio3/Projects/Designer-Wallcoverings/DW-MCP/.env', '/Users/macstudio3/Projects/george-gmail/.env'];
const genv = (k) => { for (const f of files) { try { const m = readFileSync(f, 'utf8').match(new RegExp('^' + k + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {} } return ''; };
let a = genv('GEORGE_BASIC_AUTH'); if (!a.includes(':')) a = 'admin:' + genv('GEORGE_BASIC_AUTH_PASS');
return 'Basic ' + Buffer.from(a).toString('base64');
}
function georgeGet(path) {
return new Promise((resolve, reject) => {
const req = http.request({ ...GEORGE, path, method: 'GET', headers: { Authorization: georgeAuth() } }, (r) => {
let b = ''; r.on('data', (d) => b += d); r.on('end', () => { try { resolve(JSON.parse(b)); } catch { resolve(null); } });
});
req.on('error', reject); req.end();
});
}
// --- business-day math (weekends excluded) ---
function businessDaysSince(iso, now = new Date()) {
const from = new Date(iso); let d = new Date(from); let n = 0;
while (true) {
d.setDate(d.getDate() + 1);
if (d > now) break;
const dow = d.getDay(); if (dow !== 0 && dow !== 6) n++; // skip Sun(0)/Sat(6)
}
return n;
}
// --- sent-vendor roster (same logic as fmpro.mjs) ---
function sentVendors() {
const fleet = readJSON('data/fleet.json', { vendors: [] }).vendors;
const contacts = readJSON('data/contacts.json', {});
const sent = readJSON('data/sent.json', { byEmail: {} }).byEmail || {};
const sentMap = {}; for (const [k, v] of Object.entries(sent)) sentMap[norm(k)] = v;
const out = [];
for (const v of fleet) {
const c = contacts[v.slug] || {};
if (c.disposition === 'no-chase') continue;
const addrs = norm(c.sample_email).split(',').map((a) => a.trim()).filter((a) => a.includes('@'));
const hits = addrs.map((a) => ({ a, s: sentMap[a] })).filter((x) => x.s);
if (!hits.length) continue;
const lastISO = hits.map((h) => h.s.lastSent).sort().pop();
out.push({ slug: v.slug, vid: v.vid, name: v.name, emails: addrs, lastSent: lastISO });
}
return out;
}
// --- did the vendor reply since we sent? (search info@ mailbox) ---
async function findReply(emails, sinceISO) {
const since = new Date(sinceISO);
const dateQ = `${since.getFullYear()}/${since.getMonth() + 1}/${since.getDate()}`;
for (const email of emails) {
const q = encodeURIComponent(`from:${email} after:${dateQ}`);
const res = await georgeGet(`/api/info/search?q=${q}`).catch(() => null);
const msgs = res && (res.messages || res.results || (Array.isArray(res) ? res : null));
if (Array.isArray(msgs) && msgs.length) {
const m = msgs[0];
return { email, snippet: (m.snippet || m.subject || '').slice(0, 300), messageId: m.id || m.messageId || '' };
}
}
return null;
}
const run = async () => {
const vendors = sentVendors();
const replies = readJSON('data/replies.json', { byVid: {} });
const resend = { generated: new Date().toISOString(), bizThreshold: BIZ_THRESHOLD, queue: [] };
let georgeUp = true;
// probe George once
const probe = await georgeGet('/api/info/messages?maxResults=1').catch(() => null);
if (probe === null) { georgeUp = false; console.error('⚠ George unreachable — reply detection skipped; NOT queuing resends (can\'t confirm no-reply).'); }
for (const v of vendors) {
const bdays = businessDaysSince(v.lastSent);
let replied = replies.byVid[v.vid]?.replied || false;
let reply = replies.byVid[v.vid] || null;
if (georgeUp && !replied) {
const found = await findReply(v.emails, v.lastSent);
if (found) { replied = true; reply = { replied: true, ...found, foundAt: new Date().toISOString(), lastSent: v.lastSent }; replies.byVid[v.vid] = reply; }
}
const status = replied ? 'replied' : (bdays >= BIZ_THRESHOLD ? 'resend-due' : 'waiting');
console.log(` ${status.padEnd(11)} ${v.name.padEnd(30)} [${v.vid}] ${bdays} biz-day(s) since ${v.lastSent.slice(0,10)}${replied ? ' — reply: ' + (reply.snippet||'').slice(0,60) : ''}`);
// Queue a resend only when we KNOW there's no reply and the window has elapsed.
if (georgeUp && !replied && bdays >= BIZ_THRESHOLD) {
resend.queue.push({ slug: v.slug, vid: v.vid, name: v.name, to: v.emails.join(', '), lastSent: v.lastSent, bizDays: bdays });
}
}
mkdirSync(join(ROOT, 'data'), { recursive: true });
writeFileSync(join(ROOT, 'data', 'replies.json'), JSON.stringify(replies, null, 2));
writeFileSync(join(ROOT, 'data', 'resend-queue.json'), JSON.stringify(resend, null, 2));
console.log(`\nReplies recorded: ${Object.values(replies.byVid).filter((r) => r.replied).length} · Resend-due (≥${BIZ_THRESHOLD} biz days, no reply): ${resend.queue.length}`);
console.log(`→ data/replies.json · data/resend-queue.json (console shows the one-click resend flags)`);
if (replies.byVid && Object.values(replies.byVid).some((r) => r.replied))
console.log(`Tip: summarize each reply into FileMaker Priority Memo Notes → node scripts/fmpro.mjs note --slug <slug> --sku <DW#> --text "Discontinued | Sending now | Already sent 7/30"`);
};
run().catch((e) => { console.error('poll-replies error:', e.message); process.exit(1); });