← back to Dw Signup Fulfillment
verification/tk11120/resend-corrected.js
79 lines
'use strict';
// TK-11120 — CORRECTED resend of the retail-verify letter to the ~75 customers who got a
// DEAD http://127.0.0.1:9856 link on 2026-09-02 (the backfill job ran with PUBLIC_URL
// unset). This sender is safe-by-design and specifically works around the two things that
// would otherwise break a resend:
// 1) It HARD-REQUIRES config.PUBLIC_URL (the fail-closed baseUrl now returns '' without
// it) — so it can never re-ship a localhost link.
// 2) It BYPASSES the tainted `custom.sample_verify_sent=true` flag + the old backfill
// ledger (those were set on the broken run), because every one of these customers
// still needs a WORKING link. Idempotency is instead tracked in this script's OWN
// ledger so re-runs never double-send.
// Sender identity: requires GEORGE_ACCOUNT=info so mail is genuinely from info@.
// Copy: email.verifyResendEmail (adds a one-line apology).
//
// Modes:
// node resend-corrected.js -> DRY_RUN preview of the whole list (no send)
// node resend-corrected.js --only <email> -> just that address (works for a test addr)
// node resend-corrected.js --limit N -> first N of the list
// DRY_RUN=0 ... --apply -> actually send (both required)
const fs = require('fs');
const path = require('path');
const shopify = require(path.join(__dirname, '..', '..', 'lib', 'shopify'));
const verify = require(path.join(__dirname, '..', '..', 'lib', 'verify'));
const email = require(path.join(__dirname, '..', '..', 'lib', 'email'));
const config = require(path.join(__dirname, '..', '..', 'lib', 'config'));
const args = process.argv.slice(2);
const APPLY = args.includes('--apply');
const ONLY = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
const LIMIT = args.includes('--limit') ? parseInt(args[args.indexOf('--limit') + 1], 10) : Infinity;
const FORCE = args.includes('--force'); // ignore the prior resend ledger (the 3-sample run)
const RETAIL_ONLY = args.includes('--retail-only'); // skip customers tagged 'trade' (they get unlimited)
const LISTP = path.join(__dirname, 'affected-emails.txt');
// The 5-sample upgrade run uses its OWN ledger so it stays idempotent without the 3-sample run's entries.
const LEDGER = path.join(__dirname, FORCE ? 'tk11120-5sample-ledger.jsonl' : 'tk11120-resend-ledger.jsonl');
function ledgerDone() { const s = new Set(); try { for (const l of fs.readFileSync(LEDGER, 'utf8').split('\n')) { if (!l.trim()) continue; const r = JSON.parse(l); if (r.ok) s.add(String(r.email).toLowerCase()); } } catch {} return s; }
function ledgerAppend(row) { fs.appendFileSync(LEDGER, JSON.stringify(row) + '\n'); }
function mask(e) { return String(e).replace(/(.{2}).*@/, '$1***@'); }
(async () => {
// ---- HARD PRE-FLIGHT: never re-ship a bad link or wrong sender ----
if (!config.PUBLIC_URL) { console.error('ABORT: PUBLIC_URL is empty — refusing to resend (would ship a dead localhost link). Set PUBLIC_URL=https://signup.designerwallcoverings.com'); process.exit(2); }
if (config.GEORGE_ACCOUNT !== 'info') { console.error(`ABORT: GEORGE_ACCOUNT='${config.GEORGE_ACCOUNT}', expected 'info' (Steve directive — send from info@).`); process.exit(2); }
const base = verify.baseUrl();
if (!base || /127\.0\.0\.1|localhost/.test(base)) { console.error(`ABORT: baseUrl() resolved to '${base}' — not a public https host.`); process.exit(2); }
let list = ONLY ? [ONLY] : fs.readFileSync(LISTP, 'utf8').split('\n').map(s => s.trim()).filter(Boolean);
const done = ledgerDone();
console.log(`resend-corrected: ${list.length} target(s) · base=${base} · account=${config.GEORGE_ACCOUNT} · from=${config.GEORGE_FROM} · mode=${config.DRY_RUN ? 'DRY_RUN' : 'LIVE'} · apply=${APPLY} · only=${ONLY || '-'}`);
if (!config.DRY_RUN && !APPLY) console.log('LIVE env but no --apply → will only PREVIEW. Add --apply to actually send.');
let n = 0, sent = 0, skipped = 0, failed = 0;
for (const addr0 of list) {
if (n >= LIMIT) break;
const addr = String(addr0).trim().toLowerCase();
if (!addr) continue;
if (done.has(addr)) { skipped++; console.log(` [skip] ${mask(addr)} already resent (ledger)`); continue; }
// Look up the Shopify customer for id + first name + tags (best-effort).
let customerId = null, firstName = '', tags = '';
try {
customerId = await shopify.findCustomerByEmail(addr);
if (customerId) { const g = await shopify.getCustomer(customerId); const c = g && g.json && g.json.customer; if (c) { firstName = c.first_name || ''; tags = c.tags || ''; } }
} catch (e) { /* non-fatal — resend with email-prefix greeting */ }
// RETAIL-ONLY (Steve): trade/designer accounts get UNLIMITED samples, so the 5-sample offer doesn't apply.
if (RETAIL_ONLY && tags.split(',').map(t => t.trim().toLowerCase()).includes('trade')) { skipped++; console.log(` [skip] ${mask(addr)} trade account (retail-only)`); continue; }
n++;
const token = verify.mintToken({ email: addr, customerId, count: config.FREE_SAMPLE_COUNT });
if (!token) { failed++; ledgerAppend({ email: addr, ok: false, reason: 'no_token(secret?)', ts: new Date().toISOString() }); console.log(` [FAIL] ${mask(addr)} could not mint token (VERIFY_SECRET?)`); continue; }
const url = `${base}/verify?token=${encodeURIComponent(token)}`;
const tpl = email.verifyResendEmail({ firstName: firstName || addr.split('@')[0], url, count: config.FREE_SAMPLE_COUNT });
if (config.DRY_RUN || !APPLY) { console.log(` [would-send] ${mask(addr)} id=${customerId || '-'} host_ok=${url.startsWith(base)}`); continue; }
const r = await email.sendEmail({ to: addr, subject: tpl.subject, html: tpl.html, source: 'retail-verify-resend' });
if (r && r.ok !== false) { sent++; ledgerAppend({ email: addr, ok: true, customerId: customerId || null, ts: new Date().toISOString() }); console.log(` [sent] ${mask(addr)} status=${r.status != null ? r.status : (r.dryRun ? 'dry' : '?')}`); }
else { failed++; ledgerAppend({ email: addr, ok: false, reason: (r && (r.errorCode || r.error || r.status)) || 'unknown', ts: new Date().toISOString() }); console.log(` [FAIL] ${mask(addr)} status=${r && r.status} err=${r && (r.errorCode || r.error)}`); }
await new Promise(res => setTimeout(res, 500)); // gentle pacing for George
}
console.log(`DONE: targeted=${n} sent=${sent} skipped=${skipped} failed=${failed}`);
})().catch(e => { console.error('ERR', e.message); process.exit(1); });