← 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
A data/suppress.jsonM server.js
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 →