All dispatchesView as Markdown

How to send email in Node.js—production architecture, queues, and retries

MC
Maya ChenEmail infrastructure at Email Bump

A framework-neutral guide to Node.js email: HTTP APIs and SMTP, configuration, typed adapters, durable outboxes, worker leases, idempotency, retry policy, graceful shutdown, delivery events, and testing.

The shortest Node.js email example is an awaited SDK call. That proves credentials work. It does not answer what happens when an HTTP request is retried, a provider accepts a message just before a timeout, two workers claim the same receipt, a deployment sends SIGTERM mid-request, or a delivery event arrives twice.

This guide builds the production path around those failure modes. The code uses modern Node.js, TypeScript, the built-in fetch API, and Email Bump’s transactional endpoint. The architecture applies equally to Express, Fastify, NestJS, Hapi, a plain node:http server, a CLI, or a dedicated background process.

Reliable Node.js email pipeline showing a validated HTTP command, transactional outbox, leased worker pool, email provider, delivery events, retries, ambiguity handling, and graceful shutdown
FIG.Persist important email intent before crossing the network boundary. Workers can then retry safely, limit concurrency, survive restarts, and attach provider outcomes to one message ledger.

Choose HTTP API, provider SDK, or SMTP deliberately

Node.js can submit email through a provider SDK, direct HTTPS, or SMTP. The right choice depends on infrastructure constraints and the operational contract—not on which tutorial has the fewest lines.

Transport decision
OPTION              USE WHEN                               YOU OWN
Direct HTTPS fetch  small dependency surface; stable API       request typing, timeouts, status mapping
Provider SDK        provider features justify the dependency    SDK upgrades, runtime compatibility
SMTP library        required relay or existing SMTP contract    pooling, TLS policy, reply parsing, timeouts
Local sendmail      tightly controlled host infrastructure      host queue, binary, permissions, portability
Raw SMTP sockets    almost never in application code            protocol, MIME, TLS, auth, retries, safety

All options still require authenticated domains, suppression handling,
idempotency, observability, templates, and delivery-event processing.

Nodemailer is a capable SMTP client; SMTP is not inherently less production-ready than HTTPS. A reputable SMTP provider can still manage receiver delivery and reputation. The tradeoff is integration shape: HTTP APIs usually expose structured errors, message IDs, metadata, idempotency options, and event models more directly. SMTP is valuable for a mandated corporate relay, legacy infrastructure, or portability across standards-compatible servers.

A personal Gmail account and app password are not a production transport plan. Consumer sending limits, identity policy, account security controls, and operational visibility differ from a transactional provider or managed organizational relay. Test through a sandbox, sink, or restricted development identity instead of teaching the application to depend on a personal mailbox.

Validate configuration before accepting traffic

process.env contains strings or undefined. Parse it once at startup into an immutable configuration object. Reject missing credentials, malformed URLs, invalid numeric limits, and unsafe production defaults before the HTTP server reports ready.

src/config.tstypescript
type AppConfig = Readonly<{
  emailApiKey: string;
  emailFrom: string;
  publicAppUrl: URL;
  requestTimeoutMs: number;
  workerConcurrency: number;
}>;

function required(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) throw new Error(`Missing required environment variable: ${name}`);
  return value;
}

function positiveInteger(name: string, fallback: number): number {
  const raw = process.env[name];
  const value = raw === undefined ? fallback : Number(raw);
  if (!Number.isSafeInteger(value) || value <= 0) {
    throw new Error(`${name} must be a positive integer`);
  }
  return value;
}

export const config: AppConfig = Object.freeze({
  emailApiKey: required("EMAIL_BUMP_API_KEY"),
  emailFrom: required("EMAIL_FROM"),
  publicAppUrl: new URL(required("PUBLIC_APP_URL")),
  requestTimeoutMs: positiveInteger("EMAIL_TIMEOUT_MS", 10_000),
  workerConcurrency: positiveInteger("EMAIL_WORKER_CONCURRENCY", 8),
});

