← back to NationalPaperHangers
scripts/send-claim-invitations.js
216 lines
#!/usr/bin/env node
// Gated send script for the "claim your listing" outbound campaign.
//
// Default mode: DRY-RUN. Prints exactly what would be sent + what's blocked.
// Use --commit to actually send via George.
//
// CRITICAL — fail-closed prerequisites enforced via lib/compliance.js:
// 1. MAILING_ADDRESS env (real, deliverable street address)
// 2. PUBLIC_URL (https in prod, non-localhost in dev)
// 3. SESSION_SECRET (or UNSUBSCRIBE_SIGNING_SECRET)
// 4. comms_suppression / comms_send_audit / unsubscribe_tokens tables
//
// The script ALSO checks Steve's standing rule from MEMORY.md: never use
// Anthropic API key — irrelevant here, no LLM calls.
//
// PER-RECIPIENT:
// - Suppression scrub against comms_suppression
// - Audit row in comms_send_audit (decision: sent | blocked_*)
// - List-Unsubscribe header on every send
// - In-body compliance footer with mailing address + unsubscribe link
//
// EMAIL SOURCING:
// The email address is NOT pulled from the installer row (DATA_POLICY §3
// forbids storing it). It must be supplied via --email on the CLI for a
// single targeted send, or a CSV via --csv when Steve has authorized the
// data-collection carve-out for the campaign. The dream-team's verdict is
// to use IG DM as the primary channel — email is the fallback that needs
// explicit per-send authorization.
//
// USAGE:
// node scripts/send-claim-invitations.js --slug=studio-slug --email=info@studio.com
// node scripts/send-claim-invitations.js --slug=studio-slug --email=info@studio.com --commit
// node scripts/send-claim-invitations.js --csv=./outreach-batch.csv --commit
//
// Each row of the CSV must be: slug,email (no header, no quotes).
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const db = require('../lib/db');
const email = require('../lib/email');
const compliance = require('../lib/compliance');
const ARGS = process.argv.slice(2);
const COMMIT = ARGS.includes('--commit');
const arg = (k, def = null) => {
const a = ARGS.find(x => x.startsWith(`--${k}=`));
return a ? a.split('=').slice(1).join('=') : def;
};
const CAMPAIGN = 'claim_invite';
function escapeHtml(s) {
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
async function loadCsv(csvPath) {
const abs = path.resolve(csvPath);
const txt = fs.readFileSync(abs, 'utf8');
return txt.split(/\r?\n/).filter(Boolean).map(line => {
const [slug, addr] = line.split(',').map(s => (s || '').trim());
return { slug, email: (addr || '').toLowerCase() };
});
}
function buildClaimInviteHtml({ installer, claimUrl, unsubscribeUrl }) {
const html = `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
<h1 style="font-size:22px;margin:0 0 12px">Claim your listing on National Paper Hangers</h1>
<p style="font-size:15px;line-height:1.6">Hello,</p>
<p style="font-size:15px;line-height:1.6">
We've listed <strong>${escapeHtml(installer.business_name)}</strong> in the National Paper Hangers
public directory based on your <a href="https://wallcoveringinstallers.org" style="color:#0e0e0e">WIA</a>
accreditation. Trade buyers (designers, hospitality operators, architects) are finding studios on NPH
when they need a luxury wallcovering installer outside Los Angeles.
</p>
<p style="font-size:15px;line-height:1.6">
If you'd like to take control of the listing — update your bio, add portfolio images, set availability,
and accept self-booked consultations — claim it in 2 minutes:
</p>
<p style="text-align:center;margin:24px 0">
<a href="${claimUrl}" style="display:inline-block;padding:14px 22px;background:#0e0e0e;color:#fff;text-decoration:none">Claim ${escapeHtml(installer.business_name)}</a>
</p>
<p style="font-size:13px;color:#666;line-height:1.6">
The basic listing is free. Pro ($39/mo) and Signature ($149/mo) add live calendar booking and a Verified badge.
No obligation — your listing stays visible either way.
</p>
<p style="font-size:13px;color:#666;line-height:1.6">
Questions? Reply to this email — it goes to a person, not a queue.
</p>
${compliance.complianceFooter({ campaign: CAMPAIGN, unsubscribeUrl })}
</div>`;
return html;
}
async function sendOne({ slug, recipientEmail }) {
if (!slug || !recipientEmail) {
return { ok: false, reason: 'missing_slug_or_email' };
}
recipientEmail = recipientEmail.toLowerCase();
const installer = await db.one(
`SELECT id, slug, business_name, website, claim_status FROM installers WHERE slug = $1`,
[slug]
);
if (!installer) {
return { ok: false, reason: 'installer_not_found' };
}
if (installer.claim_status === 'claimed') {
await compliance.recordAudit({
channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
installerId: installer.id, decision: 'blocked_already_claimed', reason: 'claim_status=claimed'
});
return { ok: false, reason: 'already_claimed' };
}
const suppressed = await compliance.isSuppressed({ channel: 'email', identifier: recipientEmail });
if (suppressed) {
await compliance.recordAudit({
channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
installerId: installer.id, decision: 'blocked_suppression', reason: 'recipient on comms_suppression'
});
return { ok: false, reason: 'suppressed' };
}
const publicUrl = process.env.PUBLIC_URL.replace(/\/+$/, '');
const claimUrl = `${publicUrl}/installer/${installer.slug}/claim`;
const unsubToken = await compliance.mintUnsubscribeToken({
channel: 'email', identifier: recipientEmail, campaign: CAMPAIGN, installerId: installer.id
});
const unsubscribeUrl = `${publicUrl}/unsubscribe?token=${encodeURIComponent(unsubToken)}`;
const subject = `Claim your listing — ${installer.business_name}`;
const html = buildClaimInviteHtml({ installer, claimUrl, unsubscribeUrl });
const headers = compliance.listUnsubscribeHeader(unsubscribeUrl);
if (!COMMIT) {
console.log(`[DRY-RUN] would send to ${recipientEmail} re: ${installer.business_name}`);
console.log(` claim: ${claimUrl}`);
console.log(` unsubscribe: ${unsubscribeUrl}`);
return { ok: true, dryRun: true };
}
try {
const result = await email.sendEmail({
to: recipientEmail, subject, html, extraHeaders: headers
});
await compliance.recordAudit({
channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
installerId: installer.id,
decision: result.ok ? 'sent' : 'failed',
reason: result.ok ? null : (result.error || 'send_failed'),
subject,
messageId: result.id || result.messageId || null,
payload: { claimUrl, unsubscribeUrl }
});
return { ok: result.ok, reason: result.ok ? 'sent' : 'send_failed' };
} catch (err) {
await compliance.recordAudit({
channel: 'email', campaign: CAMPAIGN, recipient: recipientEmail,
installerId: installer.id, decision: 'failed', reason: err.message, subject
});
return { ok: false, reason: 'exception:' + err.message };
}
}
async function main() {
console.log(`[claim-invite] mode=${COMMIT ? 'COMMIT' : 'DRY-RUN'}`);
// Pre-flight gate. Throws ComplianceError if anything is missing.
try {
await compliance.assertSendCompliance({ campaign: CAMPAIGN });
console.log('[claim-invite] compliance gate: PASS');
} catch (err) {
console.error(`[claim-invite] compliance gate: FAIL — ${err.code}`);
console.error(` ${err.message}`);
process.exit(2);
}
const csvPath = arg('csv');
const oneSlug = arg('slug');
const oneEmail = arg('email');
let batch = [];
if (csvPath) {
batch = await loadCsv(csvPath);
console.log(`[claim-invite] loaded ${batch.length} recipients from ${csvPath}`);
} else if (oneSlug && oneEmail) {
batch = [{ slug: oneSlug, email: oneEmail.toLowerCase() }];
} else {
console.error('Usage: --csv=path.csv OR --slug=… --email=… (add --commit to actually send)');
process.exit(1);
}
let sent = 0, blocked = 0, failed = 0, dry = 0;
for (const row of batch) {
const r = await sendOne({ slug: row.slug, recipientEmail: row.email });
if (r.dryRun) dry++;
else if (r.ok) sent++;
else if (r.reason === 'suppressed' || r.reason === 'already_claimed') blocked++;
else failed++;
// Throttle so we never blast — 2s between sends
if (COMMIT) await new Promise(r => setTimeout(r, 2000));
}
console.log(`[claim-invite] done · sent=${sent} blocked=${blocked} failed=${failed} dry-run=${dry}`);
// Steve's standing rule: alert on >10% failure rate
const total = sent + failed;
if (total > 0 && (failed / total) > 0.10) {
console.warn(`[claim-invite] FAILURE-RATE-WARNING: ${(failed/total*100).toFixed(0)}% — review comms_send_audit`);
}
await db.pool.end();
}
main().catch(err => { console.error(err); process.exit(1); });