#!/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) })