- 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>
317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
#!/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) })
|