- 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>
201 lines
7.6 KiB
JavaScript
Executable File
201 lines
7.6 KiB
JavaScript
Executable File
#!/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=<MJ_APIKEY_PUBLIC>
|
|
* export SMTP_PASS=<MJ_APIKEY_PRIVATE>
|
|
* 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 <id>
|
|
*/
|
|
|
|
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 <id>
|
|
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 <callback-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) })
|