← 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); });