Email transactional da Node.js: API REST vs SMTP, pro e contro
Da Node.js puoi inviare via nodemailer (SMTP) o via fetch verso un'API REST del provider. Performance, retry, idempotency, code esempio: cosa scegliere e perché.
Quando integri un provider email da Node.js, la scelta tipica è tra due approcci: parlare SMTP via nodemailer o invocare l'API REST del provider via fetch (o un SDK ufficiale). Entrambi funzionano, ma hanno trade-off significativi in termini di performance, gestione errori, idempotency, debuggability e portabilità. In questo articolo confrontiamo i due approcci con codice production-ready, mostriamo come implementare retry con backoff esponenziale, idempotency-key per evitare doppi invii, e quando usare uno o l'altro a seconda del workload.
SMTP da nodemailer
nodemailer è la libreria standard per Node.js, attivamente mantenuta dal 2010, supporta SMTP, SMTPS, STARTTLS, OAuth2, pool di connessioni. Esempio minimo:
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
host: "smtp.targetsmtp.it",
port: 587,
secure: false, // STARTTLS upgrade
requireTLS: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
pool: true,
maxConnections: 5,
maxMessages: 100,
});
const info = await transporter.sendMail({
from: '"Target SMTP" <noreply@targetsmtp.it>',
to: "cliente@example.it",
subject: "Conferma ordine #4287",
text: "Grazie per il tuo ordine...",
html: "<p>Grazie per il tuo ordine...</p>",
headers: {
"X-Idempotency-Key": "order-4287-confirm",
},
});
console.log("Message ID:", info.messageId);
Punti chiave nodemailer
pool: truemantiene connessioni TCP aperte, evita handshake ripetutirequireTLS: trueforza STARTTLS, fallisce se il server non lo supporta (sicurezza)maxConnectionslimita parallelismo per evitare di saturare il providermaxMessagesricicla la connessione dopo N messaggi (alcuni provider rifiutano connessioni lunghe)
API REST via fetch
L'alternativa: invio HTTP POST verso un endpoint del provider, che gestisce internamente la consegna SMTP. Esempio Target SMTP API:
async function sendViaApi(message) {
const res = await fetch("https://api.targetsmtp.it/v1/messages", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": message.idempotencyKey,
},
body: JSON.stringify({
from: { email: "noreply@targetsmtp.it", name: "Target SMTP" },
to: [{ email: message.to }],
subject: message.subject,
text: message.text,
html: message.html,
tags: ["order-confirmation", "tier-pro"],
}),
});
if (!res.ok) {
const body = await res.json();
throw new Error(`API ${res.status}: ${body.error}`);
}
return await res.json(); // { message_id, status: "queued" }
}
Confronto operativo
| Aspetto | SMTP (nodemailer) | API REST |
|---|---|---|
| Latency invio singolo | 200-500ms (handshake) o 50-100ms (pool) | 50-150ms tipico |
| Throughput | Limitato da concurrent connections SMTP | Limitato solo da rate limit API |
| Idempotency | Manuale via header custom | Nativa via Idempotency-Key header |
| Tagging/metadata | Header custom X-* | Strutturato in payload JSON |
| Allegati grandi | Streaming nativo, OK fino > 25 MB | POST size limited (tipicamente 10-20 MB) |
| Debugging | Log SMTP raw, complesso | HTTP log + response JSON dettagliata |
| Provider lock-in | Basso (SMTP è standard) | Alto (ogni provider ha API diversa) |
| Webhook eventi | Provider-specific (FBL, bounce DSN) | Provider-specific ma HTTP |
| Network requirement | Outbound 587/465 (spesso bloccato) | Outbound 443 (sempre aperto) |
Retry con backoff esponenziale
Indipendentemente dall'approccio, gli errori transitori vanno ritentati con backoff. Esempio TypeScript robusto:
async function sendWithRetry(message, attempt = 0) {
const MAX_ATTEMPTS = 5;
try {
return await sendViaApi(message);
} catch (err) {
const status = err.status ?? 0;
// 4xx non-retry tranne 408, 429
const retryable = status === 0 || status >= 500 || status === 408 || status === 429;
if (!retryable || attempt >= MAX_ATTEMPTS) throw err;
const delay = Math.min(
1000 * Math.pow(2, attempt) + Math.random() * 500,
60_000,
);
await new Promise((r) => setTimeout(r, delay));
return sendWithRetry(message, attempt + 1);
}
}
⚠️ Attenzione: ritentare senza idempotency-key porta a invii duplicati. Se la richiesta è andata a buon fine ma la risposta si è persa (network blip), il retry crea il secondo messaggio. SEMPRE generare un idempotency-key stabile (es. hash del payload + recipient) e passarlo a ogni tentativo.
Idempotency: pattern completo
import { createHash } from "crypto";
function idempotencyKey(message) {
const stable = JSON.stringify({
to: message.to,
subject: message.subject,
bodyHash: createHash("sha256").update(message.html ?? message.text ?? "").digest("hex"),
template: message.templateId,
contextId: message.contextId, // es. order_id, user_id
});
return createHash("sha256").update(stable).digest("hex").slice(0, 32);
}
// Usage
const message = {
to: "cliente@example.it",
subject: "Conferma ordine #4287",
html: "...",
contextId: "order-4287",
};
message.idempotencyKey = idempotencyKey(message);
await sendWithRetry(message);
Il provider, ricevendo lo stesso Idempotency-Key, deve riconoscere il duplicato e restituire la response originale senza inviare di nuovo. Window standard: 24 ore.
Queue: BullMQ + worker
Per volume produzione non vuoi inviare in linea con la request HTTP utente. Pattern: serializza l'invio in una queue, worker dedicato consuma.
import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";
const connection = new IORedis(process.env.REDIS_URL, { maxRetriesPerRequest: null });
const emailQueue = new Queue("email-out", { connection });
// Producer (web API)
await emailQueue.add("send", {
to: "cliente@example.it",
templateId: "order-confirmation",
context: { orderId: 4287 },
}, {
jobId: `order-4287-confirm`, // idempotency a livello queue
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { age: 86400 },
removeOnFail: { age: 604800 },
});
// Worker (process separato)
new Worker("email-out", async (job) => {
const message = await renderTemplate(job.data);
message.idempotencyKey = idempotencyKey(message);
await sendViaApi(message);
}, { connection, concurrency: 10 });
Il jobId di BullMQ garantisce che lo stesso ordine non venga accodato due volte. attempts + backoff gestiscono retry automatici. Concorrency 10 = 10 worker paralleli che processano la queue.
Quando SMTP, quando API
Scegli SMTP se
- Hai codebase legacy che già usa nodemailer e non vuoi rewrite
- Stai integrando con un mailer che NON ha API (es. self-hosted Postfix interno)
- Allegati di grandi dimensioni (> 10 MB)
- Vuoi flessibilità di switch provider senza modifiche codice
Scegli API se
- Sei in ambiente serverless (Lambda, Cloud Functions, Vercel Edge): SMTP outbound spesso bloccato
- Vuoi tagging, metadata, scheduling, template lato provider
- Hai bisogno di webhook eventi (open, click, bounce) integrati
- Vuoi rate-limit nativi e queue gestita dal provider
- Stai inviando volume elevato con allegati piccoli
💡 Suggerimento: in scenari serverless puoi ancora usare SMTP via porta 587, ma molti provider cloud limitano l'egress SMTP per default. AWS Lambda permette outbound 587 dopo richiesta a Support. Vercel Edge non lo permette. Cloud Functions GCP idem. In dubbio, API REST è la scelta safe.
Logging e observability
Loggare ogni invio con structured log (es. pino):
import pino from "pino";
const logger = pino({ level: "info" });
async function sendTracked(message) {
const start = Date.now();
const log = logger.child({
idempotencyKey: message.idempotencyKey,
recipient: message.to,
template: message.templateId,
});
try {
const result = await sendViaApi(message);
log.info({ messageId: result.message_id, durationMs: Date.now() - start }, "email_sent");
return result;
} catch (err) {
log.error({ err, durationMs: Date.now() - start }, "email_failed");
throw err;
}
}
Riferimenti
Sia SMTP che API funzionano in produzione: la scelta dipende dal contesto. Target SMTP supporta entrambi i metodi sullo stesso account (stesse credenziali, stesso pool IP), permettendo di usare SMTP per legacy e API per nuovi servizi senza dover gestire provider duplicati. Idempotency-key è supportata nativamente su entrambi gli endpoint con window 24 ore, e il Send-Time Firewall valida i messaggi prima dell'invio bloccando recipient in suppression list e payload con pattern problematici.