gigafibre-fsm/services/targo-hub/lib/campaigns.js
louispaulb ddedd60320 fix(campaigns/expiry): format dates in America/Montreal, not container UTC
The targo-hub container runs with TZ=UTC (no override set). Calls to
toLocaleDateString without an explicit timeZone option were rendering
dates in UTC, which meant a wrapper expiring at 23:59 EDT (= 03:59
UTC next day) showed "22 juin 2026" to the recipient instead of the
intended "21 juin".

All 4 date-formatting sites in lib/campaigns.js now pass
timeZone: 'America/Montreal' explicitly:
- worker (sendCampaignAsync) — main send path
- /campaigns/:id/recipients/:i/view — web fallback render
- POST /templates/:name/test-send sample defaults
- POST /templates/:name/preview sample defaults

Verified on prod: stored UTC "2026-06-22T03:59:59Z" now formats
"21 juin 2026" / "June 21, 2026" with the timeZone option, matching
the operator's intent ("expiration en fin de journée le 21 juin EDT").

Also re-patched the relance draft cmp-20260601-f857cd-rem from
2026-06-21T23:59:59Z (= 19:59 EDT, the early-evening cutoff) to
2026-06-22T03:59:59Z (= 23:59:59 EDT, true end of day). Bonus: this
aligns with the original campaign's recipients which expire around
~11:57-12:04 EDT on June 21, so the reminder always works at least
as long as the original — never the inverse confusion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:58:54 -04:00

2307 lines
105 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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, 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-<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
}
// 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/<token>
// 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 `<!doctype html><html lang="fr"><head><meta charset="utf-8">
<title>TARGO — ${m.fr}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<style>
body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#F5FAF7;color:#1B2E24;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:#fff;max-width:480px;margin:24px;padding:48px 32px;border-radius:16px;border:1px solid #e5e7eb;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.logo{font-size:28px;font-weight:800;color:#00C853;margin-bottom:24px;letter-spacing:-.5px}
.icon{font-size:48px;margin-bottom:16px}
h1{font-size:22px;margin:0 0 8px;font-weight:700}
p{color:#64748B;margin:0;line-height:1.5}
.en{margin-top:16px;font-size:14px;color:#94a3b8}
a{color:#00C853;text-decoration:none;font-weight:600}
</style></head><body>
<div class="card">
<div class="logo">TARGO</div>
<div class="icon">🎁</div>
<h1>${m.fr}</h1>
<p>Si tu penses qu'il s'agit d'une erreur, écris-nous à <a href="mailto:support@targo.ca">support@targo.ca</a>.</p>
<p class="en">${m.en}</p>
</div></body></html>`
}
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()
// Expiry resolution order:
// 1. params.gift_expires_at (explicit ISO date set in the wizard
// via the date picker) — all recipients of THIS campaign get
// the same hard cutoff, regardless of when the worker fires.
// 2. Fallback: now() + gift_expiry_days (relative deadline,
// shifts forward by the queue lag).
if (p.gift_expires_at) {
r.gift_expires_at = new Date(p.gift_expires_at).toISOString()
} else {
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', timeZone: 'America/Montreal' })
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": "<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 || '')
// 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/<token> 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/<token> 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: "<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 (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 <!doctype html> to </html>.`,
`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(/<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 })
}
// 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', timeZone: 'America/Montreal' })
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 <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 sampleExpAt = new Date(Date.now() + 30 * 86400 * 1000)
.toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'America/Montreal' })
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', timeZone: 'America/Montreal' })
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,
}