if (config.publicAppUrl.protocol !== "https:" && process.env.NODE_ENV === "production") {
  throw new Error("PUBLIC_APP_URL must use HTTPS in production");
}
  • Use the deployment platform’s secret store in production; do not commit .env files.
  • Keep separate keys and sending identities for local, test, preview, and production environments.
  • Treat every environment variable as text—parse booleans, numbers, lists, and URLs explicitly.
  • Do not silently fall back to a production recipient or sender when configuration is missing.
  • Expose a readiness check only after database, configuration, and required worker dependencies are usable.
  • Rotate credentials and identify which service and environment owns each key.

Current Node.js releases include stable dotenv-file utilities and command-line support for loading environment files. That can reduce bootstrapping dependencies, but it does not make a local file a secret manager. process.env is process-wide mutable state, and Worker threads receive their own environment copy by default. Parse configuration before spawning workers and pass only what each execution context needs.

Send a first message through the Email Bump API

Use the built-in fetch API to verify a restricted development key and sender before adding a framework or queue. Save this as send-test-email.mjs, set EMAIL_BUMP_API_KEY and EMAIL_FROM, replace the recipient with an inbox you control, and run node send-test-email.mjs.

send-test-email.mjsjavascript
const response = await fetch("https://emailbump.com/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.EMAIL_BUMP_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: process.env.EMAIL_FROM,
    to: "you@example.com",
    subject: "Hello from Node.js",
    html: "<h1>Your Node.js integration works</h1><p>This came from Email Bump.</p>",
    text: "Your Node.js integration works\n\nThis came from Email Bump.",
  }),
  signal: AbortSignal.timeout(10_000),
});

if (!response.ok) {
  throw new Error(`Email API returned ${response.status}: ${await response.text()}`);
}

const result = await response.json();
console.log("Email accepted:", result.id);

The response proves provider acceptance, not mailbox delivery. The typed adapter below keeps this request shape in one place and adds the failure states needed by a durable worker.

Create one typed provider adapter

Keep provider request shape, credentials, timeout policy, response parsing, and error classification in one module. Application services should not switch on raw HTTP status codes or import the provider SDK across dozens of handlers.

src/email/provider.tstypescript
import { config } from "../config.js";

export type OutboundEmail = {
  messageId: string;
  from: string;
  to: string;
  replyTo?: string;
  subject: string;
  html: string;
  text: string;
};

export class EmailSubmitError extends Error {
  constructor(
    message: string,
    readonly kind: "transient" | "permanent" | "ambiguous",
    readonly status?: number,
    readonly retryAfterMs?: number
  ) {
    super(message);
  }
}

export async function submitEmail(message: OutboundEmail) {
  let response: Response;
  try {
    response = await fetch("https://emailbump.com/api/v1/emails", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${config.emailApiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        from: message.from,
        to: message.to,
        reply_to: message.replyTo,
        subject: message.subject,
        html: message.html,
        text: message.text,
      }),
      signal: AbortSignal.timeout(config.requestTimeoutMs),
    });
  } catch {
    throw new EmailSubmitError("Email request ended without a response", "ambiguous");
  }

  if (response.ok) return (await response.json()) as { id: string };

  const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
  if (response.status === 408 || response.status === 429 || response.status >= 500) {
    throw new EmailSubmitError("Temporary email provider failure", "transient", response.status, retryAfterMs);
  }
  throw new EmailSubmitError("Email request was rejected", "permanent", response.status);
}

A timeout is not always a clean failure. The server may have accepted the request while the response was delayed or lost. Classifying every network error as transient and immediately retrying can duplicate a receipt or security alert. Use provider request idempotency when available. Otherwise preserve an ambiguous state and reconcile provider activity before a high-impact resend.

AbortSignal.timeout bounds how long the client waits; it does not roll back work at the remote server. It also does not create a complete latency budget by itself. Account for queue wait, DNS, connection establishment, TLS, server processing, body reading, database writes, and the caller’s own deadline.

