gigafibre-fsm/services/targo-hub/lib/email.js
louispaulb 5d763f12ff feat(hub): gift-campaign module — CSV parse, customer match, async send + webhook
- lib/campaigns.js (new): full backend for the gift campaign flow.
  • Two CSV parsers: parseMapCsv handles the pipe-delimited legacy export
    with title preamble; parseGiftbitCsv auto-detects the URL column.
  • Multi-strategy customer match against ERPNext: email → phone → civic
    + postal_code on Service Location. Returns confidence score (1.0 /
    0.9 / 0.8) and match method. Addresses the 25%-match limitation of
    the legacy_delivery_id approach by fanning out to address-based
    lookup when email/phone miss.
  • Storage: JSON files at data/campaigns/<id>.json with embedded
    recipients array. Counters auto-recomputed from recipient statuses
    on every save (single source of truth).
  • Async send worker: setImmediate fire-and-forget loop, throttle
    configurable, broadcasts recipient-update events over SSE topic
    campaign:<id> for live UI progress.
  • Mailjet webhook handler at POST /campaigns/webhook: matches events
    to recipients via X-MJ-CustomID = "<campaign-id>:<recipient-index>"
    for O(1) lookup, falls back to MessageID scan if CustomID absent.
  • Template CRUD endpoints (GET/PUT /campaigns/templates/:name) with
    automatic timestamped backups before overwrite. Path-traversal
    guarded by an allow-list (only gift-email-fr editable).
  • Mustache section renderer ({{#var}}...{{/var}}) shared with the CLI.

- lib/email.js: accept opts.from override (campaign sender differs from
  default MAIL_FROM) and opts.headers passthrough (needed for the
  X-MJ-CustomID header that drives webhook → recipient correlation).
  Return the nodemailer info object on success instead of a bare bool so
  callers can capture info.messageId — legacy truthy checks still work.

- server.js: register /campaigns/* route on the hub router.

- templates/gift-email-fr.html: bundled copy of the campaign template
  inside the hub so it's deployable without scripts/ on the path. Kept
  in sync manually with scripts/campaigns/templates/gift-email-fr.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:07:40 -04:00

133 lines
4.8 KiB
JavaScript

'use strict'
const cfg = require('./config')
const { log } = require('./helpers')
let _transporter = null
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) {
const transport = getTransporter()
if (!transport) {
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',
})
}
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) {
log(`Email send failed to ${opts.to}: ${e.message}`)
// Legacy contract: return false on failure. New callers that need the
// error string should check `Promise.allSettled` style or wrap in try
// (we don't throw here to preserve existing `if (await sendEmail(...))`
// call sites). The error is logged above.
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&ecirc;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&ecirc;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 &eacute;galement joint &agrave; 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 &mdash; Targo T&eacute;l&eacute;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 }