Events: real mass send (campaign-integrated) + white email header bar

- Mass send: "Envoyer à N destinataire(s)" button behind a confirmation dialog
  showing the exact recipient count. Creates a real campaign record (reuses
  campaigns.newCampaignId/loadCampaign/saveCampaign — 3 new exports, no worker
  changes) so Mailjet open/click tracking, per-recipient status, report.csv, and
  the /campaigns list all work unchanged. Events owns the actual send loop
  (per-recipient personal rsvp_url, per-language attachments, festive/Unlayer
  template) since the generic campaign worker doesn't support those.
- Festive email template: top accent bar changed from TARGO blue to white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-09 12:39:50 -04:00
parent 3489576212
commit 929ef73d93

View File

@ -341,7 +341,7 @@ function inviteEmailFestive (e, lang, name, rsvpUrl) {
+ `<body style="margin:0;background:#eef4f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">` + `<body style="margin:0;background:#eef4f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#eef4f8"><tr><td align="center" style="padding:30px 14px">` + `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#eef4f8"><tr><td align="center" style="padding:30px 14px">`
+ `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#fff;border-radius:18px;overflow:hidden;border:1px solid #e2e8f0">` + `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#fff;border-radius:18px;overflow:hidden;border:1px solid #e2e8f0">`
+ `<tr><td style="height:10px;background:#1a86c7;font-size:0;line-height:0">&nbsp;</td></tr>` + `<tr><td style="height:10px;background:#fff;font-size:0;line-height:0">&nbsp;</td></tr>`
+ `<tr><td align="center" style="padding:22px 30px 0">` + `<tr><td align="center" style="padding:22px 30px 0">`
+ `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>` + `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>`
+ (badge ? `<div style="display:inline-block;background:#21a34a;color:#fff;font-weight:800;font-size:12px;letter-spacing:1.5px;padding:6px 16px;border-radius:999px;margin:10px 0 8px">🎉 ${esc(badge)} 🎉</div><br>` : '<div style="height:10px"></div>') + (badge ? `<div style="display:inline-block;background:#21a34a;color:#fff;font-weight:800;font-size:12px;letter-spacing:1.5px;padding:6px 16px;border-radius:999px;margin:10px 0 8px">🎉 ${esc(badge)} 🎉</div><br>` : '<div style="height:10px"></div>')
@ -420,30 +420,97 @@ function loadSendAttachments (event, lang) {
return out return out
} }
// Normalise les pièces jointes (buffers) au format attendu par le canal.
function attachmentPayload (channel, atts) {
return channel === 'gmail'
? atts.map(a => ({ filename: a.filename, mime: a.mime, content: a.buffer.toString('base64') }))
: atts.map(a => ({ filename: a.filename, contentType: a.mime, content: a.buffer }))
}
// Envoi bas niveau d'un courriel d'invitation. customId (Mailjet) = suivi ouvertures/clics.
async function sendInviteMessage ({ channel, to, subject, html, from, attachments, customId }) {
if (channel === 'gmail') { const r = await require('./gmail').sendMessage({ to, subject, html, from: from || undefined, attachments }); return { ok: !!r, id: r && r.id } }
const headers = customId ? { 'X-MJ-CustomID': customId } : undefined
const info = await require('./email').sendEmail({ to, subject, html, from: from || undefined, attachments, headers })
return { ok: !!info, id: info && info.messageId, error: info ? null : ((require('./email').getLastError() || {}).message || 'send_failed') }
}
// ── Envoi TEST (déclenché par le staff depuis l'admin ; jamais automatique) ── // ── Envoi TEST (déclenché par le staff depuis l'admin ; jamais automatique) ──
async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '' }) { async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '' }) {
const event = getEvent(eventId) const event = getEvent(eventId)
const atts = loadSendAttachments(event, lang) // pièces jointes de la langue testée (+ 'both') const atts = attachmentPayload(channel, loadSendAttachments(event, lang)) // pièces jointes de la langue testée (+ 'both')
const results = [] const results = []
for (const em of emails) { for (const em of emails) {
const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24) const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24)
const html = inviteEmail(eventId, lang, name, link) const html = inviteEmail(eventId, lang, name, link)
const subject = '[TEST] ' + inviteSubject(eventId, lang) const subject = '[TEST] ' + inviteSubject(eventId, lang)
try { try { const r = await sendInviteMessage({ channel, to: em, subject, html, from, attachments: atts }); results.push({ email: em, ok: r.ok, error: r.error }) }
let ok catch (e) { results.push({ email: em, ok: false, error: e.message }) }
if (channel === 'gmail') {
const attachments = atts.map(a => ({ filename: a.filename, mime: a.mime, content: a.buffer.toString('base64') }))
const r = await require('./gmail').sendMessage({ to: em, subject, html, from: from || undefined, attachments }); ok = !!r
} else {
const attachments = atts.map(a => ({ filename: a.filename, contentType: a.mime, content: a.buffer }))
ok = await require('./email').sendEmail({ to: em, subject, html, from: from || undefined, attachments })
}
results.push({ email: em, ok: !!ok })
} catch (e) { results.push({ email: em, ok: false, error: e.message }) }
} }
return results return results
} }
// ── Envoi de MASSE (blast d'invitations) ───────────────────────────────────
// Crée un enregistrement CAMPAGNE (suivi Mailjet via X-MJ-CustomID `<id>:<i>` → webhook campagne existant,
// report.csv, /campaigns list/détail, SSE) MAIS events fait l'envoi lui-même : lien rsvp_url PERSONNEL par
// destinataire + pièces jointes par langue + gabarit festif/Unlayer. Le worker campagne ne gère ni rsvp_url
// ni pièces jointes ni HTML festif → on ne le réutilise pas, seulement son format d'enregistrement.
function massSend (eventId, { channel = 'mailjet', from = '' } = {}) {
const event = getEvent(eventId); if (!event) return { error: 'event_not_found' }
const aud = loadAudienceList(eventId)
const recipients = (aud.recipients || []).filter(r => EMAIL_RE.test(r.email || ''))
if (!recipients.length) return { error: 'empty_list' }
const campaigns = require('./campaigns')
const id = campaigns.newCampaignId()
const ch = channel === 'gmail' ? 'gmail' : 'mailjet'
const campaign = {
id,
name: `Invitation — ${event.fr.name}`,
created_at: new Date().toISOString(),
status: 'sending',
params: { channel: ch, from: from || '', type: 'event', event_id: eventId, template: event.email_template || 'festive' },
recipients: recipients.map(r => ({ email: r.email, firstname: r.firstname || '', lastname: r.lastname || '', language: normLang(r.language), customer_id: r.customer_id || '', source: r.source || '', status: 'pending' })),
}
campaigns.saveCampaign(campaign)
setImmediate(() => sendEventCampaignAsync(id, eventId, ch, from).catch(e => log('event blast async: ' + e.message)))
return { campaign_id: id, count: campaign.recipients.length }
}
async function sendEventCampaignAsync (id, eventId, channel, from) {
const campaigns = require('./campaigns')
const c = campaigns.loadCampaign(id); if (!c) return
const event = getEvent(eventId); if (!event) return
let sse = null; try { sse = require('./sse') } catch { /* SSE optionnel */ }
const topic = 'campaign:' + id
const bcast = (ev, data) => { try { sse && sse.broadcast(topic, ev, data) } catch { /* */ } }
const payloadByLang = { fr: attachmentPayload(channel, loadSendAttachments(event, 'fr')), en: attachmentPayload(channel, loadSendAttachments(event, 'en')) }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
bcast('campaign-status', { id, status: 'sending' })
for (let i = 0; i < c.recipients.length; i++) {
const r = c.recipients[i]
if (r.status !== 'pending') continue
const lang = normLang(r.language)
const name = ((r.firstname || '') + ' ' + (r.lastname || '')).trim()
const rsvpUrl = rsvpLink(eventId, r.customer_id || ('x-' + r.email), name, r.email)
r.rsvp_url = rsvpUrl
const html = inviteEmail(eventId, lang, name, rsvpUrl)
const subject = inviteSubject(eventId, lang)
const customId = id + ':' + i
try {
const res = await sendInviteMessage({ channel, to: r.email, subject, html, from, attachments: payloadByLang[lang] || [], customId })
if (res.ok) { r.status = 'sent'; r.sent_at = new Date().toISOString(); if (channel !== 'gmail') { r.mailjet_custom_id = customId; if (res.id) r.mailjet_uuid = res.id } }
else { r.status = 'failed'; r.error = res.error || 'send_failed' }
} catch (e) { r.status = 'failed'; r.error = e.message }
campaigns.saveCampaign(c)
bcast('recipient-update', { i, recipient: r })
if (i < c.recipients.length - 1) await sleep(600) // throttle (comme le worker campagne)
}
c.status = 'completed'; c.send_completed_at = new Date().toISOString()
campaigns.saveCampaign(c)
const sent = c.recipients.filter(x => x.status === 'sent').length
log(`Event blast ${eventId} → campaign ${id} : ${sent}/${c.recipients.length} envoyés (${channel})`)
bcast('campaign-done', { id, counters: c.counters })
}
// ── Audience de l'envoi de masse : liste clients filtrée OU import CSV ────── // ── Audience de l'envoi de masse : liste clients filtrée OU import CSV ──────
function splitName (full) { function splitName (full) {
const parts = String(full || '').trim().split(/\s+/).filter(Boolean) const parts = String(full || '').trim().split(/\s+/).filter(Boolean)
@ -1003,7 +1070,14 @@ async function handle (req, res, method, path, url) {
log(`Event invite TEST — ${eventId} · ${channel} · ${results.filter(r => r.ok).length}/${results.length} ok`) log(`Event invite TEST — ${eventId} · ${channel} · ${results.filter(r => r.ok).length}/${results.length} ok`)
return json(res, 200, { ok: results.every(r => r.ok), test: true, results }) return json(res, 200, { ok: results.every(r => r.ok), test: true, results })
} }
return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." }) // Envoi de MASSE réel (gaté côté UI par une confirmation ; ici on exige mass:true).
if (b.mass) {
const r = massSend(eventId, { channel, from: b.from })
if (r.error) return json(res, 400, { error: r.error })
log(`Event invite MASS — ${eventId} · ${channel} · ${r.count} destinataire(s) → campagne ${r.campaign_id}`)
return json(res, 202, { ok: true, campaign_id: r.campaign_id, count: r.count })
}
return json(res, 400, { error: 'bad_request' })
} }
// Staff : POST /events/<id>/audience {mode:'filter'|'csv'|'manual', filters, csv} → APERÇU (ne persiste rien) // Staff : POST /events/<id>/audience {mode:'filter'|'csv'|'manual', filters, csv} → APERÇU (ne persiste rien)
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience$/) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience$/)
@ -1048,4 +1122,4 @@ async function handle (req, res, method, path, url) {
} catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) } } catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) }
} }
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary } module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, massSend, sendEventCampaignAsync, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary }