gigafibre-fsm/services/targo-hub/lib/email.js
louispaulb 6577bb79bc feat(campaigns/send): real SMTP error + auto-retry + one-click Renvoyer
The send worker used to write "SMTP send returned false (see hub logs)"
on every failure, forcing the operator to SSH into the box to find the
actual cause. Now we capture the real reason and surface it in the UI.

Three changes:

1. lib/email.js exposes getLastError() — a side-channel for the most
   recent nodemailer error message, cleared at the start of every
   sendEmail call. Legacy "if (await sendEmail(...))" callers stay on
   the false-return contract; only the campaign worker reads the
   side-channel for detailed error capture.

2. The worker now retries each recipient up to 3 times (initial +
   2 retries with 2s/5s backoff). Most "Unexpected socket close"-style
   transient Mailjet errors recover on the second attempt. We observed
   exactly this case for Myriam Bergevin in cmp-20260522-2d4605 — a
   single socket close interrupted 1 of 202 sends; auto-retry would
   have caught it. retry_count is now stored on the recipient.

3. POST /campaigns/:id/recipients/:row/retry resets a single failed
   row back to pending and re-fires the worker. Surfaced in the
   detail-page table as a small 🔁 button next to the error text on
   any row with status=failed. Useful when auto-retry exhausted its
   3 attempts on a one-off transient.

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 13:29:25 -04:00

143 lines
5.3 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',
})
}
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&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, getLastError }