'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') // MJML v5 is async — compile MJML source to email-safe HTML server-side at // save time so the send-worker only ever reads pre-compiled HTML. const mjml2html = require('mjml') // AI service (Gemini Flash) — used by the template translation endpoint to // convert email content between FR ↔ EN while preserving HTML structure. // Lazy require because lib/ai.js requires AI_API_KEY which may not be set // in dev environments — we only fail when the endpoint is actually called. let aiLib = null function getAi () { if (!aiLib) aiLib = require('./ai') return aiLib } const DATA_DIR = path.join(__dirname, '..', 'data', 'campaigns') if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) // User-uploaded image assets (logos, photos, illustrations). Self-hosted so // we're not dependent on Mailjet's CDN for new images added post-template- // creation. Existing Mailjet-hosted brand logos in gift-email-fr.html stay // where they are — this is for ADDITIONAL assets the user wants to drop in // via the GrapesJS editor. // // Naming: .. Content-addressable = automatic dedup // (same image uploaded twice = same URL, no duplicate storage) + immutable // cache (URL never changes content, safe to Cache-Control: immutable). const UPLOADS_DIR = path.join(__dirname, '..', 'uploads') if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true }) const ALLOWED_IMAGE_TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', } const MAX_UPLOAD_BYTES = 5 * 1024 * 1024 // 5 MB raw image // Decode a `data:image/png;base64,iVBOR...` URL into { mime, ext, buffer }. // Returns null on invalid input. function decodeDataUrl (dataUrl) { if (typeof dataUrl !== 'string') return null const m = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/) if (!m) return null const mime = m[1].toLowerCase() const isBase64 = !!m[2] if (!ALLOWED_IMAGE_TYPES[mime]) return null let buffer try { buffer = isBase64 ? Buffer.from(m[3], 'base64') : Buffer.from(decodeURIComponent(m[3]), 'binary') } catch { return null } if (buffer.length === 0) return null if (buffer.length > MAX_UPLOAD_BYTES) return { error: 'too_large', size: buffer.length } return { mime, ext: ALLOWED_IMAGE_TYPES[mime], buffer } } function uploadPath (filename) { // Only accept hash-named files: 64 hex chars + .ext (no path separator, // no '..', no leading dot). Anything else = rejected (path traversal). if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) { throw new Error('invalid asset filename') } return path.join(UPLOADS_DIR, filename) } // Write an uploaded buffer to disk (dedup: skip if same content already // stored). Returns the persisted filename. Module-level (not inside handle) // because `path` inside handle() is shadowed by the URL parameter. function persistUpload (buffer, ext) { const hash = crypto.createHash('sha256').update(buffer).digest('hex') const filename = `${hash}.${ext}` const target = path.join(UPLOADS_DIR, filename) if (!fs.existsSync(target)) fs.writeFileSync(target, buffer) return filename } function readUpload (filename) { if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) return null const p = path.join(UPLOADS_DIR, filename) if (!fs.existsSync(p)) return null const ext = path.extname(p).slice(1) const mime = Object.entries(ALLOWED_IMAGE_TYPES).find(([, e]) => e === ext)?.[0] || 'application/octet-stream' return { buffer: fs.readFileSync(p), mime } } function deleteUpload (filename) { if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) return false const p = path.join(UPLOADS_DIR, filename) if (!fs.existsSync(p)) return false fs.unlinkSync(p) return true } function uploadUrl (filename, req) { // Build the absolute URL the browser will use. Falls back to a relative // path if Host header is missing (shouldn't happen on prod via Traefik). const proto = (req.headers['x-forwarded-proto'] || 'https') const host = req.headers['host'] || 'msg.gigafibre.ca' return `${proto}://${host}/campaigns/assets/${filename}` } function listUploads (req) { if (!fs.existsSync(UPLOADS_DIR)) return [] return fs.readdirSync(UPLOADS_DIR) .filter(f => /^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(f)) .map(f => { const st = fs.statSync(path.join(UPLOADS_DIR, f)) return { filename: f, url: uploadUrl(f, req), size: st.size, modified: st.mtime.toISOString(), content_type: Object.entries(ALLOWED_IMAGE_TYPES).find(([, e]) => f.endsWith('.' + e))?.[0] || 'application/octet-stream', } }) .sort((a, b) => (b.modified || '').localeCompare(a.modified || '')) } // ── 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*/ // ── French Québécois first-name accent restoration ────────────────────────── // Top ~100 names from MIQ Service Canada Québec baby names + manual additions // for older generations (legacy customer base skews 40+). Lookup is on the // LOWERCASE no-accent form → returns the properly accented version. // Missing from this list: rare names, English names that don't need fixing, // already-correctly-accented names (handled by Title Case alone). const FR_NAME_FIXES = { // É (acute) 'andre': 'André', 'andree': 'Andrée', 'aimee': 'Aimée', 'amelie': 'Amélie', 'angele': 'Angèle', 'audrey': 'Audrey', 'beatrice': 'Béatrice', 'benedict': 'Bénédict', 'cecile': 'Cécile', 'cedric': 'Cédric', 'celine': 'Céline', 'chloe': 'Chloé', 'clemence': 'Clémence', 'clement': 'Clément', 'desire': 'Désiré', 'desiree': 'Désirée', 'edith': 'Édith', 'edouard': 'Édouard', 'eliane': 'Éliane', 'elie': 'Élie', 'elisa': 'Élisa', 'elise': 'Élise', 'eloi': 'Éloi', 'emile': 'Émile', 'emilie': 'Émilie', 'emmanuel': 'Emmanuel', 'eric': 'Éric', 'ethan': 'Éthan', 'eugene': 'Eugène', 'felix': 'Félix', 'fleur': 'Fleur', 'francois': 'François', 'francoise': 'Françoise', 'frederic': 'Frédéric', 'frederique': 'Frédérique', 'gabrielle': 'Gabrielle', 'gaetan': 'Gaétan', 'gaetane': 'Gaétane', 'genevieve': 'Geneviève', 'gerald': 'Gérald', 'gerard': 'Gérard', 'helena': 'Helena', 'helene': 'Hélène', 'herve': 'Hervé', 'irene': 'Irène', 'jeremie': 'Jérémie', 'jeremy': 'Jérémie', 'jerome': 'Jérôme', 'jose': 'José', 'josee': 'Josée', 'leandre': 'Léandre', 'leon': 'Léon', 'leo': 'Léo', 'leonard': 'Léonard', 'magali': 'Magali', 'medard': 'Médard', 'melanie': 'Mélanie', 'michele': 'Michèle', 'noemie': 'Noémie', 'noel': 'Noël', 'noella': 'Noëlla', 'pamela': 'Paméla', 'pierre': 'Pierre', 'raphael': 'Raphaël', 'raphaele': 'Raphaëlle', 'regina': 'Régina', 'regine': 'Régine', 'rejean': 'Réjean', 'rejeanne': 'Réjeanne', 'remi': 'Rémi', 'rene': 'René', 'renee': 'Renée', 'salome': 'Salomé', 'sebastien': 'Sébastien', 'severin': 'Séverin', 'severine': 'Séverine', 'solange': 'Solange', 'stephane': 'Stéphane', 'stephanie': 'Stéphanie', 'theo': 'Théo', 'theodore': 'Théodore', 'therese': 'Thérèse', 'valerie': 'Valérie', 'veronique': 'Véronique', 'zoe': 'Zoé', } // Parts known to form compound first names in QC (Marie-André, Jean-Philippe, // etc.). When two parts appear concatenated with no separator (e.g. "Marcandre" // or "Mariejose"), we split + hyphenate them. Ordered roughly by frequency so // the longest match wins on lookups. const COMPOUND_PARTS = [ 'jean', 'marie', 'anne', 'pierre', 'paul', 'louis', 'claude', 'marc', 'andre', 'philippe', 'francois', 'francoise', 'jose', 'josee', 'luc', 'olivier', 'antoine', 'sebastien', 'michel', 'francois', 'christian', 'henri', 'denis', 'rene', 'roger', ] function splitCompoundName (lower) { for (const a of COMPOUND_PARTS) { if (!lower.startsWith(a)) continue const rest = lower.slice(a.length) if (!rest) continue if (COMPOUND_PARTS.includes(rest)) { return [a, rest] } } return null } // Title-case a single token. Handles apostrophes (O'Brien, L'Heureux) and // hyphens (Marie-André). Returns input unchanged if empty. function titleCaseToken (tok) { if (!tok) return tok return tok.replace(/(^|[\s\-'])([\p{L}])/gu, (_, sep, ch) => sep + ch.toUpperCase()) .replace(/(?<=[\p{L}])([\p{L}]+)/gu, m => m.toLowerCase()) } // Clean a person name: trim, Title Case, accent-restore from dictionary, // split known compound concatenations. Returns the cleaned name. function cleanName (raw) { if (!raw) return '' let s = raw.trim() if (!s) return '' // First pass: Title Case (so 'MARC' → 'Marc', 'marc' → 'Marc') s = s.split(/\s+/).map(titleCaseToken).join(' ') // Apply dictionary fixes to each space-separated word s = s.split(' ').map(word => { // For hyphenated compounds, try each part separately if (word.includes('-')) { return word.split('-').map(p => { const fix = FR_NAME_FIXES[p.toLowerCase()] return fix || titleCaseToken(p) }).join('-') } const lower = word.toLowerCase() if (FR_NAME_FIXES[lower]) return FR_NAME_FIXES[lower] // Try compound split: "Marcandre" → "Marc-Andre" → "Marc-André" const parts = splitCompoundName(lower) if (parts) { return parts.map(p => FR_NAME_FIXES[p] || titleCaseToken(p)).join('-') } return word }).join(' ') return s } // Heuristics to flag names the cleaner may not have caught — surface to the // user in the preview UI so they can manually verify / edit before sending. // Returns null if the name looks fine, or a short FR string explaining why. function nameWarning (name) { if (!name) return null const trimmed = name.trim() if (!trimmed) return null if (/\d/.test(trimmed)) return 'contient un chiffre' if (trimmed.length === 1) return 'une seule lettre' if (trimmed.length > 25) return 'trop long, peut-être plusieurs noms collés' // After cleanName, common-name lookups should have hit. If we still see a // name in the unaccented form, it's likely an uncommon name OR a typo. // We can't tell which, so just inform the user. const SUSPECT_PATTERNS = [ // Common unaccented forms that the dict should have caught — if they // survive, the name is unusual and worth a glance /^(ana|ari|cleo|elo|jul|leo|maw|oce|pen|sof|the|val)/i, ] // Likely two names mashed (no separator, length 13-25, lowercase or with // unexpected capitalisation pattern like 'Marcandre') if (/^[\p{Lu}][\p{Ll}]+[\p{Ll}]{8,}$/u.test(trimmed) && !/[\s\-']/.test(trimmed)) { return 'peut-être deux prénoms collés (vérifier)' } return null } 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, skippedDuplicate = 0, skippedMultiSkip = 0 // Capture the actual rows that were dropped so the wizard can show // operators exactly WHO got lost, not just a count. Each entry includes // the reason and just enough source-CSV columns to identify the human. const skippedRows = [] const snapshot = (row, reason, idx) => ({ source_row: idx + 2, // +1 for 0-index, +1 for header line — easier to cross-reference in Excel reason, raw_full_name: (row['nom au compte'] || row["nom à l'adresse"] || '').trim(), raw_email: (row['email au compte'] || row["email à l'adresse"] || '').trim(), raw_phone: (row['telephone au compte'] || row["telephone à l'adresse"] || '').trim(), raw_address: (row['adresse dans F'] || '').trim(), raw_postal: (row['code postal au compte'] || row["code postal à l'adresse"] || '').trim(), }) for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { const r = rows[rowIdx] // Pull emails from either source column let rawEmails = (r['email au compte'] || r["email à l'adresse"] || '').trim() if (!rawEmails) { skippedNoEmail++; skippedRows.push(snapshot(r, 'no_email', rowIdx)); continue } const emails = rawEmails.split(EMAIL_SPLIT) .map(e => normalizeEmail(e)) .filter(e => e.includes('@') && e.split('@')[1]?.includes('.')) if (!emails.length) { skippedNoEmail++; skippedRows.push(snapshot(r, 'no_email', rowIdx)); continue } const sendEmails = multi === 'split' ? emails : multi === 'skip' ? (emails.length > 1 ? [] : emails) : emails.slice(0, 1) if (!sendEmails.length) { skippedMultiSkip++; skippedRows.push(snapshot(r, 'multi_skip', rowIdx)); continue } // We used to skip rows without a name here. That dropped the contact AND // wasted its paired Giftbit shortlink — the worker already defaults // firstname to "cher client" / "dear customer" when missing, so we now // keep the row and just flag it with a name warning. const full = (r['nom au compte'] || r["nom à l'adresse"] || '').trim() const hasName = !!full if (!hasName) skippedNoName++ // informational counter, NOT a continue const parts = full.split(/\s+/, 2) const firstnameRaw = parts[0] || '' const lastnameRaw = parts.length > 1 ? full.slice(parts[0].length).trim() : '' // Auto-clean: Title Case, accent restoration from QC dictionary, compound // name detection. The user sees these cleaned versions in Step 2 and can // edit inline. Original raw values are kept on the recipient (firstname_raw // + lastname_raw) so we know whether the auto-cleaner touched anything. const firstname = cleanName(firstnameRaw) const lastname = cleanName(lastnameRaw) const name_warnings = { firstname: !hasName ? 'pas de nom dans le CSV — utilisera "cher client" à l\'envoi' : nameWarning(firstname), lastname: nameWarning(lastname), } const cleaned_changed = (firstname !== firstnameRaw || lastname !== lastnameRaw) 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)) { skippedDuplicate++ // Capture the duplicate row with its specific email — useful when one // raw row had multiple emails and only some were dupes (multi='split'). skippedRows.push({ ...snapshot(r, 'duplicate', rowIdx), raw_email: em }) continue } seen.add(em) contacts.push({ firstname, lastname, firstname_raw: firstnameRaw, lastname_raw: lastnameRaw, cleaned_changed, name_warnings, email: em, phone, civic_address, postal_code, }) } } return { contacts, skipped: { no_email: skippedNoEmail, no_name: skippedNoName, // informational only — these rows are KEPT now duplicate: skippedDuplicate, multi_skip: skippedMultiSkip, total_rows: rows.length, }, // Cap at 200 to keep response size reasonable on very dirty exports; // the count in `skipped` always reflects the true total. skipped_rows: skippedRows.slice(0, 200), } } // ── Giftbit CSV parsing ────────────────────────────────────────────────────── // Two supported formats: // // 1. "Link Order" export (headerless, one URL per line): // http://gft.link/4kpZMApLK4B // http://gft.link/Dn2cb27xYJ8 // // This is what Giftbit ships when you pre-buy N gift links upfront without // targeting specific recipients. Each line = one redeemable shortlink. The // recipient mapping is done entirely on our side (Map CSV ↔ link by row). // // 2. "Campaign" export (with header row, multiple columns): // firstname,lastname,email,gift_url,giftbit_uuid,gift_value_cents,internal_id // Alice,Tremblay,alice@x.com,https://...,uuid-001,5000,ACC-1 // // This is what create_giftbit_campaign.js produces (and what Giftbit // sends when you use their Campaign API with delivery_type=SHORTLINK). function parseGiftbitCsv (text) { const cleaned = text.replace(/^/, '').trim() if (!cleaned) return [] // Detect headerless one-URL-per-line format: first non-empty line starts // with http:// or https:// and has no comma/pipe/tab separators. const firstLine = cleaned.split(/\r?\n/, 1)[0].trim() if (/^https?:\/\/\S+$/.test(firstLine) && !firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')) { return cleaned.split(/\r?\n/) .map(l => l.trim()) .filter(l => /^https?:\/\//.test(l)) .map(url => ({ gift_url: url, giftbit_uuid: '', gift_value_cents: 0, email: '', firstname: '', lastname: '', internal_id: '', })) } // Otherwise treat as CSV with header row const rows = parseCsv(text) if (!rows.length) return [] 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 } } 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 { // ERPNext's Customer.email_id can hold multiple addresses joined by // ';' or ',' (211 records as of 2026-05 — legacy migration artifact). // An exact-equality filter misses those, so we LIKE-search and then // validate locally by splitting on the common delimiters. const needle = recipient.email.toLowerCase() const r = await erp.list('Customer', { fields: ['name', 'customer_name', 'email_id', 'mobile_no', 'language'], filters: [['email_id', 'like', '%' + needle + '%']], limit: 5, }) const hit = (r || []).find(c => { const list = (c.email_id || '').toLowerCase().split(/[;,\s]+/).filter(Boolean) return list.includes(needle) }) if (hit) { return { customer_id: hit.name, customer_name: hit.customer_name, language: hit.language || 'fr', match_method: 'email', match_confidence: 1.0 } } } catch (e) { log('match email error:', e.message) } } // 2) Phone match if (recipient.phone) { try { const r = await erp.list('Customer', { fields: ['name', 'customer_name', 'mobile_no', 'language'], filters: [['mobile_no', 'like', '%' + recipient.phone.slice(-7) + '%']], limit: 5, }) const hit = (r || []).find(c => normalizePhone(c.mobile_no) === recipient.phone) if (hit) { return { customer_id: hit.name, customer_name: hit.customer_name, language: hit.language || 'fr', match_method: 'phone', match_confidence: 0.9 } } } catch (e) { log('match phone error:', e.message) } } // 3) Civic + postal on Service Location → Customer (+ fetch Customer.language separately) if (recipient.civic_address && recipient.postal_code) { try { const civic = normalizeCivic(recipient.civic_address) if (civic) { const [num, streetPrefix] = civic.split('|') // Add a street-word filter so the 10-row default limit doesn't // miss the right SL when many addresses share the same street // number in the postal code. Same approach as name+civic below. const streetWord = (streetPrefix.split(/\s+/).find(w => w.length >= 4) || streetPrefix).slice(0, 8) 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 + '%'], ['address_line', 'like', '%' + streetWord + '%'], ], limit: 50, }) // Address-shape filter — same logic as before const candidates = (r || []).filter(sl => { const slCivic = normalizeCivic(sl.address_line) return slCivic && slCivic.startsWith(num + '|') && slCivic.split('|')[1]?.startsWith(streetPrefix.slice(0, 5)) }) // When recipient has a name AND we have multiple candidates, // disambiguate by name. Falls back to the first candidate if // no name is provided or no name match found (legacy behaviour). const tgtFirst = (recipient.firstname || '').toLowerCase().trim() const tgtLast = (recipient.lastname || '').toLowerCase().trim() const looksLikeMatch = async (sl) => { if (!tgtFirst && !tgtLast) return true // no name → trust civic let custName = sl.customer_name || '' if (!custName && sl.customer) { try { const cc = await erp.list('Customer', { fields: ['customer_name'], filters: [['name', '=', sl.customer]], limit: 1, }) if (cc?.[0]) { custName = cc[0].customer_name; sl.customer_name = custName } } catch {} } const n = (custName || '').toLowerCase() if (!n) return false if (tgtFirst && tgtLast && n.includes(tgtFirst) && n.includes(tgtLast)) return true if (tgtLast.length >= 4 && n.includes(tgtLast)) return true return false } let hit = null for (const sl of candidates) { if (await looksLikeMatch(sl)) { hit = sl; break } } // If name disambiguation found nothing AND no name was provided, // accept the first civic-shape candidate (legacy single-pass behaviour). if (!hit && !tgtFirst && !tgtLast) hit = candidates[0] if (hit && hit.customer) { let language = 'fr' try { const c = await erp.list('Customer', { fields: ['language'], filters: [['name', '=', hit.customer]], limit: 1, }) if (c?.[0]?.language) language = c[0].language } catch {} return { customer_id: hit.customer, customer_name: hit.customer_name || hit.customer, language, match_method: 'civic', match_confidence: 0.85 } } } } catch (e) { log('match civic error:', e.message) } } // 4) Name + civic_address fallback — for CSV rows without postal_code, // when the Map export gave a partner's email that doesn't match ERPNext. // We scan Service Locations whose civic_address starts with the same // street number+street prefix, fetch each linked Customer's actual name, // and accept the hit only if the names plausibly match. This refuses to // link when the address is shared with a different household (e.g. // previous occupant) — better unmatched than wrongly matched. if (recipient.civic_address && (recipient.firstname || recipient.lastname)) { try { const civic = normalizeCivic(recipient.civic_address) if (civic) { const [num, streetPrefix] = civic.split('|') // Narrow the SQL search with one street-name word from the // normalized prefix (skip "des"/"de"/"la" style stopwords by // picking the first ≥ 4-char word). Postgres LIKE is case- // sensitive but Service Location address_line is typically // stored in mixed/title case, so we cast both ends to lowercase // by using the normalized prefix's lowercase output literally. // limit bumped from 20 → 100 to cover edge cases on common // streets like "des Merles" that have hundreds of addresses // starting with the same digit. const streetWord = (streetPrefix.split(/\s+/).find(w => w.length >= 4) || streetPrefix).slice(0, 8) const r = await erp.list('Service Location', { fields: ['name', 'address_line', 'postal_code', 'customer', 'customer_name'], // Two filters AND'd: street number prefix + a contains-word // narrowing. The lowercased streetWord works because Frappe // wraps LIKE with case-insensitive collation on postgres. filters: [ ['address_line', 'like', num + '%'], ['address_line', 'like', '%' + streetWord + '%'], ], limit: 100, }) // Pre-filter by civic shape before doing per-row Customer lookups // (which are network round-trips — keep their count small). const candidates = (r || []).filter(sl => { const slCivic = normalizeCivic(sl.address_line) if (!slCivic || !slCivic.startsWith(num + '|')) return false return slCivic.split('|')[1]?.startsWith(streetPrefix.slice(0, 5)) }) const tgtFirst = (recipient.firstname || '').toLowerCase().trim() const tgtLast = (recipient.lastname || '').toLowerCase().trim() const nameMatches = (custName) => { const n = (custName || '').toLowerCase() if (!n) return false if (tgtFirst && tgtLast && n.includes(tgtFirst) && n.includes(tgtLast)) return true if (tgtLast.length >= 4 && n.includes(tgtLast)) return true return false } for (const sl of candidates) { if (!sl.customer) continue // First try the SL's denormalized customer_name (cheap), fall // back to a Customer lookup if empty (common — denorm is often // missing post-import). let custName = sl.customer_name || '' let language = 'fr' if (!custName) { try { const c = await erp.list('Customer', { fields: ['customer_name', 'language'], filters: [['name', '=', sl.customer]], limit: 1, }) if (c?.[0]) { custName = c[0].customer_name; language = c[0].language || 'fr' } } catch {} } if (nameMatches(custName)) { // We don't have language yet if denorm hit — fetch it once. if (sl.customer_name) { try { const c = await erp.list('Customer', { fields: ['language'], filters: [['name', '=', sl.customer]], limit: 1, }) if (c?.[0]?.language) language = c[0].language } catch {} } return { customer_id: sl.customer, customer_name: custName, language, match_method: 'name+civic', match_confidence: 0.65 } } } } } catch (e) { log('match name+civic error:', e.message) } } // Unmatched: default to French (93% of customer base) return { customer_id: null, customer_name: null, language: 'fr', 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. // gift_clicked is the high-value engagement signal (recipient clicked the // CTA button leading to the Giftbit shortlink), tracked separately from // the generic 'clicked' status which fires on any link in the email. const c = { ...campaign } c.counters = (c.recipients || []).reduce((acc, r) => { acc[r.status] = (acc[r.status] || 0) + 1 if (r.gift_link_clicked) acc.gift_clicked++ return acc }, { total: (c.recipients || []).length, gift_clicked: 0 }) 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) }) } // Templates are bundled inside the hub at services/targo-hub/templates/. // Keep them in sync with scripts/campaigns/templates/ for the CLI use case. 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. Naming convention: // gift-email-[-variant] — the recipient's language drives which one // gets used at send time. `-simple` variants are flat-structured copies // designed to parse cleanly in GrapesJS visual editor (no nested tables). // Adding a new lang or variant = drop a new .html here + add to this list. // Templates allow-list — moved from a hardcoded array to a regex-validated // + disk-scan approach so the user can create new templates from the editor // UI without us re-deploying the hub. The prefix list bounds what names are // allowed (prevents arbitrary file writes outside the campaign domain). const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-'] const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only function isValidTemplateName (name) { if (typeof name !== 'string' || !TEMPLATE_NAME_RE.test(name)) return false return EDITABLE_TEMPLATE_PREFIXES.some(p => name.startsWith(p)) } // Scan the templates dir and return every file matching our allow-list // prefixes (.html OR .mjml — either format makes it editable). The .json // design file alone doesn't count (it's a companion). function scanEditableTemplates () { if (!fs.existsSync(TEMPLATES_DIR)) return [] const seen = new Set() for (const f of fs.readdirSync(TEMPLATES_DIR)) { const m = f.match(/^([a-z0-9-]+)\.(html|mjml)$/) if (!m) continue if (m[1].includes('.bak-') || m[1].includes('.legacy-')) continue // skip backups if (isValidTemplateName(m[1])) seen.add(m[1]) } return [...seen].sort() } // Resolve the template path for a given recipient language. Falls back to // the French version (most of the customer base) if the language-specific // file doesn't exist. Per-campaign override via params.template_path skips // language routing entirely. function templateForLanguage (lang) { const safe = (lang || 'fr').toLowerCase().split('-')[0] // 'fr-CA' → 'fr' const candidate = path.join(TEMPLATES_DIR, `gift-email-${safe}.html`) if (fs.existsSync(candidate)) return candidate return DEFAULT_TEMPLATE } // Per-campaign per-language template override. Used when the wizard lets // the operator pick a non-default variant (e.g. seasonal: gift-email-2026-summer-fr). // The override is stored on params as a bare template name (no extension, // no path) — we resolve to an absolute path here with a defensive name // regex to block path traversal. function resolveTemplatePath (p, lang) { const safe = (lang || 'fr').toLowerCase().split('-')[0] const tplName = safe === 'en' ? p?.template_en : p?.template_fr if (tplName && /^[a-z0-9-]+$/i.test(tplName)) { const candidate = path.join(TEMPLATES_DIR, tplName + '.html') if (fs.existsSync(candidate)) return candidate } // Legacy single-template campaign-wide override if (p?.template_path) return p.template_path return templateForLanguage(lang) } // ── Gift redirect wrapper ─────────────────────────────────────────────────── // We don't put the raw Giftbit shortlink in outgoing emails any more. Instead // each recipient gets a short opaque token, and the email contains // https://msg.gigafibre.ca/g/ // which 302-redirects to the underlying Giftbit URL — but ONLY if our own // expiry hasn't passed and we haven't revoked it. This gives us: // 1. End-date control independent of Giftbit's (e.g. expire after 30d // even if Giftbit's underlying gift is valid for 12 months) // 2. Reuse of unredeemed gifts: if recipient A doesn't click, we generate // a NEW token for recipient B pointing to the same Giftbit URL. A's // old wrapper URL stops working (expired/revoked) while B's new one // directs to the same Giftbit gift. // // In-memory index `tokenIndex` is the fast lookup; the source of truth lives // inside each campaign's recipient row (gift_token, gift_url, gift_expires_at, // gift_revoked, gift_redirected_count, gift_first_redirected_at). The index // is rebuilt at startup by scanning all campaign JSONs. const tokenIndex = new Map() // token → { campaign_id, row } function generateGiftToken () { // 10 base64url chars ≈ 60 bits entropy — collision-safe for our volumes // (millions of tokens × billion years before a clash). Avoids ambiguity // characters (0/O, 1/l/I) accidentally — base64url already does. return crypto.randomBytes(8).toString('base64url').slice(0, 10) } function rebuildTokenIndex () { tokenIndex.clear() let n = 0 for (const meta of listCampaigns()) { const c = loadCampaign(meta.id) if (!c?.recipients) continue for (let i = 0; i < c.recipients.length; i++) { const tok = c.recipients[i].gift_token if (tok) { tokenIndex.set(tok, { campaign_id: c.id, row: i }); n++ } } } log(`gift token index rebuilt: ${n} tokens across ${listCampaigns().length} campaigns`) } function lookupGiftToken (token) { const ref = tokenIndex.get(token) if (!ref) return null const c = loadCampaign(ref.campaign_id) if (!c?.recipients?.[ref.row]) return null return { campaign: c, row: ref.row, recipient: c.recipients[ref.row] } } // Renders the wrapper URL for a recipient. Falls back to the raw gift_url // when no token is set (backwards-compat with campaigns sent before this // feature shipped — those emails already left and contain the raw URL). function wrapperUrl (token) { return `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/g/${encodeURIComponent(token)}` } // Static HTML returned when a token is expired/revoked/unknown. Branded but // minimal so a 60s loading time from a corporate proxy doesn't reveal // internal structure. function giftExpiredPage (reason) { const messages = { expired: { fr: 'Ce cadeau a expiré.', en: 'This gift has expired.' }, revoked: { fr: 'Ce lien a été désactivé.', en: 'This link has been revoked.' }, notfound: { fr: 'Lien introuvable.', en: 'Link not found.' }, } const m = messages[reason] || messages.notfound return ` TARGO — ${m.fr}
🎁

