[object Object]

← back to Dw Pitch Followup

CAN-SPAM: compliant footer (physical address 15442 Ventura Blvd #102 + UNSUBSCRIBE) on every send; suppression list honored at send (not overridable); tightened unsubscribe scanner (to:info@ + opt-out-at-start, dry-run by default, ?apply=1 to suppress)

a9f7c6a467fb62bf72443c052929c095e9c367fa · 2026-07-08 06:32:30 -0700 · Steve Abrams

Files touched

Diff

commit a9f7c6a467fb62bf72443c052929c095e9c367fa
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 8 06:32:30 2026 -0700

    CAN-SPAM: compliant footer (physical address 15442 Ventura Blvd #102 + UNSUBSCRIBE) on every send; suppression list honored at send (not overridable); tightened unsubscribe scanner (to:info@ + opt-out-at-start, dry-run by default, ?apply=1 to suppress)
---
 data/suppress.json |  1 +
 server.js          | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 57 insertions(+), 1 deletion(-)

diff --git a/data/suppress.json b/data/suppress.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/data/suppress.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/server.js b/server.js
index 6ed1a09..9e57e94 100644
--- a/server.js
+++ b/server.js
@@ -23,6 +23,14 @@ const PORT = Number(process.env.PORT || 9768);
 const DATA_DIR = path.join(__dirname, 'data');
 const LISTS = path.join(DATA_DIR, 'lists.json');
 const SENT_LOG = path.join(DATA_DIR, 'sent-log.jsonl'); // permanent record of every email we send
+const SUPPRESS_FILE = path.join(DATA_DIR, 'suppress.json'); // unsubscribed addresses — never email these
+function loadSuppress() { try { return new Set(JSON.parse(fs.readFileSync(SUPPRESS_FILE, 'utf8')).map((e) => String(e).toLowerCase())); } catch { return new Set(); } }
+function addSuppress(emails) {
+  const set = loadSuppress(); let added = 0;
+  for (const e of emails) { const lc = String(e || '').trim().toLowerCase(); if (lc && !set.has(lc)) { set.add(lc); added++; } }
+  try { fs.writeFileSync(SUPPRESS_FILE, JSON.stringify([...set], null, 0)); } catch {}
+  return { added, total: set.size };
+}
 const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
 // Resolve George secrets: prefer our own env, else read them straight from george-gmail/.env
 // (same fallback George itself uses — launchd/pm2 don't load .env into process.env).
@@ -347,7 +355,13 @@ function bodyToHtml(text, samples) {
     const style = isSig ? 'margin:0 0 10px;color:#555;font-size:14px' : 'margin:0 0 13px';
     return `<p style="${style}">${link(_esc(p)).replace(/\n/g, '<br>')}</p>`;
   }).join('');
-  return `<div style="font-family:Georgia,'Times New Roman',serif;font-size:15px;line-height:1.6;color:#2b2b2b;max-width:600px">${paras}</div>`;
+  // CAN-SPAM footer: valid physical postal address + a working unsubscribe mechanism.
+  const footer = `<div style="margin-top:20px;padding-top:10px;border-top:1px solid #e5e0d8;font-size:11px;color:#9a948a;line-height:1.55;font-family:Georgia,serif">`
+    + `Designer Wallcoverings · 15442 Ventura Blvd, #102, Sherman Oaks, CA 91403 · 1-888-373-4564<br>`
+    + `You're receiving this because you requested samples from Designer Wallcoverings. `
+    + `To stop receiving these, reply with <strong>UNSUBSCRIBE</strong> and we'll remove you.`
+    + `</div>`;
+  return `<div style="font-family:Georgia,'Times New Roman',serif;font-size:15px;line-height:1.6;color:#2b2b2b;max-width:600px">${paras}${footer}</div>`;
 }
 
 // GET /api/lastcorr?email= — newest email we exchanged with this address (George read-only).
@@ -374,6 +388,9 @@ app.post('/api/send', async (req, res) => {
     if (!to || !subject || !body) return res.status(400).json({ error: 'to, subject and body are required' });
     if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(to).trim())) return res.status(400).json({ error: `not a valid recipient email: ${to}` });
 
+    // SUPPRESSION: never email someone who unsubscribed (CAN-SPAM — honored, not overridable).
+    if (loadSuppress().has(String(to).trim().toLowerCase())) return res.status(403).json({ error: 'recipient has unsubscribed — not sending', suppressed: true });
+
     // FREQUENCY GUARD: the same client can appear in both list1 and list2, so guard against
     // double-emailing — block a repeat send to the same address within 30 days unless force:true.
     try {
@@ -433,6 +450,44 @@ app.get('/api/sent', (_req, res) => {
   }
 });
 
+// GET /api/suppress — the unsubscribe list. POST {email} — add one manually.
+app.get('/api/suppress', (_req, res) => res.json({ suppressed: [...loadSuppress()] }));
+app.post('/api/suppress', (req, res) => {
+  const email = String(req.body?.email || '').trim();
+  if (!email) return res.status(400).json({ error: 'email required' });
+  res.json(addSuppress([email]));
+});
+
+// GET /api/scan-unsubscribes — find clients who replied "UNSUBSCRIBE" to info@ and suppress them.
+// Honors CAN-SPAM opt-outs. Read-only against Gmail; only writes the suppression list.
+app.get('/api/scan-unsubscribes', async (req, res) => {
+  try {
+    // Only messages sent TO us (replies), containing genuine opt-out language — NOT the
+    // "unsubscribe" link that rides in every marketing email we merely receive.
+    const q = encodeURIComponent('to:info@designerwallcoverings.com ("unsubscribe" OR "remove me" OR "opt out" OR "opt-out" OR "take me off" OR "stop emailing") newer_than:90d');
+    const r = await fetch(`${GEORGE}/api/search?q=${q}&account=info&maxResults=50`, {
+      headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(10000),
+    });
+    if (!r.ok) return res.status(502).json({ error: `george ${r.status}` });
+    const j = await r.json();
+    // Strong signal only: the opt-out phrase appears at the START of the reply (first ~70 chars),
+    // i.e. it's the point of the message — not a word buried in a forwarded thread or signature.
+    const OPT = /^(re:\s*)?[\s"']*(unsubscribe|remove me|please remove|remove us|opt[- ]?out|take me off|stop emailing|stop sending|no thank)/i;
+    const emails = [];
+    for (const m of (j.messages || [])) {
+      const from = (m.from || '').match(/[\w.+-]+@[\w.-]+\.\w+/);
+      const snip = (m.snippet || '').slice(0, 70);
+      if (from && !/designerwallcoverings\.com/i.test(from[0]) && (OPT.test(snip) || OPT.test(m.subject || ''))) emails.push(from[0]);
+    }
+    const uniq = [...new Set(emails)];
+    // ?apply=1 suppresses them; default is a DRY report so a false match never silently drops a client.
+    const result = req.query.apply ? addSuppress(uniq) : { added: 0, dryRun: true };
+    res.json({ scanned: (j.messages || []).length, candidates: uniq, ...result });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 app.listen(PORT, '127.0.0.1', () => {
   console.log(`[dw-pitch-followup] listening on http://127.0.0.1:${PORT}  (auth ${BASIC_AUTH.split(':')[0]} / …)  letters via ${LETTER_MODEL}`);
 });

← cc4f468 Stop leaking internal 'From job: dw-pitch-followup' banner o  ·  back to Dw Pitch Followup  ·  auto-save: 2026-07-08T06:34:26 (1 files) — scripts/scan-unsu e2ce4dc →