Expose business commands—not a generic send endpoint

An endpoint named POST /email that accepts from, to, subject, and html is an internal convenience that can become an open relay. Define narrow commands such as resend-verification, invite-member, submit-contact-form, or send-invoice-copy. Authenticate and authorize the caller, then load the authoritative recipient and content policy on the server.

Framework-neutral service boundarytypescript
type InviteMemberCommand = {
  actorUserId: string;
  workspaceId: string;
  invitedEmail: string;
};

export async function inviteMember(command: InviteMemberCommand) {
  const membership = await memberships.find(command.workspaceId, command.actorUserId);
  if (membership?.role !== "admin") throw new AuthorizationError();

  const email = normalizeAndValidateEmail(command.invitedEmail);

  return database.transaction(async (tx) => {
    const invite = await invites.createOrReuse(tx, {
      workspaceId: command.workspaceId,
      email,
    });
    const job = await emailOutbox.createUnique(tx, {
      dedupeKey: `workspace-invite:${invite.id}:v2`,
      purpose: "workspace-invite",
      recipient: email,
      templateKey: "workspace-invite",
      templateVersion: "2",
      variables: { inviteId: invite.id, workspaceId: command.workspaceId },
    });
    return { inviteId: invite.id, emailRequestId: job.publicId };
  });
}

The HTTP framework should translate authentication, validated input, and service outcomes—not own email policy. That keeps Express and Fastify adapters thin, makes CLI and message-consumer entry points reuse the same authorization-aware service, and lets tests call business logic without booting a socket.

  • Limit JSON body size and reject unsupported content types before parsing.
  • Validate every field at runtime even when the project uses TypeScript.
  • Apply per-account, per-IP, and purpose-specific rate limits where abuse is possible.
  • Fix public contact-form From and To identities on the server; validate Reply-To.
  • Return neutral account-recovery responses so callers cannot enumerate users.
  • Use 202 Accepted for durably queued work rather than claiming delivery.

Render templates from typed, normalized variables

Do not store arbitrary caller-supplied HTML as a transactional template. Map each message purpose to a typed variable schema, normalize values once, escape untrusted strings, construct URLs through URL APIs, and render both HTML and plain text.

Template registrytypescript
type TemplateMap = {
  "workspace-invite": {
    inviterName: string;
    workspaceName: string;
    acceptUrl: string;
    expiresAt: string;
  };
  "payment-receipt": {
    receiptNumber: string;
    amountMinor: number;
    currency: string;
    paidAt: string;
    receiptUrl: string;
  };
};

type RenderedEmail = { subject: string; html: string; text: string };
type Renderer<K extends keyof TemplateMap> = (
  variables: TemplateMap[K]
) => Promise<RenderedEmail>;

export const templates: { [K in keyof TemplateMap]: Renderer<K> } = {
  "workspace-invite": renderWorkspaceInvite,
  "payment-receipt": renderPaymentReceipt,
};

React Email works in a Node.js service even when the product frontend is not React. MJML, Handlebars, Liquid, and purpose-built template services can also work. The important properties are deterministic rendering, explicit escaping behavior, versioning, representative previews, and a reviewable plain-text alternative.

Snapshot immutable facts needed by a retry. A receipt queued today should not silently render tomorrow with a renamed product, changed price, or different currency. Conversely, a payment-failure reminder should check current invoice state immediately before send and suppress itself if the customer already paid. Document whether each variable is event-time, current-time, or both.

Use the transactional outbox for important email

Writing application state and calling a remote provider cannot share one database transaction. The outbox pattern commits business state and email intent together. A separate worker owns remote submission. If the process stops anywhere after commit, the pending row remains discoverable.

