← back to George Gmail

fleet-autoresponder.js

606 lines

#!/usr/bin/env node
/**
 * Fleet Auto-Responder — George-side auto-acknowledgment for the 44 dw-domain-fleet domains.
 *
 * WHAT IT DOES
 *   Each fleet domain has a Purelymail catch-all: mail to info@<domain> forwards to
 *   steve+<tag>@designerwallcoverings.com and lands in the central George Gmail
 *   ('steve-office' = steve@designerwallcoverings.com).
 *
 *   When a GENUINE new customer inquiry arrives, this script sends ONE plain warm
 *   acknowledgment FROM info@designerwallcoverings.com TO the original sender, with
 *   Reply-To set to info@<that-fleet-domain> so customer replies route back correctly.
 *
 * SAFETY
 *   - Never replies to Spam, no-reply/bounce senders, bulk/auto/list mail.
 *   - Never replies to mail from designerwallcoverings.com or any of the 44 fleet
 *     domains themselves (loop prevention).
 *   - One reply per sender address per rolling 7-day window (persisted ledger).
 *   - Marks every processed message with the Gmail label "fleet-autoreplied" so it
 *     is never double-replied. Idempotent on re-run.
 *
 * USAGE
 *   node fleet-autoresponder.js                  # process inbox once, send replies
 *   node fleet-autoresponder.js --daemon         # long-lived, self-schedules every
 *                                                # FLEET_INTERVAL_MS (default 3 min)
 *   node fleet-autoresponder.js --dry-run        # detect + log, send nothing
 *   node fleet-autoresponder.js --force          # run even if the UI toggle is OFF
 *   node fleet-autoresponder.js --only-ids a,b   # restrict to specific Gmail msg ids
 *
 * The acknowledgment is multipart/alternative (HTML + plain-text fallback).
 * An on/off toggle + run-log + last-run state are persisted under data/ for the
 * companion admin UI (fleet-autoresponder-ui.js, 127.0.0.1:9768).
 *
 * This script reuses George's OAuth credentials (DW-MCP/.env). It does NOT need
 * the George server running and makes no prod deploy.
 */
'use strict';

const fs = require('fs');
const path = require('path');
const { google } = require('googleapis');

// ─────────────────────────────────────────────────────────────────────────────
// Config
// ─────────────────────────────────────────────────────────────────────────────
const DRY_RUN = process.argv.includes('--dry-run');
// --only-ids a,b,c  → restrict processing to these exact Gmail message ids.
// Used for the scoped 2-domain test so a backlog of probe mail is not touched.
const ONLY_IDS = (() => {
  const i = process.argv.indexOf('--only-ids');
  if (i === -1 || !process.argv[i + 1]) return null;
  return new Set(process.argv[i + 1].split(',').map(s => s.trim()).filter(Boolean));
})();
const PROCESSED_LABEL = 'fleet-autoreplied';
const LEDGER_PATH = path.join(__dirname, 'data', 'fleet-autoresponder-ledger.json');
const RUNLOG_PATH = path.join(__dirname, 'data', 'fleet-autoresponder-runlog.jsonl');
const STATE_PATH = path.join(__dirname, 'data', 'fleet-autoresponder-state.json');
const DEDUP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const PHONE = '888-373-4564';
const SIGNATURE_NAME = 'Steve';
const SENDER_FROM = 'Designer Wallcoverings <info@designerwallcoverings.com>';
const SEND_ACCOUNT = 'info';      // George account that physically sends
const READ_ACCOUNT = 'steve-office'; // George account whose inbox receives the forwards
// How far back to scan for inquiries. Gmail's newer_than granularity is days;
// override with FLEET_SCAN_WINDOW (e.g. "2h", "1d", "14d") for tighter scoping.
const SCAN_WINDOW = process.env.FLEET_SCAN_WINDOW || '14d';

