Email SDK

Production send pipeline

Build one production-ready email client — retries, a compatible fallback route, shared defaults, idempotency keys, observability, tests, and CLI verification.

This guide grows one lib/email.ts from a single adapter into a production send pipeline: retries on transient failures, a fallback route for outages, org-wide defaults, idempotency keys, secret-safe observability, a test that proves the routing, and CLI checks before the first live send.

It assumes you have finished the quickstart. Every step keeps your application code unchanged — email.send(...) stays the only call sites know about.

Start with the primary adapter

One shared client, one adapter. These docs use Resend; any adapter slots in the same way.

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

export const email = createEmailClient({
  adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
});

Add retries for transient failures

By default a send makes exactly one attempt. Give the client a retry budget for rate limits, 5xx responses, and network errors:

lib/email.ts
export const email = createEmailClient({
  adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
  retry: { retries: 2 },
});

Only retryable errors are retried — HTTP 408, 409, 425, 429, 5xx, and network failures. Validation errors and hard rejections fail immediately. The default backoff is min(100 * 2^(attempt - 1), 2000) milliseconds — 100ms doubling per attempt, capped at 2s; see fallbacks and retries for the full retry config, including custom delay and shouldRetry.

Add a compatible fallback route

Retries handle blips; a fallback adapter handles an outage. Pick a backup that supports every field your messages use — check field support first. Postmark pairs well with Resend here: both carry CC, BCC, reply-to, headers, attachments, and tags.

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

export const email = createEmailClient({
  adapters: [
    resend({ apiKey: process.env.RESEND_API_KEY! }),
    postmark({ serverToken: process.env.POSTMARK_SERVER_TOKEN! }),
  ],
  fallback: ["postmark"],
  retry: { retries: 2 },
});

Each send now tries Resend (with retries), then Postmark (with retries). A send that includes a field the backup rejects — say metadata on the Resend side, or a second tag on Postmark — throws an EmailValidationError instead of silently dropping data.

Apply org-wide defaults

The defaults plugin merges shared values into every message before validation, so individual call sites stay minimal:

lib/email.ts
import { defaultsPlugin } from "@opencoredev/email-sdk/plugins/defaults";

export const email = createEmailClient({
  adapters: [
    resend({ apiKey: process.env.RESEND_API_KEY! }),
    postmark({ serverToken: process.env.POSTMARK_SERVER_TOKEN! }),
  ],
  fallback: ["postmark"],
  retry: { retries: 2 },
  plugins: [
    defaultsPlugin({
      replyTo: "Acme Support <support@acme.com>",
      headers: { "X-Service": "checkout" },
      sendMetadata: { service: "checkout" },
    }),
  ],
});

Message values always win: a send that sets its own replyTo keeps it. sendMetadata attaches context for hooks and observability without touching the message — use it instead of message metadata when a route (like Resend) cannot carry provider metadata.

Add idempotency keys to externally visible sends

Retries and fallbacks mean one logical send can become several provider requests. Give every send a user could notice twice — receipts, password resets, invoices — a stable key derived from your domain:

await email.send(
  {
    from: "Acme <receipts@acme.com>",
    to: "user@example.com",
    subject: "Your receipt",
    text: "Thanks for your order.",
  },
  { idempotencyKey: `receipt:${order.id}` },
);

The key reaches every adapter attempt through the provider context. Resend enforces it natively via its Idempotency-Key header; custom adapters can use it for their own dedupe logic. defaultsPlugin can add an idempotencyKeyPrefix to namespace keys per environment.

Wire up observability

The observability plugin emits email.sent, email.retry, and email.error events with a redacted message — counts, tag names, and the subject, never bodies or recipients. The complete client:

lib/email.ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { defaultsPlugin } from "@opencoredev/email-sdk/plugins/defaults";
import { observabilityPlugin } from "@opencoredev/email-sdk/plugins/observability";
import { postmark } from "@opencoredev/email-sdk/postmark";
import { resend } from "@opencoredev/email-sdk/resend";

export const email = createEmailClient({
  adapters: [
    resend({ apiKey: process.env.RESEND_API_KEY! }),
    postmark({ serverToken: process.env.POSTMARK_SERVER_TOKEN! }),
  ],
  fallback: ["postmark"],
  retry: { retries: 2 },
  plugins: [
    defaultsPlugin({
      replyTo: "Acme Support <support@acme.com>",
      headers: { "X-Service": "checkout" },
      sendMetadata: { service: "checkout" },
    }),
    observabilityPlugin({
      log(event) {
        console.log(event.type, event.provider, event.attempt, event.metadata);
      },
      metric(event) {
        // increment counters: emails_sent_total{provider}, emails_error_total{provider}, ...
      },
    }),
  ],
});

A spike of email.retry followed by email.sent on postmark tells you the fallback route earned its keep. Observer exceptions are swallowed — monitoring can never break a send.

Prove the routing with a test

Rebuild the same topology with testing adapters: a failing primary, a memory backup, and the capture plugin recording lifecycle events.

lib/email.test.ts
import { expect, test } from "bun:test";
import { createEmailClient } from "@opencoredev/email-sdk";
import { capturePlugin } from "@opencoredev/email-sdk/plugins/capture";
import { failingProvider, memoryProvider } from "@opencoredev/email-sdk/testing";

test("falls back to the backup route when the primary fails", async () => {
  const backup = memoryProvider("backup");
  const email = createEmailClient({
    adapters: [failingProvider("primary"), backup],
    fallback: ["backup"],
    retry: { retries: 2, delay: () => 0 },
    plugins: [capturePlugin()],
  });

  const response = await email.send({
    from: "Acme <receipts@acme.com>",
    to: "user@example.com",
    subject: "Your receipt",
    text: "Thanks for your order.",
  });

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

  const types = email.capture.events.map((event) => event.type);
  expect(types).toContain("error"); // primary route failed
  expect(types).toContain("afterSend"); // backup route delivered
});

Run it with bun test. Use the message shape production actually sends — that is what catches a fallback route that cannot carry one of your fields.

Verify from the CLI

Check the environment for both routes, dry-run a representative message, then make one real smoke send:

npx email-sdk doctor --adapter resend
npx email-sdk doctor --adapter postmark
npx email-sdk send \
  --adapter resend \
  --from "Acme <receipts@acme.com>" \
  --to "user@example.com" \
  --subject "Pipeline check" \
  --text "It works" \
  --dry-run

--dry-run validates message shape and adapter field support without a provider request. Drop the flag — once per route, from the environment that will send production email — for the only check that proves the account, sender domain, and recipient policy are ready.

Next

On this page