PostgreSQL outbox with leasessql
CREATE TABLE email_outbox (
  id                  uuid PRIMARY KEY,
  public_id           uuid NOT NULL UNIQUE,
  dedupe_key          text NOT NULL UNIQUE,
  purpose             text NOT NULL,
  recipient           text NOT NULL,
  template_key        text NOT NULL,
  template_version    text NOT NULL,
  variables           jsonb NOT NULL,
  state               text NOT NULL DEFAULT 'pending',
  attempts            integer NOT NULL DEFAULT 0,
  next_attempt_at     timestamptz NOT NULL DEFAULT now(),
  lease_owner         text,
  lease_expires_at    timestamptz,
  provider_message_id text,
  last_error_class    text,
  created_at          timestamptz NOT NULL DEFAULT now(),
  accepted_at         timestamptz,
  delivered_at        timestamptz
);

CREATE INDEX email_outbox_claimable
  ON email_outbox (next_attempt_at, created_at)
  WHERE state IN ('pending', 'retry_wait');

A queue product can replace database polling, but the reliability question remains: is message publication atomic with the business transaction? If not, use an outbox publisher or another transactional bridge. An in-memory array, setImmediate callback, EventEmitter listener, Worker thread, or unresolved Promise disappears with the process and is not a durable queue.

Claim jobs with leases and database-enforced exclusivity

Multiple Node processes should be able to compete for work without sending the same row concurrently. PostgreSQL FOR UPDATE SKIP LOCKED is one useful claiming primitive. Commit the claim quickly; do not hold a database transaction open while rendering or calling the provider.

