# Production send pipeline (/docs/v/0.6.1/guides/production-send-pipeline)



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](/docs/v/0.6.1/getting-started/quickstart). Every step keeps your application code unchanged — `email.send(...)` stays the only call sites know about.

<Steps>
  <Step>
    ### Start with the primary adapter [#start-with-the-primary-adapter]

    One shared client, one adapter. These docs use Resend; any [adapter](/docs/v/0.6.1/adapters) slots in the same way.

    ```ts title="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! })],
    });
    ```
  </Step>

  <Step>
    ### Add retries for transient failures [#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:

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

    Only [retryable errors](/docs/v/0.6.1/reference/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](/docs/v/0.6.1/concepts/fallbacks-and-retries) for the full retry config, including custom `delay` and `shouldRetry`.
  </Step>

  <Step>
    ### Add a compatible fallback route [#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](/docs/v/0.6.1/adapters/field-support) first. Postmark pairs well with Resend here: both carry CC, BCC, reply-to, headers, attachments, and tags.

    ```ts title="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.
  </Step>

  <Step>
    ### Apply org-wide defaults [#apply-org-wide-defaults]

    The [defaults plugin](/docs/v/0.6.1/plugins/built-in/defaults) merges shared values into every message before validation, so individual call sites stay minimal:

    ```ts title="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.
  </Step>

  <Step>
    ### Add idempotency keys to externally visible sends [#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:

    ```ts
    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.
  </Step>

  <Step>
    ### Wire up observability [#wire-up-observability]

    The [observability plugin](/docs/v/0.6.1/plugins/built-in/observability) 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:

    ```ts title="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.
  </Step>

  <Step>
    ### Prove the routing with a test [#prove-the-routing-with-a-test]

    Rebuild the same topology with [testing adapters](/docs/v/0.6.1/guides/test-email-behavior): a failing primary, a memory backup, and the [capture plugin](/docs/v/0.6.1/plugins/built-in/capture) recording lifecycle events.

    ```ts title="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.
  </Step>

  <Step>
    ### Verify from the CLI [#verify-from-the-cli]

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

    ```bash
    npx email-sdk doctor --adapter resend
    npx email-sdk doctor --adapter postmark
    ```

    ```bash
    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.
  </Step>
</Steps>

## Next [#next]

<Cards>
  <Card title="Fallbacks and retries" href="/docs/v/0.6.1/concepts/fallbacks-and-retries" description="Route resolution, backoff tuning, error classification, and per-send overrides." />

  <Card title="Test email behavior" href="/docs/v/0.6.1/guides/test-email-behavior" description="More recipes: assert fields, retries, and shared capture stores." />

  <Card title="Built-in plugins" href="/docs/v/0.6.1/plugins" description="Everything defaultsPlugin, observabilityPlugin, and capturePlugin can do." />

  <Card title="CLI reference" href="/docs/v/0.6.1/reference/cli" description="All doctor, send, and dry-run flags." />
</Cards>
