#!/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" \ * --from "Gigafibre " \ * --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 // ── 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 ───────────────────────────────────────────────────── function render (tpl, vars) { 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, } 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) })