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>
143 lines
5.3 KiB
JavaScript
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ê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 }
|