${m.fr}

Si tu penses qu'il s'agit d'une erreur, écris-nous à support@targo.ca.

${m.en}

` } function templatePath (name) { if (!isValidTemplateName(name)) { throw new Error(`template name invalid or not allowed: ${name}`) } return path.join(TEMPLATES_DIR, name + '.html') } // Companion .mjml path for a template name. If this file exists, the template // is in MJML mode — the editor loads the .mjml source and on save we // recompile to .html. If only the .html exists, classic HTML mode. function templateMjmlPath (name) { if (!isValidTemplateName(name)) { throw new Error(`template name invalid or not allowed: ${name}`) } return path.join(TEMPLATES_DIR, name + '.mjml') } // Companion .json — visual editor's design tree (now Unlayer's design JSON, // previously was easy-email's tree — same file, different content). When // present, the editor uses it as the source of truth on load. Generated // client-side by the editor on save; we just persist it as-is. function templateJsonPath (name) { if (!isValidTemplateName(name)) { throw new Error(`template name invalid or not allowed: ${name}`) } return path.join(TEMPLATES_DIR, name + '.json') } // Return 'mjml' if the template has a .mjml companion file on disk (source // of truth = MJML, .html is the auto-compiled output). Otherwise 'html'. function templateFormat (name) { try { return fs.existsSync(templateMjmlPath(name)) ? 'mjml' : 'html' } catch { return 'html' } } function listEditableTemplates () { return scanEditableTemplates().map(name => { const format = templateFormat(name) const p = format === 'mjml' ? templateMjmlPath(name) : 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, format, 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 throttle = parseInt(p.throttle_ms || 600, 10) // Pre-load templates by language to avoid re-reading from disk on every // recipient. Cache map keyed by resolved template path. const tplCache = new Map() const getTpl = (lang) => { const tplPath = resolveTemplatePath(p, lang) if (!tplCache.has(tplPath)) { tplCache.set(tplPath, fs.readFileSync(tplPath, 'utf8')) } return tplCache.get(tplPath) } 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 lang = (r.language || 'fr').toLowerCase().split('-')[0] const tplText = getTpl(lang) // Web fallback ("View in browser") so recipients with rendering // issues (image-blocking, antique Outlook, third-party mail apps) // can open the campaign in any modern browser. The URL hits the // /recipients/:row_index/view endpoint defined further down which // re-renders the same template with this recipient's variables. const viewUrl = `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/campaigns/${encodeURIComponent(id)}/recipients/${i}/view` // Per-recipient amount override. Precedence: // 1. r.amount — explicit override typed in the manual-add dialog // 2. r.gift_value_cents → "$X" formatted (when CSV import set this) // 3. p.amount — campaign default ("60 $" etc.) // 4. "50 $" hard fallback // This matters for manual recipients where a user pastes a $50 Giftbit // link into a campaign whose params.amount defaults to "60 $". let displayAmount = p.amount || '50 $' if (r.amount) { displayAmount = r.amount } else if (r.gift_value_cents) { const cents = Number(r.gift_value_cents) || 0 // Cents are exact dollars when divisible, else 2-decimal displayAmount = cents % 100 === 0 ? `${cents / 100} $` : `${(cents / 100).toFixed(2)} $` } // Generate the wrapper token if absent (idempotent across retries). // Computed BEFORE the gift_url var so the email gets the wrapped URL. if (!r.gift_token && r.gift_url) { r.gift_token = generateGiftToken() const expiryDays = parseInt(p.gift_expiry_days || 90, 10) r.gift_expires_at = new Date(Date.now() + expiryDays * 86400 * 1000).toISOString() r.gift_revoked = false r.gift_redirected_count = 0 tokenIndex.set(r.gift_token, { campaign_id: id, row: i }) } // Format the wrapper's own expiry date for display in the email body. // Locale matches the recipient's language so "May 31, 2026" / "31 mai 2026" // appear naturally. expires_in_days is a friendlier shorter format for // tight deadlines (≤ 60 days). The two are intentionally redundant // so the template author can pick the one that fits their layout. const expiryLocale = lang === 'en' ? 'en-CA' : 'fr-CA' let expiresAtDate = '' let expiresInDays = '' if (r.gift_expires_at) { const exp = new Date(r.gift_expires_at) expiresAtDate = exp.toLocaleDateString(expiryLocale, { day: 'numeric', month: 'long', year: 'numeric' }) const days = Math.max(0, Math.ceil((exp - new Date()) / 86400000)) expiresInDays = String(days) } const vars = { firstname: r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'), lastname: r.lastname || '', email: r.email, description: r.civic_address || '', // Wrapper URL when we have a token; fall back to the raw Giftbit URL // (backwards-compat for recipients sent before this feature shipped). gift_url: r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url, amount: displayAmount, expiry: p.expiry || '', // Auto-derived from the wrapper's gift_expires_at — distinct from // the manual {{expiry}} field above which is for promotion-end dates // unrelated to the gift link wrapper. expires_at_date: expiresAtDate, expires_in_days: expiresInDays, commitment_months: p.commitment_months || '3', year: new Date().getFullYear(), view_url: viewUrl, } 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 // Auto-retry: most SMTP failures in this campaign (we observed // "Unexpected socket close" once per ~200 sends) are transient // Mailjet connection hiccups. Retry up to 2 times with backoff // before marking the recipient as failed. The retry doesn't // require any operator action and adds at most ~10s to the run. const RETRY_BACKOFF_MS = [2000, 5000] let sendRes = null let lastErrMessage = null const attempts = 1 + RETRY_BACKOFF_MS.length for (let attempt = 1; attempt <= attempts; attempt++) { try { sendRes = await email.sendEmail({ to, subject: p.subject || 'Un cadeau pour toi, de la part de TARGO', html, from: p.from || cfg.MAIL_FROM, headers: { 'X-MJ-CustomID': customId }, }) } catch (e) { sendRes = false lastErrMessage = String(e.message || e) } if (sendRes && sendRes.messageId !== undefined) break // success // Falsy return — pick up the real reason from email.js side-channel const le = email.getLastError && email.getLastError() if (le) lastErrMessage = String(le.message || le).slice(0, 500) if (attempt < attempts) { log(`campaign ${id} recipient ${i} attempt ${attempt} failed (${lastErrMessage || 'unknown'}); retry in ${RETRY_BACKOFF_MS[attempt - 1]}ms`) await new Promise(rs => setTimeout(rs, RETRY_BACKOFF_MS[attempt - 1])) } } 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 r.retry_count = attempts - 1 // 0 means first attempt succeeded } else { r.status = 'failed' r.error = lastErrMessage || 'SMTP send failed (no detail available)' r.retry_count = RETRY_BACKOFF_MS.length log(`campaign ${id} recipient ${i} failed after ${attempts} attempts:`, 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 || '') // Mailjet click events include the original URL the recipient clicked. We // distinguish "clicked the gift CTA" (the actionable engagement signal) // from "clicked the support email / footer link" (low-signal). Match the // recipient's stored gift_url first (exact), with a host-level fallback // for shortlink redirects (gft.link / giftbit.com). function isGiftClick (r, ev) { const clickedUrl = String(ev.url || ev.URL || '') if (!clickedUrl) return false // Wrapper URL match (current behaviour — emails sent after the gift // redirect rollout contain our /g/ link, not the raw Giftbit one). if (r.gift_token && clickedUrl.includes(`/g/${r.gift_token}`)) return true // Legacy emails sent before the wrapper shipped still have the raw URL. if (r.gift_url && clickedUrl.startsWith(r.gift_url)) return true return /(?:^|\/\/)(?:[\w-]+\.)?(?:gft\.link|giftbit\.com)/i.test(clickedUrl) } // 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() // Flag the high-value signal — recipient engaged with the offer. // Once true, stays true (later non-gift clicks don't unset it). if (isGiftClick(r, ev) && !r.gift_link_clicked) { r.gift_link_clicked = true r.gift_clicked_at = r.clicked_at } } 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 (isGiftClick(r, ev) && !r.gift_link_clicked) { r.gift_link_clicked = true r.gift_clicked_at = r.clicked_at } } 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) // Returns: // - recipients[]: contacts paired with a gift_url (ready to send) // - unpaired_contacts[]: contacts with no matching gift (won't be sent — // these are surfaced so the user can decide whether to acquire more // gift links and re-upload, or proceed anyway) // - unused_gifts[]: gift URLs with no matching contact (lost capacity — // surfaced so the user knows the imbalance) 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, skipped_rows } = parseMapCsv(map_csv, multi || 'first') const gifts = parseGiftbitCsv(giftbit_csv) // Match contacts ↔ gifts (by row order, fallback to email when Giftbit // echoed back our recipient email in their export) 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] const giftByEmail = gifts.find(gg => normalizeEmail(gg.email) === c.email) const gift = giftByEmail || g const match = await matchCustomer(c) recipients.push({ row_index: i + 1, ...c, gift_url: gift.gift_url, giftbit_uuid: gift.giftbit_uuid, gift_value_cents: gift.gift_value_cents, ...match, status: 'pending', excluded: false, }) } // Capture the imbalance: leftover contacts have no gift, leftover gifts // have no contact. Returning them as arrays (not just counts) lets the // UI render them so the user makes an informed decision. const unpaired_contacts = contacts.slice(n).map((c, idx) => ({ row_index: n + idx + 1, ...c, })) const unused_gifts = gifts.slice(n).map((g, idx) => ({ row_index: n + idx + 1, ...g, })) return json(res, 200, { recipients, unpaired_contacts, unused_gifts, // Kept for backward compat with any older callers leftover_gifts: unused_gifts.length, leftover_contacts: unpaired_contacts.length, skipped, skipped_rows, }) } // 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/gifts — flattened inventory of every gift across every // campaign so operators can see which Giftbit shortlinks are still // unredeemed and reassignable. Cross-campaign view; lighter than the // full campaign JSONs (no template params, no recipient PII beyond name+email). // ORDER MATTERS: this route must come BEFORE the /campaigns/:id wildcard. if (path === '/campaigns/gifts' && method === 'GET') { const now = Date.now() const rows = [] for (const meta of listCampaigns()) { const c = loadCampaign(meta.id) if (!c?.recipients) continue for (let i = 0; i < c.recipients.length; i++) { const r = c.recipients[i] if (!r.gift_url) continue const expired = r.gift_expires_at && new Date(r.gift_expires_at).getTime() < now // Status taxonomy for the inventory UI: // redeemed — recipient already clicked through to Giftbit (we // don't know if they actually redeemed; would need // the Giftbit /gifts/{uuid} poll, task #25) // revoked — manually killed by an operator // expired — our own gift_expires_at has passed; reassignable // active — still live, may be clicked any moment // pending — not yet sent (no gift_token generated yet) let status = 'pending' if (r.gift_token) { status = 'active' if (r.gift_revoked) status = 'revoked' else if (expired) status = 'expired' if (r.gift_link_clicked) status = 'redeemed' } rows.push({ campaign_id: c.id, campaign_name: c.name, row_index: i, firstname: r.firstname, lastname: r.lastname, email: r.email, gift_token: r.gift_token || null, gift_url: r.gift_url, giftbit_uuid: r.giftbit_uuid, gift_expires_at: r.gift_expires_at || null, gift_revoked: !!r.gift_revoked, gift_redirected_count: r.gift_redirected_count || 0, gift_first_redirected_at: r.gift_first_redirected_at || null, gift_link_clicked: !!r.gift_link_clicked, gift_clicked_at: r.gift_clicked_at || null, status, }) } } return json(res, 200, { gifts: rows }) } // POST /campaigns/:id/recipients/:row/revoke — kill switch for a single // wrapper token. Sets gift_revoked=true so /g/ returns the // "désactivé" page. Used when an operator wants to free a Giftbit URL // for reassignment before its natural expiry. const revokeMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/revoke$/) if (revokeMatch && method === 'POST') { const c = loadCampaign(revokeMatch[1]) if (!c) return json(res, 404, { error: 'not found' }) const i = parseInt(revokeMatch[2], 10) const r = (c.recipients || [])[i] if (!r) return json(res, 404, { error: 'recipient not found' }) if (!r.gift_token) return json(res, 400, { error: 'no token to revoke' }) r.gift_revoked = true r.gift_revoked_at = new Date().toISOString() saveCampaign(c) log(`gift token ${r.gift_token} revoked (campaign ${c.id} row ${i})`) sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r }) return json(res, 200, { revoked: true, gift_token: r.gift_token }) } // 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 template content (HTML or MJML // source depending on what's stored). The `format` field tells the editor // which mode to load (grapesjs-mjml for mjml, preset-newsletter for html). const tplGet = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)$/) if (tplGet && method === 'GET') { const name = tplGet[1] try { const format = templateFormat(name) // Always return: name, format, html (canonical, what gets sent), and // design (Unlayer/visual-editor JSON tree if present, for re-edit). // For legacy MJML templates the .mjml is also returned for reference. const html = fs.readFileSync(templatePath(name), 'utf8') let design = null try { const jsonStr = fs.readFileSync(templateJsonPath(name), 'utf8') design = JSON.parse(jsonStr) } catch {} const out = { name, format, html, design } if (format === 'mjml') { try { out.mjml = fs.readFileSync(templateMjmlPath(name), 'utf8') } catch {} } return json(res, 200, out) } catch (e) { return json(res, 404, { error: 'template not found', detail: e.message }) } } // PUT /campaigns/templates/:name — save updated template content. // Accepts EITHER: // { mjml: "..." } — compile to HTML server-side, save both // { html: "..." } — save HTML directly (legacy path) // // Keeps the previous version as .bak-. so a bad edit can // be rolled back without git access. if (tplGet && method === 'PUT') { const body = await parseBody(req) const name = tplGet[1] if (!body || (typeof body.html !== 'string' && typeof body.mjml !== 'string')) { return json(res, 400, { error: 'html OR mjml string required' }) } try { // ── MJML save path ── if (typeof body.mjml === 'string' && body.mjml) { const mjmlPath = templateMjmlPath(name) const htmlPath = templatePath(name) const jsonPath = templateJsonPath(name) // Compile MJML → HTML (async in mjml v5) const r = await mjml2html(body.mjml, { validationLevel: 'soft' }) if (r.errors?.length) { return json(res, 400, { error: 'MJML validation failed', details: r.errors.map(e => e.formattedMessage || e.message), }) } // Backup all 3 companion files before overwriting const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) try { if (fs.existsSync(mjmlPath)) fs.copyFileSync(mjmlPath, mjmlPath.replace(/\.mjml$/, `.bak-${ts}.mjml`)) if (fs.existsSync(htmlPath)) fs.copyFileSync(htmlPath, htmlPath.replace(/\.html$/, `.bak-${ts}.html`)) if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`)) } catch (e) { log('template backup failed:', e.message) } fs.writeFileSync(mjmlPath, body.mjml, 'utf8') fs.writeFileSync(htmlPath, r.html, 'utf8') // Optional editor-state snapshot — only written when client sends it // (the React editor does, but the legacy HTML editor doesn't). let json_size = 0 if (body.json) { const jsonStr = typeof body.json === 'string' ? body.json : JSON.stringify(body.json) fs.writeFileSync(jsonPath, jsonStr, 'utf8') json_size = jsonStr.length } log(`template ${name} updated (mjml: ${body.mjml.length}b → html: ${r.html.length}b${json_size ? ', json: ' + json_size + 'b' : ''})`) return json(res, 200, { name, format: 'mjml', saved: true, mjml_size: body.mjml.length, html_size: r.html.length, json_size, }) } // ── HTML save path (now the PRIMARY flow — Unlayer outputs HTML directly) ── const p = templatePath(name) const jsonPath = templateJsonPath(name) try { const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) if (fs.existsSync(p)) fs.copyFileSync(p, p.replace(/\.html$/, `.bak-${ts}.html`)) if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`)) } catch (e) { log('template backup failed:', e.message) } fs.writeFileSync(p, body.html, 'utf8') // Persist editor design JSON alongside (Unlayer's loadDesign() input) let design_size = 0 if (body.design) { const designStr = typeof body.design === 'string' ? body.design : JSON.stringify(body.design) fs.writeFileSync(jsonPath, designStr, 'utf8') design_size = designStr.length } log(`template ${name} updated (html: ${body.html.length}b${design_size ? ', design: ' + design_size + 'b' : ''})`) return json(res, 200, { name, format: 'html', saved: true, size: body.html.length, design_size, }) } catch (e) { return json(res, 400, { error: e.message }) } } // POST /campaigns/templates/:name/translate-to/:targetName — translate the // source template's HTML content via Gemini Flash and save as targetName. // Preserves HTML structure, Mustache {{vars}}, URLs, brand names, emojis. // Body (optional): { override: true } to overwrite an existing target. const tplTranslate = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/translate-to\/([a-zA-Z0-9_-]+)$/) if (tplTranslate && method === 'POST') { const srcName = tplTranslate[1] const targetName = tplTranslate[2] if (!isValidTemplateName(srcName) || !isValidTemplateName(targetName)) { return json(res, 400, { error: 'invalid source or target template name' }) } if (srcName === targetName) { return json(res, 400, { error: 'source and target must differ' }) } // Detect direction from name suffix (-fr → translate to en, -en → fr) // Falls back to the explicit target language inferred from target name. const srcLang = srcName.match(/-([a-z]{2})(-|$)/)?.[1] || 'fr' const tgtLang = targetName.match(/-([a-z]{2})(-|$)/)?.[1] || (srcLang === 'fr' ? 'en' : 'fr') let srcHtml try { srcHtml = fs.readFileSync(templatePath(srcName), 'utf8') } catch { return json(res, 404, { error: 'source template not found' }) } const body = await parseBody(req).catch(() => ({})) const targetHtmlPath = templatePath(targetName) if (fs.existsSync(targetHtmlPath) && !body?.override) { return json(res, 409, { error: 'target template already exists', hint: 'POST again with { "override": true } to overwrite', }) } const langNames = { fr: 'French Canadian (Quebec)', en: 'English (North American)' } // System prompt: emphasize MEANING + TONE + IDIOMATIC REPHRASING over // literal word-by-word translation. The previous version produced robotic // output because it forced byte-for-byte preservation, which suppressed // Gemini's natural rephrasing capability. New approach: rephrase freely // within the constraint of preserving HTML structure + technical content. const systemPrompt = [ `You are a senior MARKETING COPYWRITER, not a literal translator. You`, `craft email copy for TARGO, a regional Quebec-based fiber Internet ISP`, `that prides itself on local presence, warmth, and trust. You're`, `localizing email content between Quebec French and North American English.`, ``, `══ YOUR MISSION ══`, `Recreate the MEANING and EMOTIONAL IMPACT of the source in the target`, `language. You are NOT translating words — you are rewriting marketing`, `copy that lands the same way for a different audience. Feel free to:`, ` • Rephrase sentences entirely`, ` • Use idioms native to the target audience`, ` • Shorten or split sentences for clarity`, ` • Change metaphors if the original doesn't translate culturally`, ` • Switch passive↔active voice freely`, ``, `══ TONE (HARD CONSTRAINT) ══`, `Warm, conversational, slightly playful. Like a neighbor explaining`, `something — never corporate, never stiff. Informal "tu" in French and`, `informal "you" in English. Use contractions ("we're", "you'll", "on est").`, ``, `══ STRUCTURAL PRESERVATION (HARD CONSTRAINT) ══`, `Keep IDENTICAL byte-for-byte:`, ` 1. ALL HTML tags, attributes, classes, inline styles, Outlook MSO`, ` conditional comments`, ` 2. Mustache placeholders: {{firstname}}, {{amount}}, {{gift_url}},`, ` {{commitment_months}}, {{description}}, {{expiry}}, {{year}}`, ` 3. URLs, email addresses, phone numbers, hex colors, CSS, font names`, ` 4. Brand names: TARGO, Gigafibre, Giftbit, Mailjet, Amazon, IGA,`, ` Tim Hortons, Pizza Pizza, Home Depot, Best Buy, Walmart,`, ` Petro-Canada, Esso, Home Hardware, Sobeys`, ` 5. ALL emojis: 🎁 ⚡ 🤝 🪂 ✅ ⏭️ ⏰`, ` 6. Technical values like "3.5 Gbit/s", "7j/7" → "7 days/week"`, ``, `══ FEW-SHOT EXAMPLES (THIS IS THE STYLE I WANT) ══`, ``, `FR: "On veut te remercier pour ta loyauté envers l'achat local."`, `EN: "Thanks for keeping it local — it means more than you think."`, ` (NOT: "We want to thank you for your loyalty to local shopping.")`, ``, `FR: "Comme toi, on aime les connexions stables et les relations durables."`, `EN: "Like you, we believe in steady connections — both the fiber kind and the human kind."`, ` (NOT: "Like you, we love stable connections and lasting relationships.")`, ``, `FR: "On est juste à côté et on aime aider."`, `EN: "We're right next door — and we genuinely love lending a hand."`, ` (NOT: "We are next to you and we love to help.")`, ``, `FR: "Avec l'arrivée de l'été, voici un cadeau pour toi, disponible pour un temps limité."`, `EN: "Summer's here, and we've got something for you — but only for a little while."`, ``, `══ OUTPUT FORMAT ══`, `Output the COMPLETE translated HTML from to .`, `NO commentary, NO markdown fences, NO preamble. Just the HTML.`, ].join('\n') const userPrompt = `Localize this ${langNames[srcLang]} marketing email into ${langNames[tgtLang]}. ` + `Apply the COPYWRITER mindset from the system prompt — rephrase for natural flow, ` + `keep the warm tone. Preserve every HTML tag and Mustache placeholder exactly.\n\n${srcHtml}` let translated try { translated = await getAi().aiCall(systemPrompt, userPrompt, { jsonMode: false, maxTokens: 32768, temperature: 0.7, // bumped from 0.2 — let the AI rephrase creatively }) } catch (e) { return json(res, 502, { error: 'AI translation failed', detail: e.message }) } // Sanity checks — refuse to save garbled output if (typeof translated !== 'string' || translated.length < srcHtml.length * 0.5) { return json(res, 502, { error: 'AI returned truncated or invalid output', src_bytes: srcHtml.length, out_bytes: typeof translated === 'string' ? translated.length : 0, }) } // Strip optional markdown code fences if Gemini still added them despite the prompt translated = translated.replace(/^```(?:html)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim() // Backup existing target before overwriting try { if (fs.existsSync(targetHtmlPath)) { const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) fs.copyFileSync(targetHtmlPath, targetHtmlPath.replace(/\.html$/, `.bak-${ts}.html`)) const jsonPath = templateJsonPath(targetName) if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`)) } } catch (e) { log('translate backup failed:', e.message) } // Write translated HTML fs.writeFileSync(targetHtmlPath, translated, 'utf8') // Regenerate the Unlayer design JSON via the converter so the editor // can reload the translated template visually. Inline equivalent of // running scripts/convert-html-to-unlayer.js for this target. const bodyMatch = translated.match(/]*>([\s\S]*?)<\/body>/i) const innerHtml = bodyMatch ? bodyMatch[1].trim() : translated const preheaderMatch = innerHtml.match(/]*display:\s*none[^>]*>\s*([^<]+?)\s*<\/div>/i) const preheader = preheaderMatch ? preheaderMatch[1].trim() : '' const design = { counters: { u_row: 1, u_column: 1, u_content_html: 1 }, body: { id: 'BODY-1', rows: [{ id: 'ROW-1', cells: [1], columns: [{ id: 'COL-1', contents: [{ id: 'HTML-1', type: 'html', values: { html: innerHtml, hideDesktop: false, displayCondition: null, containerPadding: '0px', _meta: { htmlID: 'u_content_html_1', htmlClassNames: 'u_content_html' }, selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true, }, }], values: { _meta: { htmlID: 'u_column_1', htmlClassNames: 'u_column' } }, }], values: { displayCondition: null, columns: false, backgroundColor: '', padding: '0px', _meta: { htmlID: 'u_row_1', htmlClassNames: 'u_row' }, selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true, }, }], values: { contentWidth: '600px', fontFamily: { label: 'Plus Jakarta Sans', value: "'Plus Jakarta Sans', sans-serif" }, textColor: '#1B2E24', backgroundColor: '#F5FAF7', preheaderText: preheader, linkStyle: { body: true, linkColor: '#00C853', linkUnderline: true }, _meta: { htmlID: 'u_body', htmlClassNames: 'u_body' }, }, }, schemaVersion: 12, } fs.writeFileSync(templateJsonPath(targetName), JSON.stringify(design, null, 2), 'utf8') log(`translate: ${srcName} (${srcHtml.length}b ${srcLang}) → ${targetName} (${translated.length}b ${tgtLang})`) return json(res, 200, { source: srcName, target: targetName, from_lang: srcLang, to_lang: tgtLang, src_bytes: srcHtml.length, out_bytes: translated.length, saved: true, }) } // POST /campaigns/templates/:name/test-send — send ONE rendered email to a // specific address for visual QA. Uses the campaign Mailjet sender + // throttle. NOT linked to any campaign — purely for template testing. // Body: { to: 'louis@targo.ca', vars: {...}, from?: 'TARGO ' } const tplTestSend = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/test-send$/) if (tplTestSend && method === 'POST') { const body = await parseBody(req) const to = (body.to || '').trim() if (!to || !to.includes('@')) { return json(res, 400, { error: 'to (recipient email) required' }) } const name = tplTestSend[1] let html try { // Always read the COMPILED .html — what the recipient actually receives. // MJML source is for the editor; the send-worker reads compiled HTML. html = fs.readFileSync(templatePath(name), 'utf8') } catch (e) { return json(res, 404, { error: 'template not found', detail: e.message }) } // Sample wrapper expiry — 30 days out, locale-formatted FR. This is // critical for the reminder template which uses {{expires_at_date}} // as its main urgency line; without it the test email shows an // empty space where the date should be. const sampleExpAt = new Date(Date.now() + 30 * 86400 * 1000) .toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' }) const vars = { firstname: 'Louis', lastname: 'Test', email: to, description: '123 Rue de Test, Ste-Clotilde', gift_url: 'https://gft.link/TEST123', amount: '60 $', expiry: '31 décembre 2026', expires_at_date: sampleExpAt, expires_in_days: '30', commitment_months: '3', year: new Date().getFullYear(), // view_url left empty so the {{#view_url}} section collapses — // test emails go to internal addresses and don't need the web // fallback link. view_url: '', ...(body.vars || {}), } const rendered = renderTemplate(html, vars) const fromAddr = body.from || cfg.MAIL_FROM || 'TARGO ' try { const info = await email.sendEmail({ to, from: fromAddr, subject: body.subject || `[TEST] Template ${name}`, html: rendered, headers: { 'X-MJ-CustomID': `test-send:${name}:${Date.now()}` }, }) if (!info) return json(res, 500, { error: 'SMTP send returned false (see hub logs)' }) log(`test-send: ${name} → ${to} (${rendered.length} bytes)`) return json(res, 200, { sent: true, to, from: fromAddr, message_id: info.messageId || null, bytes: rendered.length, }) } catch (e) { return json(res, 500, { 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 sampleExpAt = new Date(Date.now() + 30 * 86400 * 1000) .toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric' }) 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', expires_at_date: sampleExpAt, expires_in_days: '30', view_url: '', ...(body.vars || {}), } return json(res, 200, { rendered: renderTemplate(html, vars) }) } // ── Image asset upload (self-hosted, for new images added via the editor) ── // Existing Mailjet-hosted brand logos in the templates are NOT affected — // those stay on xqy3m.mjt.lu/img2/... This is for additional images the // user uploads through the GrapesJS asset manager. // POST /campaigns/assets/upload — accepts JSON { name, data } where data // is a data:image/...;base64,... URL. Returns { url, filename, size }. // Hash-based filename = automatic dedup + immutable URLs. if (path === '/campaigns/assets/upload' && method === 'POST') { const body = await parseBody(req) if (!body || !body.data) return json(res, 400, { error: 'data field required (data URL)' }) const decoded = decodeDataUrl(body.data) if (!decoded) return json(res, 415, { error: 'unsupported or invalid image data URL' }) if (decoded.error === 'too_large') { return json(res, 413, { error: `image too large (${decoded.size}b, max ${MAX_UPLOAD_BYTES}b)` }) } const filename = persistUpload(decoded.buffer, decoded.ext) log(`asset upload: ${filename} (${decoded.buffer.length}b, name: ${body.name || 'n/a'})`) const url = uploadUrl(filename, req) return json(res, 200, { filename, url, size: decoded.buffer.length, content_type: decoded.mime, // GrapesJS asset manager expects this shape on upload success data: [{ src: url, type: 'image' }], }) } // GET /campaigns/assets — list all uploaded assets with metadata if (path === '/campaigns/assets' && method === 'GET') { return json(res, 200, { assets: listUploads(req) }) } // GET /campaigns/assets/:filename — serve image bytes const assetGet = path.match(/^\/campaigns\/assets\/([a-f0-9]{64}\.(?:png|jpg|gif|webp|svg))$/) if (assetGet && method === 'GET') { const r = readUpload(assetGet[1]) if (!r) return json(res, 404, { error: 'asset not found' }) res.writeHead(200, { 'Content-Type': r.mime, 'Content-Length': r.buffer.length, // Immutable cache: filename is content-hash, the bytes for a given URL // never change. 1y cache aligns with how email image proxies work. 'Cache-Control': 'public, max-age=31536000, immutable', 'Access-Control-Allow-Origin': '*', }) return res.end(r.buffer) } // DELETE /campaigns/assets/:filename — remove from disk if (assetGet && method === 'DELETE') { if (deleteUpload(assetGet[1])) { log(`asset deleted: ${assetGet[1]}`) return json(res, 200, { deleted: assetGet[1] }) } return json(res, 404, { error: 'asset not found' }) } // 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/recipients/:i/view — re-render the email for ONE // recipient using the same variables the worker used at send time. Linked // from the "Voir dans le navigateur / View in browser" line at the top of // every campaign email so recipients with rendering issues (image-blocking // clients, antique Outlook, niche third-party mail apps) can fall back to // a fresh browser render. No auth needed — the campaign-id is 21-char // nanoid (≈10²¹ space) and row_index alone is enough to reconcile. const viewMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/view$/) if (viewMatch && method === 'GET') { const c = loadCampaign(viewMatch[1]) if (!c) return json(res, 404, { error: 'not found' }) const i = parseInt(viewMatch[2], 10) const r = (c.recipients || [])[i] if (!r) return json(res, 404, { error: 'recipient not found' }) const p = c.params || {} const lang = (r.language || 'fr').toLowerCase().split('-')[0] const tplPath = resolveTemplatePath(p, lang) let tplText try { tplText = fs.readFileSync(tplPath, 'utf8') } catch { return json(res, 500, { error: 'template missing' }) } let displayAmount = p.amount || '50 $' if (r.amount) { displayAmount = r.amount } else if (r.gift_value_cents) { const cents = Number(r.gift_value_cents) || 0 displayAmount = cents % 100 === 0 ? `${cents / 100} $` : `${(cents / 100).toFixed(2)} $` } // Locale-formatted wrapper expiry — same logic as the worker so the // /view fallback renders the exact same date the recipient saw at first. const expiryLocale = lang === 'en' ? 'en-CA' : 'fr-CA' let expiresAtDate = '' let expiresInDays = '' if (r.gift_expires_at) { const exp = new Date(r.gift_expires_at) expiresAtDate = exp.toLocaleDateString(expiryLocale, { day: 'numeric', month: 'long', year: 'numeric' }) const days = Math.max(0, Math.ceil((exp - new Date()) / 86400000)) expiresInDays = String(days) } const vars = { firstname: r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'), lastname: r.lastname || '', email: r.email, description: r.civic_address || '', // Same wrapper logic as the worker so the in-browser fallback view // links to the same URL the email did. Clicking from /view goes // through our redirect, lets us honour expiry/revoke consistently. gift_url: r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url, amount: displayAmount, expiry: p.expiry || '', expires_at_date: expiresAtDate, expires_in_days: expiresInDays, commitment_months: p.commitment_months || '3', year: new Date().getFullYear(), // Empty so the {{#view_url}} section block in the template collapses // — we don't want the "view in browser" line to show up when the // user is ALREADY viewing it in a browser. view_url: '', } const html = renderTemplate(tplText, vars) res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', // Disallow indexing — these URLs aren't meant for search engines 'X-Robots-Tag': 'noindex, nofollow', }) return res.end(html) } // GET /campaigns/:id/report.csv — per-recipient report download // Columns chosen for operational follow-up (resend, refund, support): // row, firstname, lastname, email, phone, language, customer_id, civic_address, // postal_code, gift_value_cents, gift_url, giftbit_uuid, status, excluded, // sent_at, opened_at, clicked_at, mailjet_uuid, error // RFC 4180: comma-separated, CRLF endings, fields with comma/quote/newline get // wrapped in double quotes with embedded " doubled up. We prepend a UTF-8 BOM // so Excel auto-detects encoding and renders accents correctly. const reportMatch = path.match(/^\/campaigns\/([^/]+)\/report\.csv$/) if (reportMatch && method === 'GET') { const c = loadCampaign(reportMatch[1]) if (!c) return json(res, 404, { error: 'not found' }) const headers = [ 'row', 'firstname', 'lastname', 'email', 'phone', 'language', 'customer_id', 'civic_address', 'city', 'postal_code', 'gift_value_cents', 'gift_url', 'giftbit_uuid', 'giftbit_admin_url', 'gift_token', 'gift_expires_at', 'gift_revoked', 'gift_redirected_count', 'gift_first_redirected_at', 'status', 'excluded', 'sent_at', 'opened_at', 'clicked_at', 'gift_link_clicked', 'gift_clicked_at', 'mailjet_uuid', 'error', ] const esc = (v) => { if (v == null) return '' const s = String(v) return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s } const lines = [headers.join(',')] for (const r of (c.recipients || [])) { // Build Giftbit admin search URL for manual redemption check // (until /gifts/{uuid} API polling lands — see task #25). let giftbitAdminUrl = '' if (r.gift_url) { const code = String(r.gift_url).replace(/\/+$/, '').split('/').pop() if (code && code.length >= 4) { giftbitAdminUrl = `https://app.giftbit.com/app/rewards?search=${encodeURIComponent(code)}` } } lines.push([ r.row_index, r.firstname, r.lastname, r.email, r.phone, r.language, r.customer_id, r.civic_address, r.city, r.postal_code, r.gift_value_cents, r.gift_url, r.giftbit_uuid, giftbitAdminUrl, r.gift_token, r.gift_expires_at, r.gift_revoked ? 'true' : 'false', r.gift_redirected_count || 0, r.gift_first_redirected_at, r.status, r.excluded ? 'true' : 'false', r.sent_at, r.opened_at, r.clicked_at, r.gift_link_clicked ? 'true' : 'false', r.gift_clicked_at, r.mailjet_uuid, r.error, ].map(esc).join(',')) } const csv = '' + lines.join('\r\n') + '\r\n' const safeName = (c.name || c.id).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) res.writeHead(200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="campaign-${safeName}.csv"`, 'Cache-Control': 'no-store', }) return res.end(csv) } // 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' }) } // POST /campaigns/:id/reminder — clone the campaign for the non-clickers. // Creates a brand-new draft campaign rooted at the same Giftbit shortlinks // but with the reminder templates (gift-email-reminder-*) and an urgency // subject. Fresh gift_tokens get generated when the operator launches the // send so each campaign owns its own click-tracking metrics. const reminderMatch = path.match(/^\/campaigns\/([^/]+)\/reminder$/) if (reminderMatch && method === 'POST') { const parent = loadCampaign(reminderMatch[1]) if (!parent) return json(res, 404, { error: 'not found' }) const now = Date.now() // Filter rules: only people who actually got the email (status sent / // opened — NOT clicked), aren't excluded, aren't expired or revoked // on the wrapper side, and still have a gift_url to redirect to. const candidates = (parent.recipients || []).filter(r => { if (r.excluded) return false if (r.gift_link_clicked) return false // already engaged if (r.gift_revoked) return false if (r.gift_expires_at && new Date(r.gift_expires_at).getTime() < now) return false if (!r.gift_url) return false // nothing to redirect to // Only worth reminding people we actually sent to (skip pending / // failed permanently). Opened counts as sent + we want to remind. return ['sent', 'opened'].includes(r.status) }) if (!candidates.length) { return json(res, 400, { error: 'no non-clicked recipients eligible for a reminder' }) } // Build the reminder recipients. Fresh tokens & status (worker will // generate gift_token at send time). Keep firstname/lastname/email/ // gift_url/language/amount-override so we hit the same person at the // same gift, just via a new wrapper URL with new expiry. const reminderRecipients = candidates.map((r, i) => ({ row_index: i + 1, firstname: r.firstname, lastname: r.lastname, email: r.email, phone: r.phone || '', civic_address: r.civic_address || '', city: r.city || '', postal_code: r.postal_code || '', language: r.language || 'fr', gift_url: r.gift_url, // same Giftbit shortlink giftbit_uuid: r.giftbit_uuid || null, gift_value_cents: r.gift_value_cents || null, amount: r.amount || null, customer_id: r.customer_id || null, customer_name: r.customer_name || null, match_method: r.match_method || null, // Tracing links back to the parent (which recipient row triggered // this reminder) — handy for the CSV report and debugging. parent_campaign_id: parent.id, parent_row_index: parent.recipients.indexOf(r), status: 'pending', excluded: false, })) const parentParams = parent.params || {} const reminderId = newCampaignId() + '-rem' const reminderCampaign = { id: reminderId, name: (parent.name || 'Campagne') + ' — Relance', created_at: new Date().toISOString(), status: 'draft', parent_campaign_id: parent.id, reminder_of: parent.id, // for the UI "this is a reminder of X" hint params: { ...parentParams, subject: parentParams.subject_reminder || '⏰ Dernière chance — ton cadeau TARGO expire bientôt', template_fr: 'gift-email-reminder-fr', template_en: 'gift-email-reminder-en', }, recipients: reminderRecipients, } const saved = saveCampaign(reminderCampaign) log(`reminder campaign created: ${reminderId} (${candidates.length} recipients from ${parent.id})`) return json(res, 201, saved) } // POST /campaigns/:id/recipients/:row/retry — reset a single failed // recipient back to "pending" and re-fire the worker so it picks the // row up on the next pass. Used by the "Renvoyer" button in the UI // for one-off transient failures that didn't recover via auto-retry. const retryMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/retry$/) if (retryMatch && method === 'POST') { const id = retryMatch[1] const c = loadCampaign(id) if (!c) return json(res, 404, { error: 'not found' }) const i = parseInt(retryMatch[2], 10) const r = (c.recipients || [])[i] if (!r) return json(res, 404, { error: 'recipient not found' }) if (r.status !== 'failed') return json(res, 400, { error: `recipient status is "${r.status}", only "failed" can be retried` }) if (activeWorkers.has(id)) return json(res, 409, { error: 'campaign worker already running' }) r.status = 'pending' r.error = null // Clear the previous retry counter so the new attempt gets its own 3 // retries inside the worker. Keep mailjet_uuid in case it WAS partially // accepted by Mailjet — we'll overwrite on a successful resend. r.retry_count = 0 // Also force the global campaign status back to 'sending' so the UI // counter strip refreshes. if (c.status === 'completed' || c.status === 'failed') c.status = 'sending' saveCampaign(c) sse.broadcast(`campaign:${id}`, 'recipient-update', { i, recipient: r }) setImmediate(() => sendCampaignAsync(id)) return json(res, 202, { id, row: i, status: 'pending' }) } // DELETE /campaigns/:id — remove the campaign JSON from disk. Mostly for // cleaning up test/draft runs; the gifts themselves live on Giftbit and // are unaffected. Refuses to delete while the send worker is active for // this id (would crash the worker on the next saveCampaign). if (detailMatch && method === 'DELETE') { const id = detailMatch[1] if (activeWorkers.has(id)) { return json(res, 409, { error: 'cannot delete while campaign is sending — wait for it to finish or fail' }) } let filePath try { filePath = campaignPath(id) } catch { return json(res, 400, { error: 'invalid campaign id' }) } if (!fs.existsSync(filePath)) return json(res, 404, { error: 'not found' }) try { fs.unlinkSync(filePath) log(`campaign ${id} deleted`) sse.broadcast(`campaign:${id}`, 'campaign-deleted', { id }) return json(res, 200, { id, deleted: true }) } catch (e) { log(`campaign ${id} delete failed:`, e.message) return json(res, 500, { error: 'delete failed: ' + e.message }) } } return json(res, 404, { error: 'campaigns endpoint not found' }) } // ── Gift redirect handler ──────────────────────────────────────────────────── // Public unauthenticated endpoint hit by recipients' email clicks. // /g/:token → 302 to underlying Giftbit URL, OR branded expired page. async function handleGiftRedirect (req, res, urlPath) { const m = urlPath.match(/^\/g\/([A-Za-z0-9_-]{4,32})$/) const respondExpired = (reason) => { res.writeHead(reason === 'notfound' ? 404 : 410, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow', }) res.end(giftExpiredPage(reason)) } if (!m) return respondExpired('notfound') const token = m[1] const hit = lookupGiftToken(token) if (!hit) return respondExpired('notfound') const r = hit.recipient if (r.gift_revoked) return respondExpired('revoked') if (r.gift_expires_at && new Date(r.gift_expires_at) < new Date()) return respondExpired('expired') if (!r.gift_url) return respondExpired('notfound') // Successful redirect — record analytics, persist async (no await; the // recipient is already on their way to Giftbit's page). r.gift_redirected_count = (r.gift_redirected_count || 0) + 1 if (!r.gift_first_redirected_at) r.gift_first_redirected_at = new Date().toISOString() try { saveCampaign(hit.campaign) } catch (e) { log(`gift redirect save failed: ${e.message}`) } // Broadcast so the live campaign detail page updates the click counter sse.broadcast(`campaign:${hit.campaign.id}`, 'recipient-update', { i: hit.row, recipient: r }) res.writeHead(302, { Location: r.gift_url, 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow', }) res.end() } // Rebuild the token index from disk on module load so a hub restart // doesn't break existing wrapper URLs in already-sent emails. rebuildTokenIndex() module.exports = { handle, handleGiftRedirect, // Exposed for testing parseCsv, parseMapCsv, parseGiftbitCsv, matchCustomer, normalizeCivic, normalizePhone, normalizePostal, renderTemplate, }