← back to Designer Wallcoverings
TK-11387: add gated, reversible chronic-bouncer suppression tool
950f19d5bda196407a28ae85af5e6dec571c612d · 2026-09-10 09:40:39 -0700 · Steve
DW's bounce rate sits at ~22% on every send and never improves. Measured cause: 85.5%
of bounces are CC code S (suspended/dormant mailbox) - a SOFT bounce. Constant Contact
only auto-suppresses HARD bounces (B/D/U, just 0.8% here), so these are retried forever
and nothing in the DW stack reads bounce reports. Result: the SAME addresses bounce on
every campaign - 5,515 of 6,114 unique bouncers bounced in all 8 of the last 8 sends.
Recomputes the chronic set live from CC reporting, resolves list memberships by bulk
paging, and removes those contacts from mailing lists - leaving contact records and
genuine unsubscribe statuses untouched. Dry-run by default; --apply is Steve-gated and
writes a full restore map first; --rollback re-adds every contact to every list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files touched
A mailers/cc-api/hygiene/suppress-chronic-bouncers.js
Diff
commit 950f19d5bda196407a28ae85af5e6dec571c612d
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 10 09:40:39 2026 -0700
TK-11387: add gated, reversible chronic-bouncer suppression tool
DW's bounce rate sits at ~22% on every send and never improves. Measured cause: 85.5%
of bounces are CC code S (suspended/dormant mailbox) - a SOFT bounce. Constant Contact
only auto-suppresses HARD bounces (B/D/U, just 0.8% here), so these are retried forever
and nothing in the DW stack reads bounce reports. Result: the SAME addresses bounce on
every campaign - 5,515 of 6,114 unique bouncers bounced in all 8 of the last 8 sends.
Recomputes the chronic set live from CC reporting, resolves list memberships by bulk
paging, and removes those contacts from mailing lists - leaving contact records and
genuine unsubscribe statuses untouched. Dry-run by default; --apply is Steve-gated and
writes a full restore map first; --rollback re-adds every contact to every list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
.../cc-api/hygiene/suppress-chronic-bouncers.js | 169 +++++++++++++++++++++
1 file changed, 169 insertions(+)
diff --git a/mailers/cc-api/hygiene/suppress-chronic-bouncers.js b/mailers/cc-api/hygiene/suppress-chronic-bouncers.js
new file mode 100644
index 00000000..f210ef6b
--- /dev/null
+++ b/mailers/cc-api/hygiene/suppress-chronic-bouncers.js
@@ -0,0 +1,169 @@
+'use strict';
+/**
+ * suppress-chronic-bouncers.js — TK-11387
+ *
+ * THE PROBLEM: DW's bounce rate sits at ~22% on every send (healthy <2%, >10% is ESP
+ * suspension territory) and never improves. Cause, measured: 85.5% of bounces carry CC
+ * bounce_code "S" (suspended/dormant mailbox) — a SOFT bounce. Constant Contact only
+ * auto-suppresses HARD bounces (B/D/U, which are just 0.8% here), so these soft bouncers
+ * are retried forever. Nothing removes them, so the SAME ~5,800 addresses bounce on every
+ * campaign: 5,515 of 6,114 unique bouncers bounced in all 8 of the last 8 sends.
+ *
+ * WHY IT MATTERS BEYOND THE METRIC: the bouncing domains are legacy consumer ISPs
+ * (yahoo, aol, hotmail, comcast, earthlink, bellsouth, sbcglobal). Yahoo and AOL recycle
+ * long-dormant mailboxes into SPAM TRAPS. Continuing to mail thousands of suspended
+ * mailboxes at those providers is the textbook path to a blocklisting that would hit
+ * every DW send, not just marketing.
+ *
+ * WHAT THIS DOES: recomputes the chronic-bouncer set LIVE from CC's own reporting
+ * (never a stale file), then removes those contacts from their mailing lists — keeping
+ * the contact record and its real unsubscribe status untouched, so nobody is falsely
+ * marked as having opted out.
+ *
+ * SAFETY:
+ * - DRY-RUN BY DEFAULT. Without --apply it performs ZERO writes.
+ * - --apply is a destructive external write and is STEVE-GATED.
+ * - Writes a full restore map (contact_id -> the exact list_ids it belonged to)
+ * BEFORE any removal, so --rollback can re-add every contact to every list.
+ * - Threshold is configurable; default 4+ campaigns, which excludes one-off/transient
+ * bouncers and only touches addresses that have failed repeatedly.
+ *
+ * node suppress-chronic-bouncers.js # dry run (default)
+ * node suppress-chronic-bouncers.js --threshold=5 # stricter
+ * node suppress-chronic-bouncers.js --apply # GATED
+ * node suppress-chronic-bouncers.js --rollback # undo from the restore map
+ */
+const fs = require('fs');
+const path = require('path');
+
+const TOKEN_CACHE = path.join(__dirname, '..', '.cc-token-cache.json');
+const RESTORE = path.join(__dirname, 'suppress-restore.json');
+const API = 'https://api.cc.email/v3';
+
+const argv = process.argv.slice(2);
+const APPLY = argv.includes('--apply');
+const ROLLBACK = argv.includes('--rollback');
+const CAMPAIGNS = Number((argv.find(a => a.startsWith('--campaigns=')) || '').split('=')[1] || 8);
+const THRESHOLD = Number((argv.find(a => a.startsWith('--threshold=')) || '').split('=')[1] || 4);
+
+function token() {
+ const c = JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf8'));
+ if (c.expires_at && c.expires_at < Date.now()) throw new Error('CC access token expired — refresh it first.');
+ return c.access_token;
+}
+const H = () => ({ Authorization: `Bearer ${token()}`, Accept: 'application/json', 'Content-Type': 'application/json' });
+const get = async (u) => { const r = await fetch(u, { headers: H() }); if (!r.ok) throw new Error(`GET ${u} -> ${r.status} ${(await r.text()).slice(0,200)}`); return r.json(); };
+
+async function chronicSet() {
+ const s = await get(`${API}/reports/summary_reports/email_campaign_summaries?limit=25`);
+ const camps = (s.bulk_email_campaign_summaries || []).filter(c => (c.unique_counts || {}).sends > 0).slice(0, CAMPAIGNS);
+ const seen = new Map();
+ for (const c of camps) {
+ const e = await get(`${API}/emails/${c.campaign_id}`);
+ const prim = (e.campaign_activities || []).find(a => a.role === 'primary_email');
+ if (!prim) continue;
+ let url = `${API}/reports/email_reports/${prim.campaign_activity_id}/tracking/bounces?limit=500`;
+ let pg = 0;
+ while (url && pg < 25) {
+ const j = await get(url);
+ const acts = j.tracking_activities || []; if (!acts.length) break;
+ for (const t of acts) {
+ const em = (t.email_address || '').toLowerCase(); if (!em) continue;
+ const r = seen.get(em) || { n: 0, codes: new Set(), contact_id: t.contact_id };
+ r.n++; r.codes.add(t.bounce_code); seen.set(em, r);
+ }
+ url = j._links?.next?.href ? 'https://api.cc.email' + j._links.next.href : null; pg++;
+ }
+ }
+ return { camps: camps.length, all: seen };
+}
+
+async function main() {
+ if (ROLLBACK) return rollback();
+
+ console.log(`Recomputing chronic bouncers live from CC reporting (last ${CAMPAIGNS} campaigns)...`);
+ const { camps, all } = await chronicSet();
+ const chronic = [...all.entries()].filter(([, v]) => v.n >= THRESHOLD);
+ console.log(` campaigns analysed : ${camps}`);
+ console.log(` unique bouncers : ${all.size}`);
+ console.log(` chronic (>=${THRESHOLD}x) : ${chronic.length} <- the suppression set\n`);
+
+ // Resolve list memberships so removal is precise AND reversible.
+ // NOTE: do this by BULK PAGING the contacts endpoint (500/page, ~14 calls) rather than
+ // one GET per contact — the per-contact loop was ~5,800 round trips and took ~20 minutes.
+ console.log('Resolving list memberships in bulk (needed for a reversible restore map)...');
+ const want = new Map(chronic.map(([email, v]) => [v.contact_id, { email, v }]));
+ const plan = [];
+ let url = `${API}/contacts?limit=500&status=all&include=list_memberships`;
+ let pg = 0, scanned = 0;
+ while (url && pg < 400) {
+ const j = await get(url);
+ const cs = j.contacts || []; if (!cs.length) break;
+ for (const c of cs) {
+ scanned++;
+ const hit = want.get(c.contact_id); if (!hit) continue;
+ const lists = c.list_memberships || [];
+ if (lists.length) plan.push({ email: hit.email, contact_id: c.contact_id, bounces: hit.v.n, codes: [...hit.v.codes].join(''), list_ids: lists });
+ }
+ url = j._links?.next?.href ? 'https://api.cc.email' + j._links.next.href : null; pg++;
+ if (pg % 20 === 0) console.log(` ...scanned ${scanned} contacts, matched ${plan.length}`);
+ }
+ console.log(` scanned ${scanned} contacts over ${pg} pages`);
+ const totalMemberships = plan.reduce((s, p) => s + p.list_ids.length, 0);
+ console.log(`\n contacts still on >=1 list : ${plan.length}`);
+ console.log(` total list memberships : ${totalMemberships}\n`);
+
+ if (!APPLY) {
+ console.log('DRY RUN — no writes performed.');
+ console.log('Sample of what WOULD be removed from mailing lists:');
+ plan.slice(0, 10).forEach(p => console.log(` ${p.email.padEnd(38)} ${p.bounces}x codes=${p.codes} lists=${p.list_ids.length}`));
+ console.log(`\nThis is a destructive external write and is STEVE-GATED. After approval:`);
+ console.log(` node ${path.relative(process.cwd(), __filename)} --apply`);
+ console.log(`Undo: node ${path.relative(process.cwd(), __filename)} --rollback`);
+ return;
+ }
+
+ fs.writeFileSync(RESTORE, JSON.stringify({ ticket: 'TK-11387', at: new Date().toISOString(), threshold: THRESHOLD, plan }, null, 2));
+ console.log('Restore map written BEFORE any change ->', RESTORE);
+
+ // Bulk remove from lists. CC caps bulk activities, so chunk it.
+ const allListIds = [...new Set(plan.flatMap(p => p.list_ids))];
+ const ids = plan.map(p => p.contact_id);
+ const CHUNK = 500;
+ let done = 0;
+ for (let k = 0; k < ids.length; k += CHUNK) {
+ const slice = ids.slice(k, k + CHUNK);
+ const r = await fetch(`${API}/activities/remove_list_memberships`, {
+ method: 'POST', headers: H(),
+ body: JSON.stringify({ source: { contact_ids: slice }, list_ids: allListIds }),
+ });
+ const t = await r.text();
+ if (!r.ok) throw new Error(`remove_list_memberships -> ${r.status} ${t.slice(0, 300)}`);
+ done += slice.length;
+ console.log(` removed batch ${k / CHUNK + 1}: ${done}/${ids.length}`);
+ }
+ console.log(`\nDone. ${done} chronic bouncers removed from mailing lists.`);
+ console.log('Contact records and genuine unsubscribe statuses were NOT altered.');
+ console.log('Re-check the next send: bounce rate should fall from ~22% to low single digits.');
+}
+
+async function rollback() {
+ if (!fs.existsSync(RESTORE)) return console.log('No restore map — nothing to roll back.');
+ const rec = JSON.parse(fs.readFileSync(RESTORE, 'utf8'));
+ console.log(`Restoring ${rec.plan.length} contacts to their original lists...`);
+ const byList = {};
+ for (const p of rec.plan) for (const l of p.list_ids) (byList[l] = byList[l] || []).push(p.contact_id);
+ for (const [list_id, contact_ids] of Object.entries(byList)) {
+ for (let k = 0; k < contact_ids.length; k += 500) {
+ const r = await fetch(`${API}/activities/add_list_memberships`, {
+ method: 'POST', headers: H(),
+ body: JSON.stringify({ source: { contact_ids: contact_ids.slice(k, k + 500) }, list_ids: [list_id] }),
+ });
+ if (!r.ok) console.log(` ! list ${list_id}: ${r.status} ${(await r.text()).slice(0,150)}`);
+ }
+ console.log(` restored ${contact_ids.length} -> list ${list_id}`);
+ }
+ console.log('Rollback complete.');
+}
+
+main().catch(e => { console.error('FAILED:', e.message); process.exit(1); });
← bafa2f60 guard TK-11357 Fix D: 3 kravet activators found by a widened
·
back to Designer Wallcoverings
·
guard TK-11357 Fix D: push-drafts-book.cjs (TOP PRIORITY, sc 36206679 →