New "Traduire (AI)" button in the template editor toolbar. One click
translates the current template's HTML to the opposite language
(detected from the -fr/-en suffix), writing the translated content as
the matching companion template.
Backend (lib/campaigns.js):
- New endpoint: POST /campaigns/templates/:name/translate-to/:targetName
- Reads source .html, calls lib/ai.js aiCall() with Gemini Flash
- System prompt enforces 7 strict preservation rules:
1. Byte-preserve all HTML tags/attributes/styles/Outlook conditionals
2. Don't translate Mustache {{vars}}
3. Preserve URLs/emails/phones/hex colors/CSS/brand names (TARGO,
Gigafibre, Giftbit, Amazon, IGA, Tim Hortons, etc.)
4. Preserve emojis (🎁 ⚡ 🤝 🪂 ✅ ⏭️ ⏰)
5. Keep the warm informal tone (tu in FR, you in EN)
6. Translate only visible text inside elements (paragraphs, buttons,
alt attributes, link text)
7. Output full HTML doc only, no markdown wrapping
- temperature=0.2 for stable output, maxTokens=32768 to fit ~35 KB HTML
- Sanity validates output isn't truncated (>50% of source size)
- Strips defensive markdown fences if AI ignored rule 7
- Auto-backs up existing target before overwrite
- Regenerates Unlayer design JSON from the translated HTML so the
editor can reload the translated template visually
- Requires { override: true } in body to overwrite existing target
(409 Conflict otherwise — protects against accidental clobber)
API client (apps/ops/src/api/campaigns.js):
- translateTemplate(srcName, targetName, { override })
Frontend (TemplateEditorPage.vue):
- "Traduire (AI)" button (purple, icon=translate) in toolbar — disabled
when current template has no -fr/-en suffix
- aiTranslateTargetName computed: detects source lang from suffix,
flips to opposite (-fr → -en, -en → -fr)
- Confirmation dialog:
• Shows source → target template names
• Info banner explaining what's preserved (HTML, vars, brands, emojis)
• Amber banner + toggle if target exists (must confirm override)
- On success: positive notification with byte counts +
"Open" action button to jump to the translated template
- Refreshes templates list after translation so the new file appears
in the selector dropdown
UX: replaces the previous manual translation workflow (where the user
or I had to maintain two parallel templates). One click now does the
whole round-trip. User reviews + adjusts wording in the EN editor if
the AI translation needs polish.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1495 lines
64 KiB
JavaScript
1495 lines
64 KiB
JavaScript
'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/<campaign-id>.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:<id>' — 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: <sha256-of-content>.<ext>. 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
|
||
|
||
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 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: 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)) 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, total_rows: rows.length } }
|
||
}
|
||
|
||
// ── 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 {
|
||
const r = await erp.list('Customer', {
|
||
fields: ['name', 'customer_name', 'email_id', 'mobile_no', 'language'],
|
||
filters: [['email_id', '=', recipient.email]],
|
||
limit: 1,
|
||
})
|
||
if (r && r.length) {
|
||
return { customer_id: r[0].name, customer_name: r[0].customer_name,
|
||
language: r[0].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('|')
|
||
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) {
|
||
// Service Location doesn't carry language — fetch it from the linked 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.8 }
|
||
}
|
||
}
|
||
} catch (e) { log('match 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
|
||
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)
|
||
})
|
||
}
|
||
|
||
// 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-<lang>[-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
|
||
}
|
||
|
||
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 = p.template_path || templateForLanguage(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)
|
||
const vars = {
|
||
firstname: r.firstname || (lang === 'en' ? 'dear customer' : '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',
|
||
year: new Date().getFullYear(),
|
||
}
|
||
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": "<our reference>",
|
||
// "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 = "<campaign-id>:<recipient-index>" 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)
|
||
// 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 } = 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,
|
||
})
|
||
}
|
||
|
||
// 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 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: "<mjml>...</mjml>" } — compile to HTML server-side, save both
|
||
// { html: "<html>..." } — save HTML directly (legacy path)
|
||
//
|
||
// Keeps the previous version as <name>.bak-<ts>.<ext> 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 (français du Québec)', en: 'English' }
|
||
const systemPrompt = [
|
||
`You are a professional bilingual translator for TARGO, a Quebec-based`,
|
||
`fiber Internet ISP. You translate marketing email content between`,
|
||
`French Canadian and English while strictly preserving HTML structure.`,
|
||
``,
|
||
`STRICT RULES — DO NOT VIOLATE:`,
|
||
`1. PRESERVE every HTML tag, attribute, class, inline style, and Outlook`,
|
||
` conditional comment (<!--[if mso]><![endif]-->) byte-for-byte.`,
|
||
`2. PRESERVE Mustache variables: {{firstname}}, {{amount}}, {{gift_url}},`,
|
||
` {{commitment_months}}, {{description}}, {{expiry}}, {{year}}, etc.`,
|
||
` DO NOT translate the content inside {{ }}.`,
|
||
`3. PRESERVE URLs (href, src), email addresses, phone numbers, hex colors,`,
|
||
` CSS values, font names, brand names (TARGO, Gigafibre, Giftbit, Mailjet,`,
|
||
` Amazon, IGA, Tim Hortons, etc.).`,
|
||
`4. PRESERVE all emojis as-is (🎁 ⚡ 🤝 🪂 ✅ ⏭️ ⏰).`,
|
||
`5. KEEP the warm conversational tone — use "tu" (informal) in French and`,
|
||
` "you" (informal) in English. Marketing-friendly, not corporate.`,
|
||
`6. TRANSLATE only the visible text content inside elements: paragraphs,`,
|
||
` headings, button labels, link text, alt attributes.`,
|
||
`7. Output the COMPLETE translated HTML document (starting from <!doctype`,
|
||
` html> or <html> to the closing </html>). NO explanation, NO markdown`,
|
||
` code fence, NO commentary — just the HTML.`,
|
||
].join('\n')
|
||
|
||
const userPrompt = `Translate the following ${langNames[srcLang]} email HTML to ${langNames[tgtLang]}. ` +
|
||
`Apply the rules from the system prompt strictly.\n\n${srcHtml}`
|
||
|
||
let translated
|
||
try {
|
||
// Non-JSON mode (we want raw HTML back), high token limit (~35 KB output)
|
||
translated = await getAi().aiCall(systemPrompt, userPrompt, {
|
||
jsonMode: false,
|
||
maxTokens: 32768,
|
||
temperature: 0.2, // low temp = stable, less creative translation
|
||
})
|
||
} 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(/<body[^>]*>([\s\S]*?)<\/body>/i)
|
||
const innerHtml = bodyMatch ? bodyMatch[1].trim() : translated
|
||
const preheaderMatch = innerHtml.match(/<div[^>]*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 <support@targointernet.com>' }
|
||
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 })
|
||
}
|
||
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',
|
||
commitment_months: '3',
|
||
year: new Date().getFullYear(),
|
||
...(body.vars || {}),
|
||
}
|
||
const rendered = renderTemplate(html, vars)
|
||
const fromAddr = body.from || cfg.MAIL_FROM || 'TARGO <support@targointernet.com>'
|
||
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 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) })
|
||
}
|
||
|
||
// ── 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 — 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,
|
||
}
|