Menu
Accedi Crea account
Guide

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é.

12 May 2025 · 10 min di lettura · Target SMTP

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: true mantiene connessioni TCP aperte, evita handshake ripetuti
  • requireTLS: true forza STARTTLS, fallisce se il server non lo supporta (sicurezza)
  • maxConnections limita parallelismo per evitare di saturare il provider
  • maxMessages ricicla 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

AspettoSMTP (nodemailer)API REST
Latency invio singolo200-500ms (handshake) o 50-100ms (pool)50-150ms tipico
ThroughputLimitato da concurrent connections SMTPLimitato solo da rate limit API
IdempotencyManuale via header customNativa via Idempotency-Key header
Tagging/metadataHeader custom X-*Strutturato in payload JSON
Allegati grandiStreaming nativo, OK fino > 25 MBPOST size limited (tipicamente 10-20 MB)
DebuggingLog SMTP raw, complessoHTTP log + response JSON dettagliata
Provider lock-inBasso (SMTP è standard)Alto (ogni provider ha API diversa)
Webhook eventiProvider-specific (FBL, bounce DSN)Provider-specific ma HTTP
Network requirementOutbound 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.

Tag #nodejs #nodemailer #api #queue
Condividi: X LinkedIn Email

Articoli correlati