// ─────────────────────────────────────────────────────────────────────────────
// Fleet domain list — loaded from dw-domain-fleet/sites/*.json
// ─────────────────────────────────────────────────────────────────────────────
function loadFleetDomains() {
  const sitesDir = path.join(process.env.HOME || '', 'Projects/dw-domain-fleet/sites');
  const out = [];
  for (const f of fs.readdirSync(sitesDir)) {
    if (!f.endsWith('.json')) continue;
    try {
      const j = JSON.parse(fs.readFileSync(path.join(sitesDir, f), 'utf8'));
      if (j.domain) out.push(j);
    } catch { /* skip malformed */ }
  }
  return out;
}
const FLEET = loadFleetDomains();
const FLEET_DOMAINS = new Set(FLEET.map(s => s.domain.toLowerCase()));
// tag -> domain map. Purelymail plus-tag = domain with dots replaced by dashes.
// We also accept the literal-dot form for robustness.
const TAG_TO_DOMAIN = new Map();
for (const s of FLEET) {
  const d = s.domain.toLowerCase();
  TAG_TO_DOMAIN.set(d.replace(/\./g, '-'), d); // dashes form  e.g. barwallpaper-com
  TAG_TO_DOMAIN.set(d, d);                     // literal-dot form e.g. barwallpaper.com
}
// Display name for the sign-off — exact domain, capitalised first letter.
function signoffName(domain) {
  return domain.charAt(0).toUpperCase() + domain.slice(1);
}

// ─────────────────────────────────────────────────────────────────────────────
// OAuth — reuse George's credential loading (DW-MCP/.env)
// ─────────────────────────────────────────────────────────────────────────────
function loadCreds() {
  const tryPaths = [
    process.env.DW_MCP_ENV,
    path.join(process.env.HOME || '', 'Projects/Designer-Wallcoverings/DW-MCP/.env'),
    '/root/Projects/Designer-Wallcoverings/DW-MCP/.env',
  ].filter(Boolean);
  let envPath = tryPaths[tryPaths.length - 1];
  for (const p of tryPaths) {
    try { fs.accessSync(p, fs.constants.R_OK); envPath = p; break; } catch {}
  }
  const creds = {};
  try {
    fs.readFileSync(envPath, 'utf8').split('\n').forEach(line => {
      const m = line.match(/^([^#=]+)=(.+)/);
      if (m) creds[m[1].trim()] = m[2].trim();
    });
  } catch (e) {
    throw new Error(`Cannot load George credentials from ${envPath}: ${e.message}`);
  }
  return creds;
}

function gmailClient(refreshToken, creds) {
  const oauth = new google.auth.OAuth2(
    creds.GMAIL_CLIENT_ID, creds.GMAIL_CLIENT_SECRET,
    'http://localhost:9850/oauth2callback');
  oauth.setCredentials({ refresh_token: refreshToken });
  return google.gmail({ version: 'v1', auth: oauth });
}

// ─────────────────────────────────────────────────────────────────────────────
// Ledger — who has been replied to, and when (rolling 7-day dedup)
// ─────────────────────────────────────────────────────────────────────────────
function loadLedger() {
  try { return JSON.parse(fs.readFileSync(LEDGER_PATH, 'utf8')); }
  catch { return { replies: {} }; }
}
function saveLedger(ledger) {
  fs.mkdirSync(path.dirname(LEDGER_PATH), { recursive: true });
  fs.writeFileSync(LEDGER_PATH, JSON.stringify(ledger, null, 2));
}
function repliedRecently(ledger, email) {
  const ts = ledger.replies[email.toLowerCase()];
  return ts && (Date.now() - ts) < DEDUP_WINDOW_MS;
}

// ─────────────────────────────────────────────────────────────────────────────
// Run log + responder state — the structured feed the UI dashboard reads.
//   runlog : append-only JSONL, one record per processed message per run.
//   state  : single JSON object — last-run time, on/off toggle, run counters.
// ─────────────────────────────────────────────────────────────────────────────
function loadState() {
  try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); }
  catch { return { enabled: true, lastRun: null, totalRuns: 0 }; }
}
function saveState(state) {
  fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true });
  fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
}
// Append a batch of run-log records as JSONL.
function appendRunLog(records) {
  if (!records.length) return;
  fs.mkdirSync(path.dirname(RUNLOG_PATH), { recursive: true });
  fs.appendFileSync(RUNLOG_PATH, records.map(r => JSON.stringify(r)).join('\n') + '\n');
}

