[object Object]

← 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 →