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