'use strict' // ───────────────────────────────────────────────────────────────────────────── // Gift campaigns — backend for the ops UI. // // One campaign = one Mailjet blast of personalized French emails containing a // Giftbit shortlink. Two CSVs come in (raw Map export + Giftbit shortlinks), // we match each contact to an ERPNext Customer (email / phone / civic address), // store the send list as JSON, and run the send in the background with live // progress over SSE. // // Storage layout: // data/campaigns/.json // { // id, name, created_at, status: draft|sending|completed|failed, // params: { amount, expiry, commitment_months, subject, from, template, // throttle_ms, smtp_host, smtp_port }, // counters: { total, sent, failed, queued, opened, clicked, bounced }, // recipients: [{ // firstname, lastname, email, phone, civic_address, postal_code, // gift_url, giftbit_uuid, gift_value_cents, // customer_id, # null if unmatched // customer_name, # display name from ERPNext // match_method, # 'email' | 'phone' | 'civic' | null // match_confidence, # 1.0 (exact) | 0.8 (postal+civic) | 0 (none) // status, # pending | queued | sent | failed | clicked | bounced // mailjet_uuid, # set when sent (used by webhook to update) // error, sent_at, opened_at, clicked_at, // excluded # true = skip on send // }] // } // // SSE topic: 'campaign:' — emits 'recipient-update' on every status change // and 'campaign-done' when send finishes. // ───────────────────────────────────────────────────────────────────────────── const fs = require('fs') const path = require('path') const crypto = require('crypto') const cfg = require('./config') const { log, json, parseBody } = require('./helpers') const erp = require('./erp') const email = require('./email') const sse = require('./sse') const DATA_DIR = path.join(__dirname, '..', 'data', 'campaigns') if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) // ── CSV utilities ──────────────────────────────────────────────────────────── // Same RFC-4180-ish parser as the CLI scripts. Handles quoted fields with // embedded delimiters and escaped double-quotes. Delimiter auto-detect // (comma / tab / pipe) based on the first line. function parseCsv (text, opts = {}) { const skipPreamble = !!opts.skipPreamble let work = text.replace(/^/, '') // strip BOM if (skipPreamble) work = work.replace(/^[^\n]*\n/, '') // drop title line const sample = work.split(/\r?\n/, 1)[0] || '' const delim = sample.includes('|') ? '|' : sample.includes('\t') ? '\t' : ',' const rows = []; let row = [], field = '', inQ = false for (let i = 0; i < work.length; i++) { const c = work[i] if (inQ) { if (c === '"' && work[i + 1] === '"') { field += '"'; i++ } else if (c === '"') inQ = false else field += c } else { if (c === '"') inQ = 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' && work[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 o = {}; header.forEach((h, i) => { o[h] = (r[i] || '').trim() }); return o }) } // ── Address / name normalization (mirrors contacts_from_legacy.py) ────────── const LOWER_WORDS = new Set(['de','du','des','la','le','les','au','aux','à', 'et','sur','en']) const EMAIL_SPLIT = /\s*[;,]\s*/ function titleAddress (addr) { if (!addr) return '' return addr.split(/\s+/).map((word, i) => { const lw = word.toLowerCase() if (i > 0 && LOWER_WORDS.has(lw)) return lw if (word.includes('-')) { return word.split('-').map(c => { const cl = c.toLowerCase() return LOWER_WORDS.has(cl) ? cl : (c[0] || '').toUpperCase() + c.slice(1).toLowerCase() }).join('-') } return (word[0] || '').toUpperCase() + word.slice(1).toLowerCase() }).join(' ') } function normalizeEmail (raw) { return (raw || '').trim().toLowerCase() } // Strip non-digit chars; treat as Canadian 10-digit if longer than 10 function normalizePhone (raw) { const digits = (raw || '').replace(/\D/g, '') if (digits.length === 11 && digits.startsWith('1')) return digits.slice(1) if (digits.length === 10) return digits return digits || null } // Canadian postal H1A 1B1 — uppercase, no internal space function normalizePostal (raw) { return (raw || '').replace(/\s+/g, '').toUpperCase().replace(/^([A-Z]\d[A-Z])(\d[A-Z]\d)$/, '$1 $2') } // Civic = numeric prefix + first non-empty street word. We compare prefix + // first 5 chars of street to avoid false-positives from "Rue" vs "Route". function normalizeCivic (addr) { if (!addr) return null const m = addr.trim().match(/^(\d+[A-Za-z]?)\s+(.+)$/) if (!m) return null const num = m[1].toUpperCase() // Strip "Rue", "Route", "Boulevard" etc. — they're noise for matching const street = m[2].replace(/^(rue|route|boulevard|boul\.?|avenue|av\.?|chemin|ch\.?|place|pl\.?)\s+/i, '').trim() return num + '|' + street.toLowerCase().slice(0, 12) } // ── Map CSV parsing — port of contacts_from_legacy.py ──────────────────────── // The legacy export has a 1-line title preamble + pipe-delimited columns. // Columns we care about: // "nom au compte" — billing contact name (preferred) // "nom à l'adresse" — service-address name (fallback) // "email au compte" — billing email (preferred) // "email à l'adresse" — service-address email (fallback) // "telephone au compte" or "telephone à l'adresse" — phone for matching // "adresse dans F" — street address // "code postal au compte" or "code postal à l'adresse" — postal // "id emplacement" — legacy_delivery_id (note: only 25% resolves) function parseMapCsv (text, multi = 'first') { const rows = parseCsv(text, { skipPreamble: true }) const contacts = [] const seen = new Set() let skippedNoEmail = 0, skippedNoName = 0 for (const r of rows) { // Pull emails from either source column let rawEmails = (r['email au compte'] || r["email à l'adresse"] || '').trim() if (!rawEmails) { skippedNoEmail++; continue } const emails = rawEmails.split(EMAIL_SPLIT) .map(e => normalizeEmail(e)) .filter(e => e.includes('@') && e.split('@')[1]?.includes('.')) if (!emails.length) { skippedNoEmail++; continue } const sendEmails = multi === 'split' ? emails : multi === 'skip' ? (emails.length > 1 ? [] : emails) : emails.slice(0, 1) if (!sendEmails.length) continue const full = (r['nom au compte'] || r["nom à l'adresse"] || '').trim() if (!full) { skippedNoName++; continue } const parts = full.split(/\s+/, 2) const firstname = parts[0] || '' const lastname = parts.length > 1 ? full.slice(parts[0].length).trim() : '' const civic_address = titleAddress(r['adresse dans F'] || '') const postal_code = normalizePostal(r['code postal au compte'] || r["code postal à l'adresse"] || '') const phone = normalizePhone(r['telephone au compte'] || r["telephone à l'adresse"] || '') for (const em of sendEmails) { if (seen.has(em)) continue seen.add(em) contacts.push({ firstname, lastname, email: em, phone, civic_address, postal_code, }) } } return { contacts, skipped: { no_email: skippedNoEmail, no_name: skippedNoName, total_rows: rows.length } } } // ── Giftbit CSV parsing ────────────────────────────────────────────────────── function parseGiftbitCsv (text) { const rows = parseCsv(text) if (!rows.length) return [] // Detect URL column from common names const keys = Object.keys(rows[0]) const urlCandidates = ['gift_url','gift link','gift_link','url','link','shortlink', 'redemption_url','gift','giftbit_link','campaign_link'] let urlCol = null for (const c of urlCandidates) { const hit = keys.find(k => k.toLowerCase().replace(/\s+/g, '_') === c) if (hit) { urlCol = hit; break } } // Fallback: any column with http(s):// if (!urlCol) { for (const k of keys) { if ((rows[0][k] || '').match(/^https?:\/\//)) { urlCol = k; break } } } if (!urlCol) return [] return rows.map(r => ({ gift_url: r[urlCol], giftbit_uuid: r.giftbit_uuid || r.uuid || r.gift_id || '', gift_value_cents: parseInt(r.gift_value_cents || r.value_cents || r.amount || '0', 10) || 0, email: normalizeEmail(r.email || ''), firstname: r.firstname || '', lastname: r.lastname || '', internal_id: r.internal_id || '', })) } // ── Customer matching against ERPNext ──────────────────────────────────────── // Strategy: email → phone → civic+postal. First hit wins. Confidence: // 1.0 = exact email match // 0.9 = exact phone match // 0.8 = civic + postal_code on a Service Location (then customer link) // 0 = unmatched async function matchCustomer (recipient) { // 1) Email match on Customer.email_id (most reliable) if (recipient.email) { try { const r = await erp.list('Customer', { fields: ['name', 'customer_name', 'email_id', 'mobile_no'], filters: [['email_id', '=', recipient.email]], limit: 1, }) if (r && r.length) { return { customer_id: r[0].name, customer_name: r[0].customer_name, match_method: 'email', match_confidence: 1.0 } } } catch (e) { log('match email error:', e.message) } } // 2) Phone match if (recipient.phone) { try { // Try both mobile_no (with various formattings) and the canonical 10-digit form const r = await erp.list('Customer', { fields: ['name', 'customer_name', 'mobile_no'], filters: [['mobile_no', 'like', '%' + recipient.phone.slice(-7) + '%']], limit: 5, }) // Filter to exact digit match const hit = (r || []).find(c => normalizePhone(c.mobile_no) === recipient.phone) if (hit) { return { customer_id: hit.name, customer_name: hit.customer_name, match_method: 'phone', match_confidence: 0.9 } } } catch (e) { log('match phone error:', e.message) } } // 3) Civic + postal on Service Location → Customer if (recipient.civic_address && recipient.postal_code) { try { const civic = normalizeCivic(recipient.civic_address) if (civic) { const [num, streetPrefix] = civic.split('|') const r = await erp.list('Service Location', { fields: ['name', 'address_line', 'postal_code', 'customer', 'customer_name'], filters: [ ['postal_code', '=', recipient.postal_code], ['address_line', 'like', num + '%'], ], limit: 10, }) const hit = (r || []).find(sl => { const slCivic = normalizeCivic(sl.address_line) return slCivic && slCivic.startsWith(num + '|') && slCivic.split('|')[1]?.startsWith(streetPrefix.slice(0, 5)) }) if (hit && hit.customer) { return { customer_id: hit.customer, customer_name: hit.customer_name || hit.customer, match_method: 'civic', match_confidence: 0.8 } } } } catch (e) { log('match civic error:', e.message) } } return { customer_id: null, customer_name: null, match_method: null, match_confidence: 0 } } // ── Storage helpers ────────────────────────────────────────────────────────── function campaignPath (id) { // Defensive: prevent path traversal — IDs are uuid-like; reject anything else if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error('invalid campaign id') return path.join(DATA_DIR, id + '.json') } function listCampaigns () { if (!fs.existsSync(DATA_DIR)) return [] return fs.readdirSync(DATA_DIR) .filter(f => f.endsWith('.json')) .map(f => { try { const c = JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8')) return { id: c.id, name: c.name, created_at: c.created_at, status: c.status, counters: c.counters, total: (c.recipients || []).length } } catch { return null } }) .filter(Boolean) .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) } function loadCampaign (id) { const p = campaignPath(id) if (!fs.existsSync(p)) return null return JSON.parse(fs.readFileSync(p, 'utf8')) } function saveCampaign (campaign) { // Recompute counters from recipients every save — single source of truth const c = { ...campaign } c.counters = (c.recipients || []).reduce((acc, r) => { acc[r.status] = (acc[r.status] || 0) + 1 return acc }, { total: (c.recipients || []).length }) fs.writeFileSync(campaignPath(c.id), JSON.stringify(c, null, 2)) return c } function newCampaignId () { const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') const slug = crypto.randomBytes(3).toString('hex') return `cmp-${stamp}-${slug}` } // ── Template rendering (mirrors send_gift_campaign.js) ─────────────────────── function renderTemplate (tpl, vars) { // Section blocks first: {{#var}}...{{/var}} kept if var truthy tpl = tpl.replace(/\{\{\s*#\s*(\w+)\s*\}\}([\s\S]*?)\{\{\s*\/\s*\1\s*\}\}/g, (_, k, body) => (vars[k] ? body : '')) return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => { const v = vars[k] return v == null ? '' : String(v) }) } // Default template path — bundled inside the hub at services/targo-hub/templates/. // Can be overridden per campaign via params.template_path (e.g. for A/B testing). // Keep the file in sync with scripts/campaigns/templates/gift-email-fr.html // (the CLI uses that copy for one-off batch sends outside the hub). const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates') const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html') // Allow-list templates that can be edited via the API. Anything outside this // list is rejected (path-traversal defence + intent: only campaign-related // templates are exposed; contract-residential.html etc. are NOT editable here). const EDITABLE_TEMPLATES = ['gift-email-fr'] function templatePath (name) { if (!EDITABLE_TEMPLATES.includes(name)) { throw new Error(`template not editable: ${name}`) } return path.join(TEMPLATES_DIR, name + '.html') } function listEditableTemplates () { return EDITABLE_TEMPLATES.map(name => { const p = path.join(TEMPLATES_DIR, name + '.html') let size = 0, modified = null try { const stat = fs.statSync(p) size = stat.size modified = stat.mtime.toISOString() } catch {} return { name, size, modified } }) } function readTemplate (tplPath) { const p = tplPath || DEFAULT_TEMPLATE if (!fs.existsSync(p)) { throw new Error(`Template not found: ${p}`) } return fs.readFileSync(p, 'utf8') } // ── Send-async worker ──────────────────────────────────────────────────────── // Iterates recipients (not excluded, status=pending), sends each via the // existing email lib, broadcasts each status change over SSE. Throttle is // configurable. Failures stop only that recipient — the loop continues. // // Runs in the background — caller fires this and returns immediately. const activeWorkers = new Set() async function sendCampaignAsync (id) { if (activeWorkers.has(id)) { log(`campaign ${id} already sending`) return } activeWorkers.add(id) const topic = `campaign:${id}` try { let campaign = loadCampaign(id) if (!campaign) throw new Error(`campaign ${id} not found`) campaign.status = 'sending' campaign.send_started_at = new Date().toISOString() campaign = saveCampaign(campaign) sse.broadcast(topic, 'campaign-status', { id, status: 'sending' }) const p = campaign.params || {} const tplText = readTemplate(p.template_path) const throttle = parseInt(p.throttle_ms || 600, 10) for (let i = 0; i < campaign.recipients.length; i++) { const r = campaign.recipients[i] if (r.excluded || r.status !== 'pending') continue // Mark queued so the UI shows movement immediately r.status = 'queued' saveCampaign(campaign) sse.broadcast(topic, 'recipient-update', { i, recipient: r }) const vars = { firstname: r.firstname || 'cher client', lastname: r.lastname || '', email: r.email, description: r.civic_address || '', gift_url: r.gift_url, amount: p.amount || '50 $', expiry: p.expiry || '', commitment_months: p.commitment_months || '3', } const html = renderTemplate(tplText, vars) const toName = `${r.firstname || ''} ${r.lastname || ''}`.trim() const to = toName ? `"${toName}" <${r.email}>` : r.email // Set Mailjet CustomID = campaign_id:recipient_index so the Event API // webhook events can be matched back to a specific recipient row. // Mailjet echoes this value in every event under the CustomID field; // SMTP messageId alone is not a reliable join key (different ID space). const customId = `${id}:${i}` r.mailjet_custom_id = customId // email.sendEmail returns the nodemailer info object on success // (truthy, with .messageId), or `false` on failure (error logged in // lib/email.js). It doesn't throw. We treat falsy = failed. let sendRes try { sendRes = await email.sendEmail({ to, subject: p.subject || 'Un cadeau pour vous, de la part de TARGO', html, from: p.from || cfg.MAIL_FROM, headers: { 'X-MJ-CustomID': customId }, }) } catch (e) { sendRes = false r.error = String(e.message || e).slice(0, 500) } if (sendRes && sendRes.messageId !== undefined) { r.mailjet_uuid = sendRes.messageId || null // SMTP Message-ID for reference r.status = 'sent' r.sent_at = new Date().toISOString() r.error = null } else { r.status = 'failed' if (!r.error) r.error = 'SMTP send returned false (see hub logs)' log(`campaign ${id} recipient ${i} failed:`, r.error) } saveCampaign(campaign) sse.broadcast(topic, 'recipient-update', { i, recipient: r }) if (throttle > 0) await new Promise(rs => setTimeout(rs, throttle)) } campaign = loadCampaign(id) campaign.status = 'completed' campaign.send_completed_at = new Date().toISOString() campaign = saveCampaign(campaign) sse.broadcast(topic, 'campaign-done', { id, counters: campaign.counters }) log(`campaign ${id} done — ${campaign.counters.sent || 0} sent, ${campaign.counters.failed || 0} failed`) } catch (e) { log(`campaign ${id} worker failed:`, e.message) try { const c = loadCampaign(id) if (c) { c.status = 'failed' c.error = String(e.message || e) saveCampaign(c) sse.broadcast(topic, 'campaign-done', { id, error: c.error }) } } catch {} } finally { activeWorkers.delete(id) } } // ── Mailjet webhook receiver ───────────────────────────────────────────────── // Mailjet's Event API posts a JSON array of events: // [{"event": "sent"|"open"|"click"|"bounce"|"blocked"|"spam"|"unsub", // "MessageID": 1234, "CustomID": "", // "time": 1234567890, "email": "...", "Payload": "..."}, ...] // We match by mailjet_uuid (== MessageID) and update status across all // campaigns. We don't know which campaign a given event belongs to without // scanning, but for the volumes we're dealing with (a few campaigns × ~200 // recipients each) scanning all open JSON files per webhook is fine. function mailjetEventToStatus (event) { switch ((event || '').toLowerCase()) { case 'sent': return null // already 'sent' from SMTP — informational case 'open': return 'opened' case 'click': return 'clicked' case 'bounce': case 'hardbounce': case 'softbounce': return 'bounced' case 'blocked': case 'spam': case 'unsub': return 'failed' default: return null } } function applyWebhookEvent (ev) { const newStatus = mailjetEventToStatus(ev.event) if (!newStatus) return false // Primary join key: CustomID = ":" which we // injected on send via X-MJ-CustomID. Falling back to MessageID for events // that might predate the CustomID rollout. const customId = String(ev.CustomID || ev.custom_id || '') const msgId = String(ev.MessageID || ev.message_id || '') // Fast path: parse CustomID to skip the campaign scan entirely if (customId && customId.includes(':')) { const [campId, idxStr] = customId.split(':') const idx = parseInt(idxStr, 10) const c = loadCampaign(campId) if (c && c.recipients && c.recipients[idx]) { const r = c.recipients[idx] r.status = newStatus if (newStatus === 'opened') r.opened_at = new Date((ev.time || 0) * 1000).toISOString() if (newStatus === 'clicked') r.clicked_at = new Date((ev.time || 0) * 1000).toISOString() if (newStatus === 'bounced' || newStatus === 'failed') { r.error = ev.error || ev.error_related_to || ev.event } saveCampaign(c) sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i: idx, recipient: r }) return true } } // Fallback: scan all campaigns for a recipient matching msgId (slower) if (!msgId) return false for (const meta of listCampaigns()) { const c = loadCampaign(meta.id) if (!c) continue for (let i = 0; i < (c.recipients || []).length; i++) { const r = c.recipients[i] if (String(r.mailjet_uuid) !== msgId) continue r.status = newStatus if (newStatus === 'opened') r.opened_at = new Date((ev.time || 0) * 1000).toISOString() if (newStatus === 'clicked') r.clicked_at = new Date((ev.time || 0) * 1000).toISOString() if (newStatus === 'bounced' || newStatus === 'failed') { r.error = ev.error || ev.error_related_to || ev.event } saveCampaign(c) sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r }) return true } } return false } // ── HTTP routing ───────────────────────────────────────────────────────────── async function handle (req, res, method, path) { // POST /campaigns/parse — preview matched send list (no save) if (path === '/campaigns/parse' && method === 'POST') { const body = await parseBody(req) const { map_csv, giftbit_csv, multi } = body || {} if (!map_csv || !giftbit_csv) { return json(res, 400, { error: 'map_csv and giftbit_csv required' }) } const { contacts, skipped } = parseMapCsv(map_csv, multi || 'first') const gifts = parseGiftbitCsv(giftbit_csv) // Match contacts ↔ gifts (by row order, fallback to email) const recipients = [] const n = Math.min(contacts.length, gifts.length) for (let i = 0; i < n; i++) { const c = contacts[i]; const g = gifts[i] // If Giftbit echoed back our email, prefer that match for robustness const giftByEmail = gifts.find(gg => normalizeEmail(gg.email) === c.email) const gift = giftByEmail || g const match = await matchCustomer(c) recipients.push({ ...c, gift_url: gift.gift_url, giftbit_uuid: gift.giftbit_uuid, gift_value_cents: gift.gift_value_cents, ...match, status: 'pending', excluded: false, }) } return json(res, 200, { recipients, leftover_gifts: gifts.length - n, leftover_contacts: contacts.length - n, skipped, }) } // POST /campaigns — create from a parsed send list if (path === '/campaigns' && method === 'POST') { const body = await parseBody(req) const { name, params, recipients } = body || {} if (!Array.isArray(recipients) || !recipients.length) { return json(res, 400, { error: 'recipients[] required' }) } const id = newCampaignId() const campaign = { id, name: name || `Campagne ${id}`, created_at: new Date().toISOString(), status: 'draft', params: params || {}, recipients: recipients.map(r => ({ ...r, status: r.status || 'pending' })), } const saved = saveCampaign(campaign) return json(res, 200, saved) } // GET /campaigns — list summaries if (path === '/campaigns' && method === 'GET') { return json(res, 200, { campaigns: listCampaigns() }) } // ── Template CRUD (for the GrapesJS editor in the ops UI) ───────────────── // ORDER MATTERS: these template routes must be BEFORE the /campaigns/:id // wildcard below, otherwise paths like /campaigns/templates get matched // by the wildcard as if "templates" were a campaign ID. // GET /campaigns/templates — list editable templates with metadata if (path === '/campaigns/templates' && method === 'GET') { return json(res, 200, { templates: listEditableTemplates() }) } // GET /campaigns/templates/:name — return current HTML content const tplGet = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)$/) if (tplGet && method === 'GET') { try { const html = fs.readFileSync(templatePath(tplGet[1]), 'utf8') return json(res, 200, { name: tplGet[1], html }) } catch (e) { return json(res, 404, { error: 'template not found', detail: e.message }) } } // PUT /campaigns/templates/:name — save new HTML // Keeps the previous version as .bak-.html so a bad edit can // be rolled back without git access. if (tplGet && method === 'PUT') { const body = await parseBody(req) if (typeof body.html !== 'string' || !body.html) { return json(res, 400, { error: 'html string required' }) } try { const p = templatePath(tplGet[1]) // Backup current version (if any) — best effort, don't fail save on backup error try { if (fs.existsSync(p)) { const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) fs.copyFileSync(p, p.replace(/\.html$/, `.bak-${ts}.html`)) } } catch (e) { log('template backup failed:', e.message) } fs.writeFileSync(p, body.html, 'utf8') log(`template ${tplGet[1]} updated by ops (${body.html.length} bytes)`) return json(res, 200, { name: tplGet[1], saved: true, size: body.html.length }) } catch (e) { return json(res, 400, { error: e.message }) } } // POST /campaigns/templates/:name/preview — render with sample data // Useful for the editor's "Preview" pane to see what {{vars}} resolve to. const tplPreview = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/preview$/) if (tplPreview && method === 'POST') { const body = await parseBody(req) const html = body.html || fs.readFileSync(templatePath(tplPreview[1]), 'utf8') const vars = { firstname: 'Louis', lastname: 'Paul', email: 'louis@targo.ca', description: '123 Rue de Test', gift_url: 'http://gtbt.co/PREVIEW', amount: '60 $', expiry: '31 décembre 2026', commitment_months: '3', ...(body.vars || {}), } return json(res, 200, { rendered: renderTemplate(html, vars) }) } // POST /campaigns/webhook — Mailjet Event API receiver // Mailjet sends an array of events; we process all of them. if (path === '/campaigns/webhook' && method === 'POST') { const body = await parseBody(req) const events = Array.isArray(body) ? body : [body] let applied = 0 for (const ev of events) { if (applyWebhookEvent(ev)) applied++ } log(`mailjet webhook: ${events.length} events, ${applied} applied`) return json(res, 200, { received: events.length, applied }) } // ── Per-campaign wildcard routes (MUST stay below the /templates and // /webhook fixed paths above, otherwise the wildcard captures them) ───── // GET /campaigns/:id — full detail const detailMatch = path.match(/^\/campaigns\/([^/]+)$/) if (detailMatch && method === 'GET') { const c = loadCampaign(detailMatch[1]) if (!c) return json(res, 404, { error: 'not found' }) return json(res, 200, c) } // PATCH /campaigns/:id — update recipients (e.g. exclude rows, edit email) if (detailMatch && method === 'PATCH') { const body = await parseBody(req) const c = loadCampaign(detailMatch[1]) if (!c) return json(res, 404, { error: 'not found' }) if (body.name) c.name = body.name if (body.params) c.params = { ...c.params, ...body.params } if (Array.isArray(body.recipients)) c.recipients = body.recipients return json(res, 200, saveCampaign(c)) } // POST /campaigns/:id/send — fire background worker const sendMatch = path.match(/^\/campaigns\/([^/]+)\/send$/) if (sendMatch && method === 'POST') { const id = sendMatch[1] const c = loadCampaign(id) if (!c) return json(res, 404, { error: 'not found' }) if (activeWorkers.has(id)) return json(res, 409, { error: 'already sending' }) // Fire and forget setImmediate(() => sendCampaignAsync(id)) return json(res, 202, { id, status: 'sending' }) } return json(res, 404, { error: 'campaigns endpoint not found' }) } module.exports = { handle, // Exposed for testing parseCsv, parseMapCsv, parseGiftbitCsv, matchCustomer, normalizeCivic, normalizePhone, normalizePostal, renderTemplate, }