Email SDK
Concepts

Fallbacks and retries

Retry transient provider errors with backoff, then fail over to a compatible backup adapter — two layers, one send call.

A failing send gets two distinct layers of recovery. Retries re-attempt the same adapter when an error looks transient (rate limit, timeout, 5xx). Fallback moves to the next adapter only after the current one has finally failed. Both are off by default — retries is 0 and fallback is empty.

lib/email.ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
import { smtp } from "@opencoredev/email-sdk/smtp";

const email = createEmailClient({
  adapters: [
    resend({ apiKey: process.env.RESEND_API_KEY! }),
    smtp({
      host: process.env.SMTP_HOST!,
      auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
    }),
  ],
  retry: { retries: 2 },
  fallback: ["smtp"],
});

With that client, one send call that keeps hitting rate limits plays out like this:

resend  attempt 1 → 429 (retryable)   wait 100ms
resend  attempt 2 → 429               wait 200ms
resend  attempt 3 → 429               retries exhausted → onError("resend")
smtp    attempt 1 → accepted          response.provider === "smtp"

The retry budget resets for each adapter on the route. The fallback adapter gets its own retries: 2, so the worst case for this client is six provider attempts — size your retry budget with the whole route in mind.

Fallback routes must be field-compatible

A backup adapter only helps if it supports every EmailMessage field your messages actually use — adapters reject unsupported fields instead of silently dropping them, so an incompatible fallback fails too. Pick routes from the field support matrix.

Retries

Configure retries once on the client with the retry option:

Prop

Type

The default backoff doubles from 100ms and caps at 2 seconds: 100, 200, 400, 800, 1600, 2000, 2000…

What retries by default

The default shouldRetry is isRetryableEmailError: it retries only errors the SDK has marked retryable: true. Adapters set that flag for:

ErrorWhy it retries
HTTP 408, 409, 425, 429Timeout, conflict, too-early, rate limit
HTTP 5xxProvider-side failure
Network errorsECONNRESET-style failures, fetch failed, timeouts

Everything else — 401, 422, validation errors, bugs in your code — fails immediately. To change the policy, supply your own shouldRetry or delay:

const email = createEmailClient({
  adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
  retry: {
    retries: 3,
    delay: (attempt) => 250 * attempt + Math.random() * 100, // linear + jitter
  },
});

Fallback routes

For each send, the SDK builds a route from the selected adapter followed by the fallback adapters, with duplicate names removed:

[adapter, ...fallbackAdapters]

The selected adapter comes from the per-send adapter option or the client's defaultAdapter (the first registered adapter unless you set it). Fallbacks come from the per-send fallbackAdapters option or the client-level fallback list. A route name that is not registered throws EmailProviderNotFoundError when the route reaches it.

Fallback is not limited to retryable errors. Retryability decides whether the same adapter gets another attempt; fallback fires on any final adapter failure — including a non-retryable 401 on the first attempt.

Per-send overrides

send options can reroute or re-budget a single send without touching the client:

// More retries for a payment receipt, different backup route
await email.send(message, {
  retries: 4,
  adapter: "resend",
  fallbackAdapters: ["postmark"],
});

// Disable the client-level fallback for one send
await email.send(message, { fallbackAdapters: [] });

An empty fallbackAdapters array is the right tool when one message uses fields the default backup route cannot represent. provider and fallbackProviders are aliases for the same options.

When every route fails

If only one adapter was attempted, its error is rethrown as-is. If multiple routes failed, the SDK throws an EmailSdkError with code all_providers_failed and every per-adapter failure collected in details:

import { EmailSdkError } from "@opencoredev/email-sdk";

try {
  await email.send(message);
} catch (error) {
  if (error instanceof EmailSdkError && error.code === "all_providers_failed") {
    console.error(error.details); // one failure per attempted adapter, in route order
  }
}

See the errors reference for the full error taxonomy, and hooks for observing attempts, retries, and route changes as they happen.

Idempotency keys

Retries and fallbacks mean the same logical email can be transmitted more than once. Give externally visible sends a stable key:

await email.send(message, {
  idempotencyKey: "receipt:order_123",
});

The key (from send options, falling back to message.idempotencyKey) is passed to the adapter on every attempt. What happens next depends on the adapter:

  • Resend is the only adapter that transmits it natively, as the Idempotency-Key HTTP header — the provider itself deduplicates repeated sends.
  • SMTP uses message.idempotencyKey as the Message-ID header value, so downstream mail systems can recognize duplicates.
  • Every other adapter ignores it on the wire.

Even where the provider ignores the key, set one anyway: it gives your own pipeline — queues, workers, hook-based logging — a stable identity, so application-level retries and re-enqueues stay safe to deduplicate.

Testing the route

failingProvider and memoryProvider from the testing entry point make fallback behavior assertable without network calls:

import { createEmailClient } from "@opencoredev/email-sdk";
import { failingProvider, memoryProvider } from "@opencoredev/email-sdk/testing";

const backup = memoryProvider("backup");
const email = createEmailClient({
  adapters: [failingProvider("primary"), backup],
  fallback: ["backup"],
});

const response = await email.send({
  from: "Acme <hello@acme.com>",
  to: "user@example.com",
  subject: "Fallback",
  text: "Hello",
});

expect(response.provider).toBe("backup");
expect(backup.raw.sent).toHaveLength(1);

Test with the same message shape production sends — that is what proves the routes are compatible.

Next

On this page