// ─────────────────────────────────────────────────────────────────────────────
// Header helpers
// ─────────────────────────────────────────────────────────────────────────────
function headerVal(headers, name) {
  const h = (headers || []).find(x => x.name.toLowerCase() === name.toLowerCase());
  return h ? h.value : null;
}
function allHeaderVals(headers, name) {
  return (headers || []).filter(x => x.name.toLowerCase() === name.toLowerCase()).map(x => x.value);
}
// Extract a bare email address from a From/To header value.
function parseAddress(raw) {
  if (!raw) return null;
  const m = raw.match(/<([^>]+)>/);
  const addr = (m ? m[1] : raw).trim().toLowerCase();
  return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr) ? addr : null;
}
function parseDisplayName(raw) {
  if (!raw) return '';
  const m = raw.match(/^\s*"?([^"<]*?)"?\s*<[^>]+>/);
  return m ? m[1].trim() : '';
}

// ─────────────────────────────────────────────────────────────────────────────
// Spam / loop safety gate. Returns { ok:true } or { ok:false, reason }
// ─────────────────────────────────────────────────────────────────────────────
// Infrastructure-probe subject patterns. The fleet is exercised by automated
// delivery/forwarding probes (RETEST-, FLEETMAILTEST-, DTEST-, PMRCV- tokens,
// and "diagnostic"/"receive probe" subjects). They look like real inquiries
// but must never be auto-acknowledged. This is defense-in-depth on top of the
// fleet-autoreplied labelling of any known backlog.
const PROBE_SUBJECT = /\b(RETEST|FLEETMAILTEST|DTEST|PMRCV)[-\s]|\b(forwarding|delivery|receive)\s+(diagnostic|probe|re-?test)\b|\bautomated\s+(inbound\s+)?email\s+(delivery\s+)?(re-?test|diagnostic)\b/i;

function safetyGate(msg, headers, senderEmail) {
  const labelIds = msg.labelIds || [];

  // 1. Spam folder — never reply.
  if (labelIds.includes('SPAM')) return { ok: false, reason: 'message in SPAM' };

  // 1b. Infrastructure probe — subject matches a known probe token/phrase.
  const subj = headerVal(headers, 'Subject') || '';
  if (PROBE_SUBJECT.test(subj)) {
    return { ok: false, reason: `infrastructure probe subject: "${subj}"` };
  }

  // 2. no-reply / system senders.
  const NO_REPLY = /(^|[._-])(no-?reply|donotreply|do-not-reply|mailer-daemon|postmaster|bounce|bounces)([._-]|@)/i;
  if (senderEmail && NO_REPLY.test(senderEmail)) {
    return { ok: false, reason: `system/no-reply sender: ${senderEmail}` };
  }

  // 3. Auto-Submitted header present and not "no".
  const autoSub = headerVal(headers, 'Auto-Submitted');
  if (autoSub && autoSub.trim().toLowerCase() !== 'no') {
    return { ok: false, reason: `Auto-Submitted: ${autoSub}` };
  }

  // 4. Precedence bulk/list/junk.
  const prec = headerVal(headers, 'Precedence');
  if (prec && /\b(bulk|list|junk)\b/i.test(prec)) {
    return { ok: false, reason: `Precedence: ${prec}` };
  }

  // 5. List mail — List-Unsubscribe / List-Id.
  if (headerVal(headers, 'List-Unsubscribe') || headerVal(headers, 'List-Id')) {
    return { ok: false, reason: 'list mail (List-Unsubscribe/List-Id present)' };
  }

  // 6. Loop prevention — sender is designerwallcoverings.com or any fleet domain.
  if (senderEmail) {
    const dom = senderEmail.split('@')[1] || '';
    if (dom === 'designerwallcoverings.com') {
      return { ok: false, reason: 'sender is designerwallcoverings.com (loop guard)' };
    }
    if (FLEET_DOMAINS.has(dom)) {
      return { ok: false, reason: `sender is fleet domain ${dom} (loop guard)` };
    }
  }

  // 7. Our own auto-reply marker — never reply to a message we generated.
  if (headerVal(headers, 'X-Fleet-Autoresponder')) {
    return { ok: false, reason: 'message carries X-Fleet-Autoresponder header (loop guard)' };
  }

  return { ok: true };
}

