@email-service/email-service
Version:
email-service is a versatile npm package designed to simplify the integration and standardization of email communications across multiple Email Service Providers (ESPs).
150 lines (149 loc) • 5.91 kB
JavaScript
import { renderTemplate } from './templating.js';
import { injectUnsubscribeHeader } from './unsubscribe.js';
import { stripHtml } from './stripHtml.js';
/**
* Règles de blocage par stream. Appelées sur la `SuppressionReason` retournée
* par le hook `checkSuppression` du consommateur. Si la fonction retourne `true`,
* l'envoi est skip et `onSkipped` est invoqué.
*
* - `transactional` bloque uniquement sur les cas absolus (adresse invalide,
* désinscription totale)
* - `marketing` bloque en plus sur les cas « soft » (plainte spam, désinscription
* canal marketing)
*/
function blocksStream(reason, stream) {
if (reason === 'hard_bounce' || reason === 'unsubscribe_all')
return true;
if (stream === 'marketing') {
return reason === 'spam_complaint' || reason === 'unsubscribe_marketing';
}
return false;
}
/**
* Boucle interne exécutée par `ESP.sendBulk`. Reçoit une fonction `sendOne`
* qui délègue à `sendMail()` de l'ESP concret — permet au template method de
* la classe ESP de conserver le rate limit Phase 3 automatiquement.
*
* Comportement par destinataire :
* 1. Validation marketing → si absent, throw avant même la boucle
* 2. checkSuppression via hook → skip si bloqué par le stream
* 3. Interpolation subject/html/text via renderTemplate + mergeVars
* 4. Injection List-Unsubscribe si marketing
* 5. Appel sendOne (qui applique rate limit)
* 6. Hook onSent / onFailed
* 7. Accumulation dans le BulkReport
*/
export async function runBulk(payload, hooks, sendOne) {
// Validation stream=marketing
if (payload.stream === 'marketing' && typeof payload.unsubscribeUrl !== 'function') {
throw new Error('[sendBulk] stream="marketing" requires unsubscribeUrl: (email) => string');
}
const startedAt = new Date().toISOString();
const startTs = Date.now();
const report = {
campaignId: payload.campaignId,
sent: 0,
skipped: [],
failed: [],
startedAt,
endedAt: startedAt,
durationMs: 0,
};
for (const recipient of payload.recipients) {
// 1. checkSuppression
if (hooks?.checkSuppression) {
try {
const reason = await hooks.checkSuppression(recipient.email, {
stream: payload.stream,
campaignId: payload.campaignId,
});
if (reason && blocksStream(reason, payload.stream)) {
report.skipped.push({ email: recipient.email, reason });
if (hooks.onSkipped) {
try {
await hooks.onSkipped(recipient.email, reason, payload.campaignId);
}
catch { /* swallow — ne casse pas le batch */ }
}
continue;
}
}
catch {
// Hook user cassé → on continue l'envoi sans check, plutôt que
// d'échouer tout le batch. Décision conservatrice : mieux vaut
// envoyer à quelqu'un en suppression que planter une campagne.
}
}
// 2. Interpolation mergeVars dans subject/html/text
const vars = recipient.mergeVars ?? {};
const subject = renderTemplate(payload.template.subject, vars);
const html = renderTemplate(payload.template.html, vars);
const text = payload.template.text
? renderTemplate(payload.template.text, vars)
: stripHtml(html);
// 3. Construction payload unitaire
let emailPayload = {
from: payload.from,
to: recipient.name
? [{ email: recipient.email, name: recipient.name }]
: recipient.email,
subject,
html,
text,
metaData: {
...(payload.metadata ?? {}),
campaignId: payload.campaignId,
stream: payload.stream,
},
};
// 4. Injection List-Unsubscribe en marketing
if (payload.stream === 'marketing') {
const url = payload.unsubscribeUrl(recipient.email);
emailPayload = injectUnsubscribeHeader(emailPayload, url);
}
// 5. Envoi via sendOne (qui applique le rate limit de l'ESP)
let response;
try {
response = await sendOne(emailPayload);
}
catch (err) {
const error = {
name: 'SEND_THROWN',
message: err instanceof Error ? err.message : String(err),
};
report.failed.push({ email: recipient.email, error });
if (hooks?.onFailed) {
try {
await hooks.onFailed(recipient.email, error, payload.campaignId);
}
catch { /* swallow */ }
}
continue;
}
// 6. Hooks post-envoi — narrowing manuel via const pour contourner
// l'inférence TS qui ne narrow pas toujours un `let` réassigné.
const result = response;
if (result.success === true) {
report.sent += 1;
if (hooks?.onSent) {
try {
await hooks.onSent(recipient.email, result, payload.campaignId);
}
catch { /* swallow */ }
}
}
else {
report.failed.push({ email: recipient.email, error: result.error });
if (hooks?.onFailed) {
try {
await hooks.onFailed(recipient.email, result.error, payload.campaignId);
}
catch { /* swallow */ }
}
}
}
const endedAt = new Date().toISOString();
report.endedAt = endedAt;
report.durationMs = Date.now() - startTs;
return report;
}