diff --git a/scripts/campaigns/README.md b/scripts/campaigns/README.md index b68cbb3..2661a16 100644 --- a/scripts/campaigns/README.md +++ b/scripts/campaigns/README.md @@ -99,7 +99,7 @@ domain level. The two known-validated senders on this account are: The default for gift campaigns: ``` ---from "Gigafibre Support " +--from "TARGO " ``` Reasoning for `support@` over `noreply@`: campaigns INVITE a reply @@ -129,10 +129,11 @@ node send_gift_campaign.js \ --gifts /path/to/giftbit-gifts.csv \ --contacts /path/to/giftbit-contacts-A-first-email.csv \ --template ./templates/gift-email-fr.html \ - --subject "🎁 Un cadeau pour vous, de la part de Gigafibre" \ - --amount "50 $" \ + --subject "🎁 Un cadeau pour vous, de la part de TARGO" \ + --amount "60 $" \ --expiry "31 décembre 2026" \ - --from "Gigafibre Support " \ + --commitment-months 3 \ + --from "TARGO " \ --dry-run ``` @@ -151,10 +152,11 @@ node send_gift_campaign.js \ --gifts /path/to/giftbit-gifts.csv \ --contacts /path/to/giftbit-contacts-A-first-email.csv \ --template ./templates/gift-email-fr.html \ - --subject "🎁 Un cadeau pour vous, de la part de Gigafibre" \ - --amount "50 $" \ + --subject "🎁 Un cadeau pour vous, de la part de TARGO" \ + --amount "60 $" \ --expiry "31 décembre 2026" \ - --from "Gigafibre Support " \ + --commitment-months 3 \ + --from "TARGO " \ --smtp-host in-v3.mailjet.com --smtp-port 587 \ --smtp-user "$SMTP_USER" --smtp-pass "$SMTP_PASS" \ --throttle-ms 600 @@ -185,11 +187,13 @@ Variables resolved at send time: | `{{gift_url}}` | matched from the gifts CSV | | `{{amount}}` | `--amount` CLI flag (e.g. `"50 $"`) | | `{{expiry}}` | `--expiry` CLI flag (e.g. `"31 décembre 2026"`) | +| `{{commitment_months}}` | `--commitment-months` CLI flag (default `3`) — used in the "Condition" pill and prorata-refund disclaimer of the retention offer | -The template uses a vintage `{{#expiry}} ... {{/expiry}}` block for the -optional expiry line — currently rendered as plain text (the script's -simple `{{var}}` renderer doesn't strip the tags). If you don't want the -expiry sentence, edit the template directly to remove that block. +The template uses a Mustache-style `{{#expiry}} ... {{/expiry}}` block for +the optional expiry line. The renderer keeps the contents when the +matching variable is truthy and drops them entirely otherwise — so if you +omit `--expiry` from the CLI, the "Le lien expire le …" sentence +disappears cleanly with no orphan tags showing. ## Source data — the two CSVs diff --git a/scripts/campaigns/package-lock.json b/scripts/campaigns/package-lock.json new file mode 100644 index 0000000..5bae6f0 --- /dev/null +++ b/scripts/campaigns/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "campaigns", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "campaigns", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "nodemailer": "^8.0.7" + } + }, + "node_modules/nodemailer": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", + "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + } + } +} diff --git a/scripts/campaigns/package.json b/scripts/campaigns/package.json new file mode 100644 index 0000000..ab30e39 --- /dev/null +++ b/scripts/campaigns/package.json @@ -0,0 +1,16 @@ +{ + "name": "campaigns", + "version": "1.0.0", + "description": "One-shot tool to send Giftbit gift cards to a list of contacts with a branded French email, bypassing Giftbit's English-only built-in delivery.", + "main": "create_giftbit_campaign.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "nodemailer": "^8.0.7" + } +} diff --git a/scripts/campaigns/send_gift_campaign.js b/scripts/campaigns/send_gift_campaign.js index 8b00480..a40b0a8 100644 --- a/scripts/campaigns/send_gift_campaign.js +++ b/scripts/campaigns/send_gift_campaign.js @@ -18,6 +18,7 @@ * --subject "Votre cadeau Gigafibre" \ * --amount "50 $" \ * --expiry "31 décembre 2026" \ + * --commitment-months 3 \ * --from "Gigafibre " \ * --smtp-host in-v3.mailjet.com --smtp-port 587 \ * --smtp-user $SMTP_USER --smtp-pass $SMTP_PASS \ @@ -75,6 +76,9 @@ 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 @@ -164,7 +168,18 @@ function matchByEmail (gifts, contacts, urlCol) { } // ── 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) @@ -247,13 +262,14 @@ async function main () { 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 || '', + firstname: contact.firstname || 'cher client', + lastname: contact.lastname || '', + email: contact.email, + description: contact.description || '', gift_url, - amount: AMOUNT, - expiry: EXPIRY, + amount: AMOUNT, + expiry: EXPIRY, + commitment_months: COMMITMENT_MONTHS, } const html = render(tpl, vars) const ts = new Date().toISOString() diff --git a/scripts/campaigns/setup_mailjet_webhook.js b/scripts/campaigns/setup_mailjet_webhook.js new file mode 100755 index 0000000..4b40957 --- /dev/null +++ b/scripts/campaigns/setup_mailjet_webhook.js @@ -0,0 +1,200 @@ +#!/usr/bin/env node +'use strict' +/** + * setup_mailjet_webhook.js — Register the Hub's /campaigns/webhook URL with + * Mailjet's Event API for every event type we care about. + * + * Mailjet's API uses ONE eventcallbackurl record PER event type. We want to + * be notified about: sent, open, click, bounce, blocked, spam, unsub. So this + * script idempotently registers (POST) or updates (PUT) one record per type. + * + * Auth: SMTP_USER + SMTP_PASS env vars (same creds work for the REST API on + * Mailjet — they call them API_PUBLIC_KEY / API_PRIVATE_KEY but the values + * are identical to the SMTP credentials). + * + * Usage: + * export SMTP_USER= + * export SMTP_PASS= + * node setup_mailjet_webhook.js --url https://msg.gigafibre.ca/campaigns/webhook + * + * # Production-safe defaults: + * # --is-backup false primary (not backup) callback URL + * # --group-events true send events as a JSON array (recommended, + * # minimizes hub load — one POST per ~50 events + * # instead of one POST per event) + * + * To inspect / delete what's registered: + * node setup_mailjet_webhook.js --list + * node setup_mailjet_webhook.js --delete + */ + +const https = require('https') + +const ALL_EVENTS = ['sent', 'open', 'click', 'bounce', 'blocked', 'spam', 'unsub'] +// Safe defaults: only events that aren't typically already claimed by other +// integrations (WP-Mail-SMTP on targo.ca currently owns sent/bounce/blocked +// — see `--list` output). open + click are the events the gift campaign +// actually needs for tracking; spam + unsub are nice-to-have signals. +const SAFE_EVENTS = ['open', 'click', 'spam', 'unsub'] + +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 + else { out[k] = next; i++ } + } + } + return out +} + +function mjApi (method, urlPath, body, { user, pass }) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null + const auth = Buffer.from(`${user}:${pass}`).toString('base64') + const req = https.request({ + host: 'api.mailjet.com', + path: '/v3/REST' + urlPath, + method, + headers: { + 'Authorization': 'Basic ' + auth, + 'Accept': 'application/json', + ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}), + }, + }, res => { + let chunks = '' + res.on('data', c => { chunks += c }) + res.on('end', () => { + try { resolve({ status: res.statusCode, body: chunks ? JSON.parse(chunks) : {} }) } + catch (e) { resolve({ status: res.statusCode, body: chunks }) } + }) + }) + req.on('error', reject) + if (data) req.write(data) + req.end() + }) +} + +async function listCallbacks (creds) { + const r = await mjApi('GET', '/eventcallbackurl?Limit=100', null, creds) + if (r.status !== 200) throw new Error(`GET eventcallbackurl ${r.status}: ${JSON.stringify(r.body)}`) + return r.body.Data || [] +} + +async function deleteCallback (id, creds) { + const r = await mjApi('DELETE', `/eventcallbackurl/${id}`, null, creds) + return r.status === 204 || r.status === 200 +} + +async function upsert (eventType, url, isBackup, creds, existing) { + // existing array is the result of GET — find a matching record (same + // EventType + IsBackup combination). Mailjet only allows ONE primary + + // ONE backup URL per event, so this combination is the unique key. + const match = existing.find(r => r.EventType === eventType && Boolean(r.IsBackup) === isBackup) + const payload = { EventType: eventType, IsBackup: isBackup, Url: url, Status: 'alive', Version: 2 } + if (match) { + const r = await mjApi('PUT', `/eventcallbackurl/${match.ID}`, payload, creds) + return { action: 'updated', id: match.ID, status: r.status, ok: r.status === 200 } + } else { + const r = await mjApi('POST', '/eventcallbackurl', payload, creds) + const id = r.body.Data?.[0]?.ID + return { action: 'created', id, status: r.status, ok: r.status === 201 || r.status === 200 } + } +} + +async function main () { + const args = parseArgs(process.argv) + const user = process.env.SMTP_USER || process.env.MJ_APIKEY_PUBLIC + const pass = process.env.SMTP_PASS || process.env.MJ_APIKEY_PRIVATE + if (!user || !pass) { + console.error('Set SMTP_USER + SMTP_PASS (or MJ_APIKEY_PUBLIC + MJ_APIKEY_PRIVATE).') + process.exit(1) + } + const creds = { user, pass } + + // --list — dump current config and exit + if (args.list) { + const callbacks = await listCallbacks(creds) + console.log(`\n── Registered event callbacks: ${callbacks.length} ──`) + for (const c of callbacks) { + const flag = c.IsBackup ? '[BACKUP]' : '[PRIMARY]' + console.log(` ${flag} id=${c.ID} event=${c.EventType.padEnd(10)} status=${c.Status} v${c.Version} → ${c.Url}`) + } + process.exit(0) + } + + // --delete + if (args.delete && args.delete !== true) { + const ok = await deleteCallback(args.delete, creds) + console.log(ok ? ` ✓ deleted callback ${args.delete}` : ` ✗ delete failed`) + process.exit(ok ? 0 : 1) + } + + const url = args.url + if (!url || url === true) { + console.error('Missing --url ') + console.error('Example: --url https://msg.gigafibre.ca/campaigns/webhook') + process.exit(1) + } + if (!url.startsWith('https://')) { + console.error('Mailjet requires HTTPS. Got:', url) + process.exit(1) + } + const isBackup = args['is-backup'] === 'true' || args['is-backup'] === true + + // Resolve which events to configure + let events + if (args.all) { + events = ALL_EVENTS + } else if (args.events && args.events !== true) { + events = args.events.split(',').map(e => e.trim()).filter(Boolean) + } else { + events = SAFE_EVENTS + } + + const existing = await listCallbacks(creds) + + // Pre-flight: detect conflicts with existing PRIMARY records pointing + // elsewhere. Refuse to overwrite unless --force-takeover is passed. + const conflicts = events + .map(ev => ({ ev, hit: existing.find(r => r.EventType === ev && Boolean(r.IsBackup) === isBackup) })) + .filter(c => c.hit && c.hit.Url !== url) + + console.log(`\n── Mailjet Event API webhook setup ──`) + console.log(` callback URL: ${url}`) + console.log(` type: ${isBackup ? 'BACKUP' : 'PRIMARY'}`) + console.log(` events: ${events.join(', ')}`) + console.log(` existing: ${existing.length} records on the account`) + + if (conflicts.length && !args['force-takeover']) { + console.log(`\n ⚠ Conflicts detected — these events already point elsewhere:`) + for (const c of conflicts) { + console.log(` • ${c.ev.padEnd(10)} → ${c.hit.Url} (id=${c.hit.ID})`) + } + console.log(`\n Refusing to overwrite without --force-takeover. Either:`) + console.log(` • Exclude the conflicting events: --events open,click`) + console.log(` • Or override the existing config: --force-takeover`) + process.exit(1) + } + console.log() + + let okCount = 0 + for (const ev of events) { + process.stdout.write(` ${ev.padEnd(10)} ... `) + const r = await upsert(ev, url, isBackup, creds, existing) + if (r.ok) { + okCount++ + console.log(`✓ ${r.action} (id=${r.id})`) + } else { + console.log(`✗ status=${r.status}`) + } + } + + console.log(`\n ${okCount}/${events.length} events configured.`) + console.log(`\n Verify with: node setup_mailjet_webhook.js --list`) + console.log(` Mailjet dashboard: Account Settings → REST API → Event tracking\n`) +} + +main().catch(e => { console.error('Fatal:', e); process.exit(1) }) diff --git a/scripts/campaigns/templates/gift-email-fr.html b/scripts/campaigns/templates/gift-email-fr.html index dd6d46e..9394183 100644 --- a/scripts/campaigns/templates/gift-email-fr.html +++ b/scripts/campaigns/templates/gift-email-fr.html @@ -3,97 +3,329 @@ -Un cadeau de Gigafibre +Une offre exclusive de TARGO - - - -
+ -
+ + + style="max-width:600px; background:#ffffff; border:1px solid #e5e7eb; border-radius:14px; overflow:hidden;"> - + - + + + + + + + + + + - + - - + + + + - + + + + + + + + + + - + + + + - + + + + + + {{#expiry}} + + + + + + {{/expiry}} + + + + + + +
-
- Gigafibre · Récompense -
-
- 🎁 Un cadeau pour vous +
+ TARGO +
+

Bonjour {{firstname}},

+

+ Tu choisis local, on veut te remercier. +

+

+ Avec l'arrivée de l'été, voici ton + offre exclusive pour un temps limité : +

+
+
+
+ Carte-cadeau numérique +
+
+ 🎁 {{amount}} chez des centaines de marques +
-

Bonjour {{firstname}},

- -

- Merci de faire partie de la famille Gigafibre. Pour vous remercier - de votre fidélité, voici une carte-cadeau d'une valeur de - {{amount}}, utilisable sur les marchands de votre choix. -

- - - - -

- Le lien vous mène à une page sécurisée où vous pourrez choisir la - marque qui vous fait plaisir (Amazon, Tim Hortons, SAQ, App Store, - et plusieurs autres). -

- {{#expiry}} -

- ⏰ Le lien expire le {{expiry}}. -

- {{/expiry}} +
+ + + + + +
+
+
+ Envoi +
+
+ ⚡ Instantané à l'activation +
+
+
+
+
+ Condition +
+
+ 🤝 Rester encore {{commitment_months}} mois ou + +
+
+
-
- Vous recevez ce cadeau parce que vous êtes client(e) Gigafibre à - l'adresse {{description}}. - Si vous avez la moindre question, écrivez-nous à - facturation@targointernet.com - ou appelez-nous au 514 242-1500. +
+ + ✅ Option 1 + +
+ + + + + +
+
+ 🎁  {{amount}} +
+
+ Activer ma carte-cadeau +
+
+ Choisir ma carte sur Giftbit → +
+
+
+
+
+ 🪂 En cas de départ avant {{commitment_months}} mois, le prorata du montant est remboursable.
-
- Gigafibre — Internet fibre optique au Québec
- www.gigafibre.ca +
+ + ⏭️ Option 2 + +
+
+ Ne rien faire. Ton abonnement mensuel se poursuit normalement, + sans engagement ni carte-cadeau. +
+
+
+ ⏰ Cette offre expire le {{expiry}}. +
+
+
+ 🤝 Merci de faire rouler l'économie de notre région avec nous ! +
+
+ L'équipe TARGO
+ + + + + + + + +
+
+ Quelques exemples de choix pour votre carte cadeau : +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ Amazon +
+
+ + +
+ IGA +
+
+ + +
+ Tim Hortons +
+
+ + +
+ $1 Plus +
+
+ + +
+ Pizza Pizza +
+
+ + +
+ Home Depot +
+
+ + +
+ Best Buy +
+
+ + +
+ Walmart +
+
+ + +
+ Petro-Canada +
+
+ + +
+ Esso +
+
+ + +
+ Home Hardware +
+
+ + +
+ Sobeys +
+
+
+ + + + + + + + + +
+
+ Tu reçois ce courriel parce que tu es client(e) TARGO à + {{description}}.
+ Une question ? Écris-nous à + facturation@targointernet.com + ou appelle au 514 242-1500. +
+
+
+ TARGO + — Internet fibre optique au Québec — service Gigafibre
+ targointernet.com + · + gigafibre.ca +
+
+
- -
-