[object Object]

← 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

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 →