← back to George Gmail
Add fleet auto-responder for the 44 dw-domain-fleet domains
d135118a7396699d586eddf748a9383c13595444 · 2026-05-19 10:03:30 -0700 · Steve Abrams
Sends one warm acknowledgment from info@designerwallcoverings.com to genuine
new customer inquiries arriving at info@<fleet-domain> (forwarded into the
steve-office Gmail via Purelymail catch-all + plus-tag). Reply-To is set to
info@<fleet-domain> so customer replies route back to the right site.
Spam-safety: skips Spam, no-reply/bounce senders, bulk/auto/list mail, and any
mail from designerwallcoverings.com or a fleet domain (loop guard). One reply
per sender per rolling 7-day window (persisted ledger). Marks every processed
message with the 'fleet-autoreplied' Gmail label — idempotent on re-run.
Reuses George's DW-MCP/.env OAuth credentials; no prod deploy needed.
Not yet scheduled — run manually with: node fleet-autoresponder.js
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit d135118a7396699d586eddf748a9383c13595444
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 19 10:03:30 2026 -0700
Add fleet auto-responder for the 44 dw-domain-fleet domains
Sends one warm acknowledgment from info@designerwallcoverings.com to genuine
new customer inquiries arriving at info@<fleet-domain> (forwarded into the
steve-office Gmail via Purelymail catch-all + plus-tag). Reply-To is set to
info@<fleet-domain> so customer replies route back to the right site.
Spam-safety: skips Spam, no-reply/bounce senders, bulk/auto/list mail, and any
mail from designerwallcoverings.com or a fleet domain (loop guard). One reply
per sender per rolling 7-day window (persisted ledger). Marks every processed
message with the 'fleet-autoreplied' Gmail label — idempotent on re-run.
Reuses George's DW-MCP/.env OAuth credentials; no prod deploy needed.
Not yet scheduled — run manually with: node fleet-autoresponder.js
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
fleet-autoresponder.js | 398 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 398 insertions(+)
diff --git a/fleet-autoresponder.js b/fleet-autoresponder.js
new file mode 100644
index 0000000..2768a05
--- /dev/null
+++ b/fleet-autoresponder.js
@@ -0,0 +1,398 @@
+#!/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 --dry-run # detect + log, send nothing
+ *
+ * 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');
+const PROCESSED_LABEL = 'fleet-autoreplied';
+const LEDGER_PATH = path.join(__dirname, 'data', 'fleet-autoresponder-ledger.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
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 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;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 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 }
+// ─────────────────────────────────────────────────────────────────────────────
+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' };
+
+ // 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') + '?=';
+}
+function buildReply({ toAddress, originalSubject, fleetDomain, inReplyTo, references }) {
+ 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.
+
+${SIGNATURE_NAME}
+${name}
+${PHONE}
+`;
+ const bodyB64 = Buffer.from(bodyText, 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n');
+ const subject = originalSubject && /^re:/i.test(originalSubject.trim())
+ ? originalSubject.trim()
+ : `Re: ${originalSubject || '(your inquiry)'}`;
+ const refsHeader = references && references.length
+ ? references.join(' ')
+ : (inReplyTo || null);
+
+ const lines = [
+ `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: text/plain; charset=UTF-8',
+ 'Content-Transfer-Encoding: base64',
+ ].filter(Boolean);
+
+ const message = lines.join('\r\n') + '\r\n\r\n' + bodyB64;
+ return { raw: Buffer.from(message, 'utf8').toString('base64url'), subject, bodyText };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// 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 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 inbox mail addressed to a steve+ plus-tag.
+ // We deliberately scope to INBOX (excludes SPAM) and exclude already-labelled mail.
+ const q = `in:inbox -label:${PROCESSED_LABEL} to:steve+ newer_than:14d`;
+ const list = await officeGmail.users.messages.list({ userId: 'me', q, maxResults: 100 });
+ const ids = (list.data.messages || []).map(m => m.id);
+ log(`[fleet-autoresponder] ${ids.length} candidate message(s) matched query: ${q}`);
+
+ const results = { replied: [], skipped: [] };
+
+ 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) {
+ 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 });
+ 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 });
+ 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 });
+ 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 });
+ 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,
+ });
+ }
+
+ log(`[fleet-autoresponder] done — replied:${results.replied.length} skipped:${results.skipped.length}`);
+ console.log(JSON.stringify(results, null, 2));
+ return results;
+}
+
+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 };
← 02ef28b Make /api/profile honor ?account= param like the other 31 ro
·
back to George Gmail
·
Fleet auto-responder: deliveredto query fix + --only-ids sco 7d77cad →