← back to Designer Wallcoverings
mailers/cc-api/hygiene/suppress-chronic-bouncers.js
170 lines
'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); });