Atomic claim querysql
WITH candidate AS (
  SELECT id
  FROM email_outbox
  WHERE (
    state IN ('pending', 'retry_wait')
    AND next_attempt_at <= now()
  ) OR (
    state = 'leased'
    AND lease_expires_at < now()
  )
  ORDER BY next_attempt_at, created_at
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
UPDATE email_outbox AS job
SET state = 'leased',
    lease_owner = $1,
    lease_expires_at = now() + interval '60 seconds',
    attempts = attempts + 1
FROM candidate
WHERE job.id = candidate.id
RETURNING job.*;

Choose a lease longer than normal render and submission time, renew it for legitimately long work, and use a conditional final update that still requires the same lease owner. If a worker pauses beyond expiry, another worker may reclaim the row. Provider idempotency and semantic deduplication remain necessary because leases reduce concurrency—they do not create exactly-once delivery across a remote network.

Worker execution outlinetypescript
async function processJob(job: EmailJob, workerId: string) {
  const current = await recheckEligibility(job);
  if (!current.eligible) {
    return outbox.suppress(job.id, workerId, current.reason);
  }

  const rendered = await renderVersionedTemplate(job);
  try {
    const result = await submitEmail({
      messageId: job.id,
      from: senderFor(job.purpose),
      to: job.recipient,
      subject: rendered.subject,
      html: rendered.html,
      text: rendered.text,
    });
    await outbox.markAccepted(job.id, workerId, result.id);
  } catch (error) {
    if (!(error instanceof EmailSubmitError)) throw error;
    if (error.kind === "ambiguous") return outbox.markAmbiguous(job.id, workerId);
    if (error.kind === "permanent") return outbox.markPermanentFailure(job.id, workerId, error.status);
    return outbox.scheduleRetry(job.id, workerId, nextAttempt(job, error));
  }
}

Bound concurrency from evidence—not CPU count

Email submission is I/O-bound, so Node can keep several requests in flight without Worker threads. Worker threads are designed primarily for CPU-intensive JavaScript and do not make remote email I/O inherently more reliable. Scale processes or async tasks around provider limits, database capacity, connection pools, message urgency, and observed latency.

Concurrency budget
MAX IN-FLIGHT IS CONSTRAINED BY THE SMALLEST OF
provider requests per second and concurrent request policy
database connection-pool headroom
worker memory under worst-case template size
CPU cost of template rendering and serialization
network egress and DNS / TLS behavior
per-recipient-domain pacing policy
shutdown drain deadline

Start conservatively. Measure queue age and error response before increasing.

Use separate lanes when security, product, and bulk messages have different latency goals. A large notification import should not delay password resets. Weighted scheduling or reserved concurrency is often more predictable than one FIFO with a priority integer that bulk traffic can still dominate.

Retry with classification, server guidance, and jitter

Retrying every error wastes capacity and can amplify an incident. Authentication failures, unverified senders, malformed payloads, invalid addresses, and policy rejections need repair. Rate limits, many 5xx responses, connection resets before a response, and provider outages may recover—but response-less failures can be ambiguous.

Backoff policytypescript
function exponentialDelayMs(attempt: number) {
  const base = 2_000;
  const cap = 30 * 60_000;
  const exponential = Math.min(cap, base * 2 ** Math.min(attempt - 1, 20));
  return Math.floor(exponential * (0.5 + Math.random()));
}

function nextAttempt(job: EmailJob, error: EmailSubmitError) {
  const delay = error.retryAfterMs ?? exponentialDelayMs(job.attempts);
  return new Date(Date.now() + delay);
}

POLICY
Respect a valid Retry-After when it is longer than your minimum.
Cap attempts and total job age by message purpose.
Stop stale reminders when current state invalidates them.
Move exhausted jobs to a visible dead-letter state.
Never retry an ambiguous high-impact request without reconciliation.

Jitter prevents every worker from retrying at the same instant after a provider recovers. A circuit breaker can temporarily stop submissions during a clear provider-wide incident, but it must not hide queued work. Health dashboards should distinguish the API accepting new application traffic from the email delivery subsystem being degraded.

Design idempotency at the event, intent, and provider layers

Idempotency identities
SOURCE EVENT
webhook_event_id or application command id
Prevents the same input from being interpreted twice.

MESSAGE INTENT
payment-receipt:{paymentId}:v4
Prevents separate inputs from creating the same customer communication.

OUTBOX ATTEMPT
email_outbox.id
Correlates every worker attempt, log, and provider submission.

PROVIDER REQUEST
email_outbox.id when the provider supports idempotency
Prevents duplicate remote acceptance during response loss.

Do not mark an event processed before the database transaction that creates its intended work commits. Do not use only a random UUID—it distinguishes attempts but cannot recognize the same business purpose. Do not deduplicate forever by recipient or subject; both block legitimate future messages.

Shut down without abandoning leased work

Container platforms and process managers normally ask a Node process to stop with SIGTERM. Once you install a signal listener, Node’s default exit behavior is removed, so the handler must complete shutdown and set an exit code. Stop accepting new HTTP traffic, stop claiming jobs, drain in-flight work within the platform deadline, then close pools and exit.

Graceful shutdown outline
let shuttingDown = false;
const inFlight = new Set<Promise<unknown>>();

async function shutdown(signal: string) {
  if (shuttingDown) return;
  shuttingDown = true;
  logger.info({ signal }, "shutdown started");

  worker.stopClaiming();
  await closeHttpServer(); // server.close(): no new connections, drain active work

  const drain = Promise.allSettled([...inFlight]);
  const deadline = new Promise((resolve) => setTimeout(resolve, 20_000));
  await Promise.race([drain, deadline]);

  await worker.releaseUnfinishedLeases();
  await database.end();
  process.exitCode = 0;
}

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

Do not call process.exit immediately after asynchronous cleanup begins; it can truncate logs and abandon I/O. Let the event loop finish after resources close, and retain an external hard deadline for a process that cannot drain. If a worker dies without cleanup, leases must expire so another worker can recover the job.

Receive delivery events with the same rigor as send requests

The provider’s synchronous response usually proves acceptance, not mailbox delivery. A separate authenticated webhook reports delivered, deferred, bounced, complained, or suppressed outcomes. Verify its signature exactly as documented, preserve the raw body when required, insert event IDs uniquely, and acknowledge only after durable storage.

Delivery evidence flow
POST /webhooks/email
  → enforce body limit
  → capture exact required request bytes
  → verify timestamp and signature
  → insert provider_event_id ON CONFLICT DO NOTHING
  → return 2xx after commit

EVENT WORKER
  → map provider_message_id to email_outbox
  → apply monotonic or provider-specific state rules
  → update delivered / bounced / complained evidence
  → suppress or alert according to policy
  → retain raw event only as long as privacy and audit require

Delivery events can be duplicated or arrive out of order. Do not let an older deferred event overwrite a terminal delivered event without a provider-specific reason. A bounced receipt does not roll back a successful payment, and an email complaint does not erase an account event; communication state and product state remain separate.

Carry correlation through asynchronous work

AsyncLocalStorage can retain request or job context across promise chains, which is useful for correlation-aware logs. It is not durable context: serialize the identifiers needed by a future process into the outbox row or queue message.

Async correlationtypescript
import { AsyncLocalStorage } from "node:async_hooks";

type TraceContext = { traceId: string; emailJobId?: string };
export const trace = new AsyncLocalStorage<TraceContext>();

export function withJobTrace<T>(job: EmailJob, task: () => Promise<T>) {
  return trace.run(
    { traceId: job.traceId, emailJobId: job.id },
    task
  );
}

// Include traceId and emailJobId in structured logs.
// Persist both on the job; memory context disappears on restart.
  • Log purpose, template version, internal recipient ID, attempt, latency, and outcome.
  • Do not log API keys, action tokens, full message content, or unnecessary addresses.
  • Track oldest ready-job age and lease-expiry recovery, not only queue length.
  • Measure provider acceptance and delivery-event latency separately.
  • Alert on permanent failures, ambiguous submissions, dead letters, bounce spikes, and complaints.
  • Include application, worker, and template deployment versions for incident comparison.

Test transport failure as a first-class behavior

Node’s built-in test runner supports mocking and timers, so an email service does not require real sends in unit tests. Inject the provider adapter and clock where practical. Use a restricted sandbox or sink only for integration tests that intentionally exercise the real network.

Test matrix
CONFIGURATION
missing key · malformed URL · invalid concurrency · production HTTP origin

COMMAND
unauthorized actor · invalid recipient · duplicate command · concurrent command

OUTBOX
business rollback · unique intent · stale suppression · expired lease reclaim

PROVIDER
202 acceptance · 400 permanent · 429 Retry-After · 500 transient
connection failure before response · timeout after possible acceptance

PROCESS
SIGTERM while idle · while leasing · while submitting · after acceptance

EVENTS
duplicate webhook · bad signature · reordered delivery states · unknown message id

TEMPLATE
HTML escaping · plain text · long Unicode · missing values · secure links

Assert state transitions and side effects, not only returned JSON. A timeout test should prove the job becomes ambiguous. A repeated webhook should prove one event record. A SIGTERM test should prove no new job is claimed and unfinished leases remain recoverable.

Production checklist

  • Choose HTTPS, an SDK, or SMTP from requirements and operational evidence.
  • Validate immutable typed configuration before reporting the process ready.
  • Centralize provider requests, timeouts, response parsing, and error classification.
  • Expose named, authorized business commands instead of an arbitrary send endpoint.
  • Validate runtime input, body size, rate limits, sender ownership, and Reply-To.
  • Use versioned templates with escaped variables and inspected plain text.
  • Commit important business state and email intent in one database transaction.
  • Claim jobs with short transactions, leases, conditional updates, and recovery.
  • Bound concurrency by provider, database, memory, CPU, and shutdown capacity.
  • Retry only transient failures with backoff, jitter, caps, and stale-state checks.
  • Preserve ambiguous outcomes and reconcile before risky resends.
  • Deduplicate source events, business message intent, and provider requests separately.
  • Handle SIGTERM by stopping claims, draining work, releasing leases, and closing resources.
  • Verify and deduplicate provider events before updating message evidence.
  • Persist correlation identifiers and keep sensitive content out of logs.
  • Test database, network, provider, process, duplicate, and ordering failures.