[object Object]

← back to George Gmail

Fleet auto-responder: multipart/alternative HTML+plain reply, structured run-log + state

13d4e3ba67a771583c57f39f9f6df52ae4d355cc · 2026-05-19 10:13:06 -0700 · Steve Abrams

Files touched

Diff

commit 13d4e3ba67a771583c57f39f9f6df52ae4d355cc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 19 10:13:06 2026 -0700

    Fleet auto-responder: multipart/alternative HTML+plain reply, structured run-log + state
---
 fleet-autoresponder.js | 154 +++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 144 insertions(+), 10 deletions(-)

diff --git a/fleet-autoresponder.js b/fleet-autoresponder.js
index b060a9a..8b8cafd 100644
--- a/fleet-autoresponder.js
+++ b/fleet-autoresponder.js
@@ -47,6 +47,8 @@ const ONLY_IDS = (() => {
 })();
 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';
@@ -136,6 +138,26 @@ function repliedRecently(ledger, email) {
   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
 // ─────────────────────────────────────────────────────────────────────────────
@@ -243,16 +265,64 @@ function encodeMimeHeader(str) {
   if (/^[\x00-\x7F]*$/.test(str)) return str;
   return '=?UTF-8?B?' + Buffer.from(str, 'utf8').toString('base64') + '?=';
 }
-function buildReply({ toAddress, originalSubject, fleetDomain, inReplyTo, references }) {
+// 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);
-  const bodyText =
-`Thank you for your inquiry — we've received your message and will jump on your request as soon as possible.
+  return `${THANKYOU_COPY}
 
 ${SIGNATURE_NAME}
 ${name}
 ${PHONE}
 `;
-  const bodyB64 = Buffer.from(bodyText, 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n');
+}
+
+// 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)'}`;
@@ -260,7 +330,11 @@ ${PHONE}
     ? references.join(' ')
     : (inReplyTo || null);
 
-  const lines = [
+  // 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}`,
@@ -274,12 +348,29 @@ ${PHONE}
     'X-Fleet-Autoresponder: v1',
     'Precedence: auto_reply',
     'MIME-Version: 1.0',
-    'Content-Type: text/plain; charset=UTF-8',
-    'Content-Transfer-Encoding: base64',
+    `Content-Type: multipart/alternative; boundary="${boundary}"`,
   ].filter(Boolean);
 
-  const message = lines.join('\r\n') + '\r\n\r\n' + bodyB64;
-  return { raw: Buffer.from(message, 'utf8').toString('base64url'), subject, bodyText };
+  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,
+  };
 }
 
 // ─────────────────────────────────────────────────────────────────────────────
@@ -303,6 +394,19 @@ 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);
@@ -329,6 +433,8 @@ async function main() {
   }
 
   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' });
@@ -346,6 +452,7 @@ async function main() {
     const fleetDomain = recoverFleetDomain(headers);
     if (!fleetDomain) {
       results.skipped.push({ id, reason: 'no recognised fleet plus-tag', tag });
+      rec({ event: 'skip', id, sender: senderEmail || fromRaw, subject, reason: 'no recognised fleet plus-tag' });
       log(`  SKIP ${tag} — no recognised fleet plus-tag`);
       continue;
     }
@@ -354,6 +461,7 @@ async function main() {
     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) {
@@ -364,6 +472,7 @@ async function main() {
 
     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] } });
@@ -374,6 +483,7 @@ async function main() {
     // 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] } });
@@ -391,6 +501,8 @@ async function main() {
     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;
     }
 
@@ -405,6 +517,23 @@ async function main() {
     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}`);
@@ -415,4 +544,9 @@ async function main() {
 if (require.main === module) {
   main().catch(e => { console.error('[fleet-autoresponder] FATAL', e); process.exit(1); });
 }
-module.exports = { main, recoverFleetDomain, safetyGate, buildReply, signoffName, FLEET_DOMAINS, TAG_TO_DOMAIN };
+module.exports = {
+  main, recoverFleetDomain, safetyGate, buildReply, signoffName,
+  buildHtmlBody, buildPlainBody, FLEET_DOMAINS, TAG_TO_DOMAIN,
+  loadState, saveState, loadLedger,
+  RUNLOG_PATH, STATE_PATH, LEDGER_PATH, FLEET,
+};

← 7d77cad Fleet auto-responder: deliveredto query fix + --only-ids sco  ·  back to George Gmail  ·  Fleet auto-responder admin UI dashboard (Express viewer, por fb57ee9 →