← 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 => (
+ { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →