gigafibre-fsm/scripts/campaigns/send_gift_campaign.js
louispaulb 9f2b37939d feat(campaigns): TARGO rebrand + Mustache sections + Mailjet webhook setup
- Template gift-email-fr.html: switch from Gigafibre indigo to TARGO green
  (#019547), use real Mailjet-hosted TARGO logo, adopt retention-offer
  layout from the latest mockup (tutoiement, Option 1/Option 2 split,
  prorata-refund disclaimer, "L'équipe TARGO" signature). Row 1 of the
  merchant grid uses real Mailjet logos (Amazon, IGA, Tim Hortons, $1
  Plus); rows 2-3 are placehold.co until URLs are shared.

- send_gift_campaign.js: add {{#var}}...{{/var}} Mustache section support
  to the renderer so the optional expiry block disappears cleanly when
  --expiry is omitted (was rendering literal tags before). Add new
  --commitment-months CLI flag (default 3) for the "Rester encore X mois
  ou +" wording.

- setup_mailjet_webhook.js (new): one-shot Node script to register the
  Hub callback URL with Mailjet's /v3/REST/eventcallbackurl. Defaults
  to a safe event subset (open/click/spam/unsub) that doesn't conflict
  with the WP-Mail-SMTP integration already owning sent/bounce/blocked.
  --all forces full takeover with a conflict guard requiring
  --force-takeover to overwrite existing records. Supports --list and
  --delete for inspection / rollback.

- package.json (new): nodemailer dependency for SMTP send.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:07:20 -04:00

317 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
'use strict'
/**
* send_gift_campaign.js — Personalized French email sender for Giftbit campaigns.
*
* The Giftbit UI/API generates one gift_url per recipient. Their built-in
* delivery emails are English-only and lack tracking. This script bridges
* the gap: take a CSV of contacts (firstname, lastname, email, description)
* and a CSV of gifts from Giftbit (one column with the gift URL), match
* them row-by-row, and send a branded French email via Mailjet (the SMTP
* already configured on the hub).
*
* Usage:
* node send_gift_campaign.js \
* --gifts ./giftbit-gifts.csv \
* --contacts ./giftbit-contacts-A-first-email.csv \
* --template ./templates/gift-email-fr.html \
* --subject "Votre cadeau Gigafibre" \
* --amount "50 $" \
* --expiry "31 décembre 2026" \
* --commitment-months 3 \
* --from "Gigafibre <noreply@gigafibre.ca>" \
* --smtp-host in-v3.mailjet.com --smtp-port 587 \
* --smtp-user $SMTP_USER --smtp-pass $SMTP_PASS \
* --throttle-ms 600 \
* --dry-run
*
* Remove --dry-run to actually send. With --dry-run the script writes
* the rendered HTML for each recipient to ./preview/ for visual review.
*
* Output: ./results-YYYYMMDD-HHMM.csv with columns
* firstname,lastname,email,gift_url,status,error,timestamp
*
* Match strategy is row-order by default (line N of gifts pairs with line
* N of contacts). Override with --match-by email if your gifts CSV has
* an `email` column to use as the join key (more robust against ordering
* mistakes, but requires Giftbit to include emails in their export).
*/
const fs = require('fs')
const path = require('path')
const readline = require('readline')
// Light deps — kept zero so this script runs anywhere with just node.
// (nodemailer is the only required external; install at use time.)
function parseArgs (argv) {
const out = {}
for (let i = 2; i < argv.length; i++) {
const a = argv[i]
if (a.startsWith('--')) {
const k = a.slice(2)
const next = argv[i + 1]
if (!next || next.startsWith('--')) { out[k] = true } // boolean flag
else { out[k] = next; i++ }
}
}
return out
}
const args = parseArgs(process.argv)
const REQUIRED = ['gifts', 'contacts', 'template', 'subject', 'from']
for (const k of REQUIRED) {
if (!args[k]) {
console.error(`Missing required --${k}`)
console.error('See header comment for usage.')
process.exit(1)
}
}
const DRY_RUN = !!args['dry-run']
const MATCH_BY = args['match-by'] || 'row' // 'row' | 'email'
const THROTTLE_MS = parseInt(args['throttle-ms'] || '600', 10)
const AMOUNT = args.amount || '50 $'
const EXPIRY = args.expiry || ''
const SUBJECT = args.subject
const FROM = args.from
// Retention commitment used in the template's "Condition" pill and prorata
// disclaimer. Default 3 months. Override with --commitment-months 6 etc.
const COMMITMENT_MONTHS = args['commitment-months'] || '3'
// ── CSV parsing ────────────────────────────────────────────────────────────
// Minimal RFC-4180-ish parser. Handles quoted fields with embedded commas
// and escaped double-quotes. Falls back to comma delimiter; auto-detect for
// tab/pipe if the header doesn't contain a comma. Good enough for the
// Giftbit + contacts CSVs we're feeding it.
function parseCsv (text) {
const sample = text.split(/\r?\n/, 1)[0] || ''
const delim = sample.includes(',') ? ',' : (sample.includes('\t') ? '\t' : '|')
const rows = []
let row = [], field = '', inQuotes = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (inQuotes) {
if (c === '"' && text[i + 1] === '"') { field += '"'; i++ }
else if (c === '"') inQuotes = false
else field += c
} else {
if (c === '"') inQuotes = true
else if (c === delim) { row.push(field); field = '' }
else if (c === '\n' || c === '\r') {
if (field !== '' || row.length) { row.push(field); rows.push(row); row = []; field = '' }
if (c === '\r' && text[i + 1] === '\n') i++
} else field += c
}
}
if (field !== '' || row.length) { row.push(field); rows.push(row) }
if (!rows.length) return []
const header = rows[0].map(h => h.trim())
return rows.slice(1).filter(r => r.some(c => c !== '')).map(r => {
const obj = {}
header.forEach((h, i) => { obj[h] = (r[i] || '').trim() })
return obj
})
}
function readCsv (filepath) {
const text = fs.readFileSync(filepath, 'utf8').replace(/^/, '')
return parseCsv(text)
}
// Find the "gift URL" column in the Giftbit export. Giftbit's column
// naming varies between exports; check the common spellings.
function findUrlColumn (row) {
const keys = Object.keys(row)
const candidates = ['gift_url', 'gift link', 'gift_link', 'url', 'link',
'redemption_url', 'gift', 'giftbit_link', 'campaign_link']
for (const c of candidates) {
const hit = keys.find(k => k.toLowerCase().replace(/\s+/g, '_') === c)
if (hit) return hit
}
// Fallback: first column that looks like a URL in the first row
for (const k of keys) {
if ((row[k] || '').match(/^https?:\/\//)) return k
}
return null
}
// ── Matching ───────────────────────────────────────────────────────────────
function matchRowOrder (gifts, contacts, urlCol) {
const matched = []
const n = Math.min(gifts.length, contacts.length)
for (let i = 0; i < n; i++) {
matched.push({ contact: contacts[i], gift_url: gifts[i][urlCol] })
}
return {
matched,
leftover_gifts: gifts.length - n,
leftover_contacts: contacts.length - n,
}
}
function matchByEmail (gifts, contacts, urlCol) {
const emailCol = Object.keys(gifts[0] || {}).find(k => k.toLowerCase().includes('email'))
if (!emailCol) {
throw new Error('--match-by email requested but no email column found in gifts CSV')
}
const giftByEmail = new Map(gifts.map(g => [(g[emailCol] || '').trim().toLowerCase(), g]))
const matched = []
let unmatched = 0
for (const c of contacts) {
const g = giftByEmail.get((c.email || '').trim().toLowerCase())
if (g) matched.push({ contact: c, gift_url: g[urlCol] })
else { unmatched++ }
}
return { matched, leftover_gifts: gifts.length - matched.length, leftover_contacts: unmatched }
}
// ── Template rendering ─────────────────────────────────────────────────────
// Supports two constructs:
// {{var}} simple substitution
// {{#var}}...{{/var}} section block: kept if var is truthy, dropped otherwise
//
// The section pass runs FIRST so that variable expansion can fill in the
// kept body. Non-greedy match with [\s\S] handles multi-line blocks (HTML
// templates span many lines between the open/close tags).
function render (tpl, vars) {
// Pass 1: section blocks (truthy → keep body, falsy → drop everything)
tpl = tpl.replace(/\{\{\s*#\s*(\w+)\s*\}\}([\s\S]*?)\{\{\s*\/\s*\1\s*\}\}/g,
(_, k, body) => (vars[k] ? body : ''))
// Pass 2: simple variable substitution
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => {
const v = vars[k]
return v == null ? '' : String(v)
})
}
// ── Mailer (lazy require so dry-run works without nodemailer installed) ───
function makeTransporter () {
let nodemailer
try { nodemailer = require('nodemailer') } catch (e) {
console.error('ERROR: nodemailer not installed. Run:')
console.error(' npm install nodemailer')
process.exit(1)
}
return nodemailer.createTransport({
host: args['smtp-host'] || 'in-v3.mailjet.com',
port: parseInt(args['smtp-port'] || '587', 10),
secure: false,
auth: {
user: args['smtp-user'] || process.env.SMTP_USER,
pass: args['smtp-pass'] || process.env.SMTP_PASS,
},
})
}
function sleep (ms) { return new Promise(r => setTimeout(r, ms)) }
// ── Main flow ──────────────────────────────────────────────────────────────
async function main () {
console.log(`\n── Gift campaign send — ${DRY_RUN ? 'DRY RUN' : 'LIVE'} ──`)
console.log(` gifts: ${args.gifts}`)
console.log(` contacts: ${args.contacts}`)
console.log(` template: ${args.template}`)
console.log(` subject: "${SUBJECT}"`)
console.log(` amount: ${AMOUNT}`)
console.log(` match: ${MATCH_BY}`)
const gifts = readCsv(args.gifts)
const contacts = readCsv(args.contacts)
const tpl = fs.readFileSync(args.template, 'utf8')
console.log(`\n loaded ${gifts.length} gifts, ${contacts.length} contacts`)
if (!gifts.length) { console.error('No gifts loaded.'); process.exit(1) }
if (!contacts.length) { console.error('No contacts loaded.'); process.exit(1) }
const urlCol = findUrlColumn(gifts[0])
if (!urlCol) {
console.error('Cannot detect gift URL column. Available columns:', Object.keys(gifts[0]))
process.exit(1)
}
console.log(` gift URL column: "${urlCol}"`)
const { matched, leftover_gifts, leftover_contacts } = MATCH_BY === 'email'
? matchByEmail(gifts, contacts, urlCol)
: matchRowOrder(gifts, contacts, urlCol)
console.log(` matched: ${matched.length} (gifts left over: ${leftover_gifts}, contacts skipped: ${leftover_contacts})`)
if (!matched.length) { console.error('No matches.'); process.exit(1) }
// Prepare output paths
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16)
const resultsPath = path.resolve(`results-${stamp}.csv`)
const previewDir = path.resolve(`preview-${stamp}`)
if (DRY_RUN) fs.mkdirSync(previewDir, { recursive: true })
const transporter = DRY_RUN ? null : makeTransporter()
if (transporter) {
await transporter.verify().catch(e => {
console.error('SMTP verify failed:', e.message); process.exit(1)
})
console.log(' SMTP connection OK')
}
// Open results CSV (write header first)
const out = fs.createWriteStream(resultsPath, { encoding: 'utf8' })
out.write('firstname,lastname,email,gift_url,status,error,timestamp\n')
let sent = 0, failed = 0
for (let i = 0; i < matched.length; i++) {
const { contact, gift_url } = matched[i]
const vars = {
firstname: contact.firstname || 'cher client',
lastname: contact.lastname || '',
email: contact.email,
description: contact.description || '',
gift_url,
amount: AMOUNT,
expiry: EXPIRY,
commitment_months: COMMITMENT_MONTHS,
}
const html = render(tpl, vars)
const ts = new Date().toISOString()
try {
if (DRY_RUN) {
const safeEmail = contact.email.replace(/[^a-z0-9._@-]/gi, '_')
fs.writeFileSync(path.join(previewDir, `${String(i + 1).padStart(3, '0')}-${safeEmail}.html`), html)
out.write(`${csvCell(contact.firstname)},${csvCell(contact.lastname)},${csvCell(contact.email)},${csvCell(gift_url)},dry-run,,${ts}\n`)
sent++
} else {
await transporter.sendMail({
from: FROM,
to: `"${contact.firstname} ${contact.lastname}".trim() <${contact.email}>`.replace('""', '"'),
subject: SUBJECT,
html,
})
out.write(`${csvCell(contact.firstname)},${csvCell(contact.lastname)},${csvCell(contact.email)},${csvCell(gift_url)},sent,,${ts}\n`)
sent++
await sleep(THROTTLE_MS)
}
if ((i + 1) % 25 === 0) process.stdout.write(` ${i + 1}/${matched.length} done\n`)
} catch (e) {
out.write(`${csvCell(contact.firstname)},${csvCell(contact.lastname)},${csvCell(contact.email)},${csvCell(gift_url)},failed,${csvCell(e.message)},${ts}\n`)
failed++
console.error(`${contact.email}: ${e.message}`)
}
}
out.end()
console.log(`\n${sent} ${DRY_RUN ? 'previewed' : 'sent'}, ${failed} failed`)
console.log(` → results: ${resultsPath}`)
if (DRY_RUN) console.log(` → previews: ${previewDir}/`)
}
function csvCell (s) {
s = String(s == null ? '' : s)
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
main().catch(e => { console.error('Fatal:', e); process.exit(1) })