← back to Ventura Claw Leads
scripts/deliver-leads.js
212 lines
#!/usr/bin/env node
// Lead-delivery worker — emails each new business_interest row to the
// matching business's email address with reply-to set to the consumer, so the
// business can reply directly without VCL on the chain.
//
// Sister to NPH's notify-interest-queue.js but inverted: in NPH the business
// is the recipient AFTER the studio goes live; here the business is the
// recipient AT CAPTURE TIME (within ~5 min via cron */5 * * * *).
//
// Lifecycle on each row:
// 1. Skip if delivery_msg_id already set (idempotent).
// 2. Skip if business has no email (capture row stays in queue; ops fix the business record).
// 3. Skip if consumer is on the suppression list (mark delivered with msgId='suppressed').
// 4. Skip if business is on the suppression list for THIS lead-delivery channel
// (i.e., the business unsubscribed from receiving leads — paid-tier business
// should never be on this list, but we still check).
// 5. Send via Purelymail SMTP. Update delivery_msg_id + delivery_email +
// bill_amount_cents (per-tier metering).
// 6. Audit row to comms_send_audit.
//
// Flags:
// --dry-run : no DB write, no SMTP, just log
// --limit N : cap iteration count (default 100)
const path = require('node:path');
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
const db = require('../lib/db');
const { sendEmail } = require('../lib/email');
const compliance = require('../lib/compliance');
const DRY_RUN = process.argv.includes('--dry-run');
const LIMIT = (() => {
const i = process.argv.indexOf('--limit');
return i >= 0 ? parseInt(process.argv[i + 1], 10) || 100 : 100;
})();
const PUBLIC_URL = (process.env.PUBLIC_URL || 'https://leads.venturaclaw.com').replace(/\/+$/, '');
const CAMPAIGN = 'lead_delivery';
// Per-tier per-lead billing rate (cents). Charged at delivery time so the
// business sees a clean monthly invoice.
// free : $0 — leads still delivered (we want them to upgrade), not billed.
// starter : $0 — leads included in subscription.
// standard : $0 — leads included in subscription.
// premier : $0 — leads included in subscription.
// (When v0.2 #4 lands real Stripe billing, switch any tier from $0 to a
// metered value. v0.1 keeps it at zero across the board so we have a reliable
// audit trail before the meter goes live.)
const BILL_PER_LEAD_CENTS = {
free: 0, starter: 0, standard: 0, premier: 0
};
function escapeHtml(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
function buildEmail({ business, lead, unsubscribeUrl }) {
const subject = `New customer message via Ventura Claw — ${lead.consumer_name}`;
const profileUrl = `${PUBLIC_URL}/business/${encodeURIComponent(business.slug)}`;
const html = `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
<p style="font-size:11px;letter-spacing:0.16em;text-transform:uppercase;color:#b8860b;margin:0 0 8px">Lead via Ventura Claw</p>
<h1 style="font-size:24px;font-weight:400;margin:0 0 6px;font-family:Georgia,serif">A customer messaged you</h1>
<p style="font-size:14px;line-height:1.55;color:#666;margin:0 0 24px">Hit reply below — your response goes straight to <strong>${escapeHtml(lead.consumer_name)}</strong>. Ventura Claw isn't on the reply chain.</p>
<table style="width:100%;border-collapse:collapse;font-size:14px;line-height:1.55;border-top:1px solid #ddd">
<tr><td style="padding:10px 0;width:120px;color:#666">From</td><td style="padding:10px 0"><strong>${escapeHtml(lead.consumer_name)}</strong></td></tr>
<tr><td style="padding:10px 0;color:#666;border-top:1px solid #eee">Email</td><td style="padding:10px 0;border-top:1px solid #eee"><a href="mailto:${escapeHtml(lead.consumer_email)}" style="color:#0e0e0e">${escapeHtml(lead.consumer_email)}</a></td></tr>
${lead.consumer_phone ? `<tr><td style="padding:10px 0;color:#666;border-top:1px solid #eee">Phone</td><td style="padding:10px 0;border-top:1px solid #eee"><a href="tel:${escapeHtml(lead.consumer_phone)}" style="color:#0e0e0e">${escapeHtml(lead.consumer_phone)}</a></td></tr>` : ''}
${lead.zip ? `<tr><td style="padding:10px 0;color:#666;border-top:1px solid #eee">ZIP</td><td style="padding:10px 0;border-top:1px solid #eee">${escapeHtml(lead.zip)}</td></tr>` : ''}
${lead.message ? `<tr><td style="padding:10px 0;color:#666;border-top:1px solid #eee;vertical-align:top">Message</td><td style="padding:10px 0;border-top:1px solid #eee;white-space:pre-wrap">${escapeHtml(lead.message)}</td></tr>` : ''}
</table>
<p style="margin:24px 0 0">
<a href="mailto:${escapeHtml(lead.consumer_email)}?subject=Re%3A%20your%20Ventura%20Claw%20inquiry%20to%20${encodeURIComponent(business.business_name)}" style="display:inline-block;background:#0e0e0e;color:#fff;padding:12px 22px;text-decoration:none;font-size:14px;letter-spacing:0.04em;border-radius:2px">Reply to ${escapeHtml(lead.consumer_name.split(' ')[0])} →</a>
</p>
<p style="font-size:12px;color:#888;margin:32px 0 0;line-height:1.55;border-top:1px solid #eee;padding-top:18px">Your live profile: <a href="${profileUrl}" style="color:#888">${profileUrl.replace(/^https?:\/\//, '')}</a></p>
${compliance.complianceFooter({ campaign: CAMPAIGN, unsubscribeUrl })}
</div>`;
const text = `New customer message via Ventura Claw\n\nFrom: ${lead.consumer_name} <${lead.consumer_email}>${lead.consumer_phone ? '\nPhone: ' + lead.consumer_phone : ''}${lead.zip ? '\nZIP: ' + lead.zip : ''}${lead.message ? '\n\nMessage:\n' + lead.message : ''}\n\nReply directly to ${lead.consumer_email}.\n\nProfile: ${profileUrl}\n---\nUnsubscribe (stop receiving leads): ${unsubscribeUrl}`;
return { subject, html, text };
}
(async () => {
if (!DRY_RUN) await compliance.assertSendCompliance({ campaign: CAMPAIGN });
const queue = await db.many(`
SELECT bi.id AS interest_id,
bi.consumer_name, bi.consumer_email, bi.consumer_phone, bi.zip, bi.message,
bi.business_id,
b.slug, b.business_name, b.email AS biz_email, b.tier
FROM business_interest bi
JOIN businesses b ON b.id = bi.business_id
WHERE bi.delivery_msg_id IS NULL
ORDER BY bi.created_at ASC
LIMIT $1
`, [LIMIT]);
console.log(`[deliver-leads] ${queue.length} candidate(s)${DRY_RUN ? ' (dry run)' : ''}`);
if (queue.length === 0) return;
let sent = 0, skipped_no_email = 0, suppressed = 0, errors = 0;
for (const row of queue) {
// Business has no email on file → leave row in queue, ops should fix.
if (!row.biz_email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(row.biz_email)) {
skipped_no_email++;
console.log(` · interest=${row.interest_id} biz=${row.slug} → SKIP (no business email)`);
continue;
}
// Consumer suppression — they explicitly opted out of being contacted.
// Don't deliver lead to business; mark queue row 'suppressed' so it doesn't
// sit forever, and audit it.
const consumerSuppressed = await compliance.isSuppressed({ channel: 'email', identifier: row.consumer_email });
if (consumerSuppressed) {
suppressed++;
console.log(` · interest=${row.interest_id} consumer=${row.consumer_email} → CONSUMER SUPPRESSED, skipping delivery`);
if (!DRY_RUN) {
await db.query(`UPDATE business_interest SET delivered_at = now(), delivery_msg_id = 'suppressed_consumer' WHERE id = $1`, [row.interest_id]);
await compliance.recordAudit({
channel: 'email', campaign: CAMPAIGN, recipient: row.biz_email,
businessId: row.business_id, decision: 'blocked_suppression',
reason: 'consumer_unsubscribed', subject: null, messageId: null,
payload: { interest_id: row.interest_id }
});
}
continue;
}
// Business suppression — they unsubscribed from receiving leads.
const bizSuppressed = await compliance.isSuppressed({ channel: 'email', identifier: row.biz_email });
if (bizSuppressed) {
suppressed++;
console.log(` · interest=${row.interest_id} biz=${row.biz_email} → BUSINESS SUPPRESSED, skipping`);
if (!DRY_RUN) {
await db.query(`UPDATE business_interest SET delivered_at = now(), delivery_msg_id = 'suppressed_business' WHERE id = $1`, [row.interest_id]);
await compliance.recordAudit({
channel: 'email', campaign: CAMPAIGN, recipient: row.biz_email,
businessId: row.business_id, decision: 'blocked_suppression',
reason: 'business_unsubscribed', subject: null, messageId: null,
payload: { interest_id: row.interest_id }
});
}
continue;
}
// Mint unsubscribe token for the BUSINESS (so they can stop receiving leads).
const unsubToken = DRY_RUN
? 'dry-run-token'
: await compliance.mintUnsubscribeToken({
channel: 'email', identifier: row.biz_email,
campaign: CAMPAIGN, businessId: row.business_id
});
const unsubscribeUrl = `${PUBLIC_URL}/unsubscribe?t=${unsubToken}`;
const { subject, html, text } = buildEmail({
business: { slug: row.slug, business_name: row.business_name },
lead: row,
unsubscribeUrl
});
if (DRY_RUN) {
console.log(` · interest=${row.interest_id} → would send to ${row.biz_email} (subject: "${subject}")`);
sent++;
continue;
}
const result = await sendEmail({
to: row.biz_email,
replyTo: row.consumer_email, // the magic — biz hits Reply, consumer gets it
subject, html, text,
extraHeaders: compliance.listUnsubscribeHeader(unsubscribeUrl)
});
if (result && result.ok) {
sent++;
const billCents = BILL_PER_LEAD_CENTS[row.tier] ?? 0;
await db.query(
`UPDATE business_interest
SET delivered_at = now(), delivery_email = $2, delivery_msg_id = $3, bill_amount_cents = $4
WHERE id = $1`,
[row.interest_id, row.biz_email, result.messageId || 'sent-no-id', billCents]
);
await compliance.recordAudit({
channel: 'email', campaign: CAMPAIGN, recipient: row.biz_email,
businessId: row.business_id, decision: 'sent', reason: null,
subject, messageId: result.messageId || null,
payload: { interest_id: row.interest_id, tier: row.tier, bill_cents: billCents }
});
console.log(` · interest=${row.interest_id} → delivered to ${row.biz_email} (msg ${(result.messageId || '').slice(0, 28)}…, tier=${row.tier}, bill=${billCents}¢)`);
} else {
errors++;
await compliance.recordAudit({
channel: 'email', campaign: CAMPAIGN, recipient: row.biz_email,
businessId: row.business_id, decision: 'failed',
reason: (result && result.error) || 'unknown',
subject, messageId: null,
payload: { interest_id: row.interest_id, smtp_code: result && result.code }
});
console.error(` · interest=${row.interest_id} → ERROR ${(result && result.code) || ''} ${((result && result.error) || '').slice(0, 80)}`);
// Don't update delivery_msg_id — let the next */5 cron run retry.
}
}
console.log(`[deliver-leads] done — sent=${sent} suppressed=${suppressed} skipped_no_email=${skipped_no_email} errors=${errors}`);
})().catch(err => {
console.error('[deliver-leads] FATAL:', err.message);
process.exit(1);
}).finally(() => {
setTimeout(() => process.exit(0), 200);
});