Ops event page overhaul (hub lib/events.js + email.js, ops EventsPage.vue + api): - Create/edit/delete events (persisted config shadowing the seeded SEED_CONTENT); labels factored into shared LABELS, content editable bilingual (EN falls back to FR). - Capacity cap by headcount: public RSVP form closes with a "booking is full" message when reached (server-enforced); existing registrants can still update. - Email attachments (image/PDF, <=4MB, <=6), tagged per language (fr/en/both) so each customer gets files in their account language; optional Gemini auto-detect on images. email.sendEmail gains a generic attachments[] param. - Additive main audience list (persisted per event): build one deduped list by appending batches from filters, manual picks, or CSV; per-source breakdown, remove/clear. - Filters: name-contains (customer_name like) and city (Service Location.city, resolved accent/case-insensitively via PG with REST fallback), intersected with other criteria. - Manual picker + fuzzy search reuse app-wide /collab/customer-search (pg_trgm), so "repa" finds "Réparation …". - nginx /hub/ proxy: client_max_body_size 8m for base64 attachment uploads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
5.8 KiB
JavaScript
156 lines
5.8 KiB
JavaScript
'use strict'
|
|
const cfg = require('./config')
|
|
const { log } = require('./helpers')
|
|
|
|
let _transporter = null
|
|
// Side-channel for the most recent send failure. Callers that need the
|
|
// specific reason (e.g. campaign worker that wants to display it in the
|
|
// UI) read this after a falsy return from sendEmail. Cleared at the
|
|
// start of every send call. NOT thread-safe — fine for the worker's
|
|
// sequential loop, but if we ever parallelise sends we'd need a proper
|
|
// returned result tuple instead.
|
|
let _lastError = null
|
|
function getLastError () { return _lastError }
|
|
|
|
function getTransporter () {
|
|
if (_transporter) return _transporter
|
|
try {
|
|
const nodemailer = require('nodemailer')
|
|
|
|
if (cfg.SMTP_USER && cfg.SMTP_PASS) {
|
|
_transporter = nodemailer.createTransport({
|
|
host: cfg.SMTP_HOST,
|
|
port: cfg.SMTP_PORT,
|
|
secure: cfg.SMTP_SECURE,
|
|
auth: { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS },
|
|
})
|
|
log(`Email transport configured: ${cfg.SMTP_HOST}:${cfg.SMTP_PORT} as ${cfg.SMTP_USER}`)
|
|
} else {
|
|
// Direct delivery (no SMTP relay) — sends directly to recipient MX
|
|
_transporter = nodemailer.createTransport({ direct: true, name: 'msg.gigafibre.ca' })
|
|
log('Email transport: direct MX delivery (no SMTP relay configured)')
|
|
}
|
|
} catch (e) {
|
|
log('Email transport init failed:', e.message)
|
|
return null
|
|
}
|
|
return _transporter
|
|
}
|
|
|
|
/**
|
|
* Send an HTML email with optional PDF attachment
|
|
* @param {object} opts
|
|
* @param {string} opts.to - Recipient email
|
|
* @param {string} opts.subject - Subject line
|
|
* @param {string} opts.html - HTML body
|
|
* @param {Buffer} [opts.pdfBuffer] - Optional PDF attachment
|
|
* @param {string} [opts.pdfFilename] - PDF filename
|
|
* @returns {Promise<boolean>} true if sent
|
|
*/
|
|
async function sendEmail (opts) {
|
|
_lastError = null
|
|
const transport = getTransporter()
|
|
if (!transport) {
|
|
_lastError = new Error('No transport available (SMTP not configured)')
|
|
log('Cannot send email — no transport available')
|
|
return false
|
|
}
|
|
|
|
const mailOpts = {
|
|
from: opts.from || cfg.MAIL_FROM,
|
|
to: opts.to,
|
|
subject: opts.subject,
|
|
html: opts.html,
|
|
attachments: [],
|
|
// Custom headers (e.g. X-MJ-CustomID for Mailjet Event API webhook
|
|
// correlation — Mailjet echoes the CustomID back in every event so
|
|
// we can match webhook events to the originating recipient).
|
|
headers: opts.headers || {},
|
|
}
|
|
|
|
if (opts.pdfBuffer && opts.pdfFilename) {
|
|
mailOpts.attachments.push({
|
|
filename: opts.pdfFilename,
|
|
content: opts.pdfBuffer,
|
|
contentType: 'application/pdf',
|
|
})
|
|
}
|
|
|
|
// Generic attachments (e.g. event invitation images) : [{ filename, content(Buffer), contentType|mime }]
|
|
if (Array.isArray(opts.attachments)) {
|
|
for (const a of opts.attachments) {
|
|
if (a && a.filename && a.content) {
|
|
mailOpts.attachments.push({
|
|
filename: a.filename,
|
|
content: a.content,
|
|
contentType: a.contentType || a.mime || 'application/octet-stream',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
const info = await transport.sendMail(mailOpts)
|
|
log(`Email sent to ${opts.to}: ${info.messageId || 'OK'}`)
|
|
// Return the info object (always truthy) so callers can capture
|
|
// info.messageId for tracking. Legacy `if (await sendEmail(...))`
|
|
// callers continue to work because the object is truthy.
|
|
return info || { messageId: null }
|
|
} catch (e) {
|
|
_lastError = e
|
|
log(`Email send failed to ${opts.to}: ${e.message}`)
|
|
// Legacy contract: return false on failure. New callers that need the
|
|
// error string should call email.getLastError() right after a falsy
|
|
// return — set above, cleared at the start of every send call.
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a quotation acceptance email with PDF attached
|
|
*/
|
|
async function sendQuotationEmail (opts) {
|
|
const { to, quotationName, acceptLink, pdfBuffer } = opts
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"></head><body style="font-family:'Inter',system-ui,sans-serif;margin:0;padding:0;background:#f1f5f9">
|
|
<div style="max-width:580px;margin:0 auto;padding:24px">
|
|
<div style="background:white;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.06)">
|
|
<div style="background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;padding:24px 28px">
|
|
<h1 style="margin:0;font-size:20px;font-weight:700">Votre devis est prêt</h1>
|
|
<p style="margin:6px 0 0;opacity:0.85;font-size:13px">Devis ${quotationName}</p>
|
|
</div>
|
|
<div style="padding:24px 28px">
|
|
<p style="color:#334155;font-size:15px;line-height:1.6;margin:0 0 20px">
|
|
Bonjour,<br><br>
|
|
Votre devis <b>${quotationName}</b> est prêt pour votre examen.
|
|
Cliquez sur le bouton ci-dessous pour le consulter et l'accepter.
|
|
</p>
|
|
<div style="text-align:center;margin:24px 0">
|
|
<a href="${acceptLink}" style="display:inline-block;background:#6366f1;color:white;padding:14px 32px;border-radius:10px;text-decoration:none;font-weight:700;font-size:15px">
|
|
Voir et accepter le devis
|
|
</a>
|
|
</div>
|
|
<p style="color:#64748b;font-size:13px;text-align:center;margin:16px 0 0">
|
|
${pdfBuffer ? 'Le PDF du devis est également joint à ce courriel.' : ''}
|
|
Ce lien est valide pour 7 jours.
|
|
</p>
|
|
</div>
|
|
<div style="border-top:1px solid #e2e8f0;padding:16px 28px;text-align:center">
|
|
<p style="color:#94a3b8;font-size:11px;margin:0">Gigafibre — Targo Télécommunications</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body></html>`
|
|
|
|
return sendEmail({
|
|
to,
|
|
subject: `Devis ${quotationName} — Acceptation requise`,
|
|
html,
|
|
pdfBuffer: pdfBuffer || null,
|
|
pdfFilename: pdfBuffer ? `${quotationName}.pdf` : null,
|
|
})
|
|
}
|
|
|
|
module.exports = { sendEmail, sendQuotationEmail, getLastError }
|