// ─────────────────────────────────────────────────────────────────────────────
// Recover which fleet domain an inquiry came in on, from the plus-tag.
// Forwards land addressed to steve+<tag>@designerwallcoverings.com — the tag may
// appear in To, Delivered-To, X-Original-To, or X-Forwarded-To.
// ─────────────────────────────────────────────────────────────────────────────
function recoverFleetDomain(headers) {
  const candidates = [
    ...allHeaderVals(headers, 'Delivered-To'),
    ...allHeaderVals(headers, 'X-Original-To'),
    ...allHeaderVals(headers, 'X-Forwarded-To'),
    ...allHeaderVals(headers, 'To'),
    ...allHeaderVals(headers, 'Cc'),
  ];
  for (const c of candidates) {
    // match steve+<tag>@designerwallcoverings.com  (tag = [a-z0-9.-]+)
    const re = /steve\+([a-z0-9.\-]+)@designerwallcoverings\.com/ig;
    let m;
    while ((m = re.exec(c.toLowerCase())) !== null) {
      const tag = m[1];
      if (TAG_TO_DOMAIN.has(tag)) return TAG_TO_DOMAIN.get(tag);
    }
  }
  return null;
}

// ─────────────────────────────────────────────────────────────────────────────
// Build the acknowledgment as raw RFC 2822 MIME (full header control incl. Reply-To)
// ─────────────────────────────────────────────────────────────────────────────
function encodeMimeHeader(str) {
  // RFC 2047 — only encode if non-ASCII present.
  if (/^[\x00-\x7F]*$/.test(str)) return str;
  return '=?UTF-8?B?' + Buffer.from(str, 'utf8').toString('base64') + '?=';
}
// The warm copy — shared by both the plain-text and HTML parts.
const THANKYOU_COPY =
  "Thank you for your inquiry — we've received your message and will jump on " +
  "your request as soon as possible.";

// HTML-escape a string for safe interpolation into the HTML part.
function escapeHtml(s) {
  return String(s || '').replace(/[&<>"']/g, c => (
    { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

// Plain-text body — the deliverability-friendly fallback.
function buildPlainBody(fleetDomain) {
  const name = signoffName(fleetDomain);
  return `${THANKYOU_COPY}

${SIGNATURE_NAME}
${name}
${PHONE}
`;
}

// HTML body — LIGHT, inline-styled, no external images, no heavy markup.
// Branded but minimal: site name header, the thank-you, and the sign-off block.
function buildHtmlBody(fleetDomain) {
  const name = escapeHtml(signoffName(fleetDomain));
  return `<!DOCTYPE html>
<html lang="en">
<body style="margin:0;padding:0;background:#faf7f2;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#faf7f2;">
<tr><td align="center" style="padding:32px 16px;">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;width:100%;background:#ffffff;border:1px solid #e6ddcf;">
<tr><td style="padding:24px 32px 16px;border-bottom:1px solid #e6ddcf;">
<div style="font-family:Georgia,'Times New Roman',serif;font-size:20px;color:#7d3c00;letter-spacing:.5px;">${name}</div>
</td></tr>
<tr><td style="padding:24px 32px;font-family:Georgia,'Times New Roman',serif;font-size:15px;line-height:1.6;color:#1a1714;">
<p style="margin:0 0 20px;">${escapeHtml(THANKYOU_COPY)}</p>
<p style="margin:0;color:#1a1714;">
${escapeHtml(SIGNATURE_NAME)}<br>
<span style="color:#7d3c00;">${name}</span><br>
<a href="tel:${PHONE.replace(/-/g, '')}" style="color:#1a1714;text-decoration:none;">${PHONE}</a>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}

// Fold base64 into 76-char lines per RFC 2045.
function foldB64(s) {
  return Buffer.from(s, 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n');
}

function buildReply({ toAddress, originalSubject, fleetDomain, inReplyTo, references }) {
  const bodyText = buildPlainBody(fleetDomain);
  const bodyHtml = buildHtmlBody(fleetDomain);
  const subject = originalSubject && /^re:/i.test(originalSubject.trim())
    ? originalSubject.trim()
    : `Re: ${originalSubject || '(your inquiry)'}`;
  const refsHeader = references && references.length
    ? references.join(' ')
    : (inReplyTo || null);

  // multipart/alternative — plain-text first (fallback), HTML second (preferred).
  const boundary = '----=_fleet_' + Date.now().toString(36) +
    Math.random().toString(36).slice(2, 10);

  const headerLines = [
    `From: ${SENDER_FROM}`,
    `To: ${toAddress}`,
    `Reply-To: info@${fleetDomain}`,
    `Subject: ${encodeMimeHeader(subject)}`,
    inReplyTo ? `In-Reply-To: ${inReplyTo}` : null,
    refsHeader ? `References: ${refsHeader}` : null,
    // Loop-prevention headers — mark this as an auto-reply so neither this
    // responder nor any other autoresponder treats it as a fresh inquiry.
    'Auto-Submitted: auto-replied',
    'X-Auto-Response-Suppress: All',
    'X-Fleet-Autoresponder: v1',
    'Precedence: auto_reply',
    'MIME-Version: 1.0',
    `Content-Type: multipart/alternative; boundary="${boundary}"`,
  ].filter(Boolean);

  const bodyParts = [
    `--${boundary}`,
    'Content-Type: text/plain; charset=UTF-8',
    'Content-Transfer-Encoding: base64',
    '',
    foldB64(bodyText),
    `--${boundary}`,
    'Content-Type: text/html; charset=UTF-8',
    'Content-Transfer-Encoding: base64',
    '',
    foldB64(bodyHtml),
    `--${boundary}--`,
    '',
  ].join('\r\n');

  const message = headerLines.join('\r\n') + '\r\n\r\n' + bodyParts;
  return {
    raw: Buffer.from(message, 'utf8').toString('base64url'),
    subject, bodyText, bodyHtml,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// Label management
// ─────────────────────────────────────────────────────────────────────────────
async function ensureLabel(gmail, name) {
  const list = await gmail.users.labels.list({ userId: 'me' });
  const found = (list.data.labels || []).find(l => l.name === name);
  if (found) return found.id;
  const created = await gmail.users.labels.create({
    userId: 'me',
    requestBody: { name, labelListVisibility: 'labelShow', messageListVisibility: 'show' },
  });
  return created.data.id;
}

// ─────────────────────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────────────────────
async function main() {
  const log = (...a) => console.log(new Date().toISOString(), ...a);
  log(`[fleet-autoresponder] start ${DRY_RUN ? '(DRY RUN)' : ''} — ${FLEET.length} fleet domains loaded`);

  const runStartedAt = new Date().toISOString();
  const state = loadState();

  // On/off toggle — the UI can pause the responder by flipping state.enabled.
  // --force overrides the toggle (used for the gated re-test).
  if (state.enabled === false && !process.argv.includes('--force')) {
    log('[fleet-autoresponder] responder is DISABLED via state toggle — exiting (no mail processed)');
    state.lastRun = runStartedAt;
    state.lastRunResult = { disabled: true, replied: 0, skipped: 0 };
    saveState(state);
    return { replied: [], skipped: [], disabled: true };
  }

  const creds = loadCreds();
  const officeGmail = gmailClient(creds.GMAIL_REFRESH_TOKEN, creds);
  const infoGmail = gmailClient(creds.INFO_REFRESH_TOKEN, creds);

  // Label lives on the steve-office mailbox (where the forwards land).
  const labelId = await ensureLabel(officeGmail, PROCESSED_LABEL);
  log(`[fleet-autoresponder] label "${PROCESSED_LABEL}" id=${labelId}`);

  const ledger = loadLedger();

  // Candidate inquiries: unprocessed recent inbox mail.
  //
  // NOTE: a bare `deliveredto:steve+` does NOT work as a Gmail search prefix —
  // Gmail will not prefix-match a partial plus-address, so that query returns
  // zero. Instead we scan all recent unprocessed INBOX mail and let
  // recoverFleetDomain() — which reads the real Delivered-To header — decide
  // which messages actually came in on a fleet plus-tag. Non-fleet mail is
  // skipped immediately ("no recognised fleet plus-tag"). INBOX scope excludes
  // SPAM; the safety gate handles the rest.
  const q = `in:inbox -label:${PROCESSED_LABEL} newer_than:${SCAN_WINDOW}`;
  let ids;
  if (ONLY_IDS) {
    ids = [...ONLY_IDS];
    log(`[fleet-autoresponder] --only-ids set — processing exactly ${ids.length} message(s): ${ids.join(', ')}`);
  } else {
    const list = await officeGmail.users.messages.list({ userId: 'me', q, maxResults: 100 });
    ids = (list.data.messages || []).map(m => m.id);
    log(`[fleet-autoresponder] ${ids.length} candidate message(s) matched query: ${q}`);
  }

  const results = { replied: [], skipped: [] };
  const runlogRecords = [];
  const rec = (o) => runlogRecords.push({ ts: new Date().toISOString(), run: runStartedAt, ...o });

  for (const id of ids) {
    const full = await officeGmail.users.messages.get({ userId: 'me', id, format: 'full' });
    const msg = full.data;
    const headers = msg.payload?.headers || [];
    const fromRaw = headerVal(headers, 'From');
    const senderEmail = parseAddress(fromRaw);
    const subject = headerVal(headers, 'Subject') || '';
    const rfcMessageId = headerVal(headers, 'Message-Id') || headerVal(headers, 'Message-ID');
    const refsHdr = headerVal(headers, 'References');

    const tag = `${id} <${senderEmail || fromRaw}> "${subject}"`;

    // Recover the fleet domain.
    const fleetDomain = recoverFleetDomain(headers);
    if (!fleetDomain) {
      // Non-fleet mail (newsletters, DMARC reports, order notifications, etc.).
      // Not an inquiry at all — counted in the console result but deliberately
      // NOT written to the UI run-log, which tracks fleet inquiries only.
      results.skipped.push({ id, reason: 'no recognised fleet plus-tag', tag });
      log(`  SKIP ${tag} — no recognised fleet plus-tag`);
      continue;
    }

    // Safety gate.
    const gate = safetyGate(msg, headers, senderEmail);
    if (!gate.ok) {
      results.skipped.push({ id, reason: gate.reason, tag, fleetDomain });
      rec({ event: 'skip', id, sender: senderEmail || fromRaw, subject, fleetDomain, reason: gate.reason });
      log(`  SKIP ${tag} — ${gate.reason}`);
      // Label it so we don't re-evaluate every run (it is decided, permanently).
      if (!DRY_RUN) {
        await officeGmail.users.messages.modify({ userId: 'me', id, requestBody: { addLabelIds: [labelId] } });
      }
      continue;
    }

    if (!senderEmail) {
      results.skipped.push({ id, reason: 'unparseable sender', tag });
      rec({ event: 'skip', id, sender: fromRaw, subject, fleetDomain, reason: 'unparseable sender' });
      log(`  SKIP ${tag} — unparseable sender`);
      if (!DRY_RUN) {
        await officeGmail.users.messages.modify({ userId: 'me', id, requestBody: { addLabelIds: [labelId] } });
      }
      continue;
    }

    // 7-day per-sender dedup.
    if (repliedRecently(ledger, senderEmail)) {
      results.skipped.push({ id, reason: `already replied to ${senderEmail} within 7d`, tag, fleetDomain });
      rec({ event: 'skip', id, sender: senderEmail, subject, fleetDomain, reason: `already replied to ${senderEmail} within 7d (dedup)` });
      log(`  SKIP ${tag} — already replied within 7d`);
      if (!DRY_RUN) {
        await officeGmail.users.messages.modify({ userId: 'me', id, requestBody: { addLabelIds: [labelId] } });
      }
      continue;
    }

    // Build + send the reply.
    const references = [refsHdr, rfcMessageId].filter(Boolean);
    const { raw, subject: replySubject, bodyText } =
      buildReply({ toAddress: senderEmail, originalSubject: subject, fleetDomain,
                   inReplyTo: rfcMessageId, references });

    log(`  REPLY ${tag} — domain=${fleetDomain} → ${replySubject}`);
    if (DRY_RUN) {
      log(`  [dry-run] would send:\n--- reply body ---\n${bodyText}------------------`);
      results.replied.push({ id, senderEmail, fleetDomain, replySubject, dryRun: true });
      rec({ event: 'reply', id, sender: senderEmail, recipient: senderEmail, subject: replySubject,
            fleetDomain, dryRun: true });
      continue;
    }

    const sendRes = await infoGmail.users.messages.send({ userId: 'me', requestBody: { raw } });
    log(`  SENT  id=${sendRes.data.id} from info@designerwallcoverings.com to ${senderEmail}`);

    // Record in ledger + label the source message (idempotency).
    ledger.replies[senderEmail.toLowerCase()] = Date.now();
    saveLedger(ledger);
    await officeGmail.users.messages.modify({ userId: 'me', id, requestBody: { addLabelIds: [labelId] } });

    results.replied.push({
      id, senderEmail, fleetDomain, replySubject, sentMessageId: sendRes.data.id,
    });
    rec({ event: 'reply', id, sender: senderEmail, recipient: senderEmail, subject: replySubject,
          fleetDomain, sentMessageId: sendRes.data.id });
  }

  // Persist the structured run-log + state for the UI dashboard.
  if (!DRY_RUN) {
    appendRunLog(runlogRecords);
    state.lastRun = runStartedAt;
    state.lastRunFinished = new Date().toISOString();
    state.totalRuns = (state.totalRuns || 0) + 1;
    state.lastRunResult = {
      candidates: ids.length,
      replied: results.replied.length,
      skipped: results.skipped.length,
    };
    if (state.enabled === undefined) state.enabled = true;
    saveState(state);
  }

  log(`[fleet-autoresponder] done — replied:${results.replied.length} skipped:${results.skipped.length}`);
  console.log(JSON.stringify(results, null, 2));
  return results;
}

// ─────────────────────────────────────────────────────────────────────────────
// Daemon mode — `--daemon` keeps the process alive and runs main() on an
// internal interval. This is the pm2-managed schedule: pm2 keeps the process
// `online`, the process self-schedules. (pm2 cron_restart can't restart a
// short-lived process that has already exited, so a long-lived loop is used.)
//   FLEET_INTERVAL_MS overrides the default 3-minute cadence.
// ─────────────────────────────────────────────────────────────────────────────
async function daemon() {
  const intervalMs = parseInt(process.env.FLEET_INTERVAL_MS || String(3 * 60 * 1000), 10);
  console.log(new Date().toISOString(),
    `[fleet-autoresponder] daemon mode — running every ${Math.round(intervalMs / 1000)}s`);
  let running = false;
  const tick = async () => {
    if (running) return; // never overlap runs
    running = true;
    try { await main(); }
    catch (e) { console.error(new Date().toISOString(), '[fleet-autoresponder] tick error', e.message); }
    finally { running = false; }
  };
  await tick();                       // run immediately on boot
  setInterval(tick, intervalMs);      // …then every interval
}

if (require.main === module) {
  if (process.argv.includes('--daemon')) {
    daemon();
  } else {
    main().catch(e => { console.error('[fleet-autoresponder] FATAL', e); process.exit(1); });
  }
}
module.exports = {
  main, recoverFleetDomain, safetyGate, buildReply, signoffName,
  buildHtmlBody, buildPlainBody, FLEET_DOMAINS, TAG_TO_DOMAIN,
  loadState, saveState, loadLedger,
  RUNLOG_PATH, STATE_PATH, LEDGER_PATH, FLEET,
};