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



## What this guide builds [#what-this-guide-builds]

This guide wires Email SDK as a production transactional email send pipeline:

1. Choose the message fields your app actually sends.
2. Pick compatible primary and fallback adapters.
3. Create one shared email client.
4. Add retries and fallback routes.
5. Add secret-safe observability.
6. Test the send path without real providers.
7. Verify adapter setup with the CLI.
8. Run one live smoke send from the target environment.

Email SDK does not replace your queue, template system, campaign tool, or provider account setup. It gives your app the send layer: one message shape, explicit routing, validation, retries, fallback, hooks, plugins, tests, and CLI checks.

## 1. Choose the message shape [#1-choose-the-message-shape]

Start by listing the fields your production emails need.

```ts
const receiptMessage = {
  from: "Acme <receipts@acme.com>",
  to: "user@example.com",
  subject: "Receipt",
  text: "Thanks for your order.",
  headers: {
    "X-App": "checkout",
  },
};
```

If a message uses attachments, metadata, tags, reply-to, CC, BCC, or custom headers, choose adapters that can represent those fields. See <a href="/docs/adapters/field-support">Field support</a> before adding a fallback route.

## 2. Pick compatible adapters [#2-pick-compatible-adapters]

For simple text/html transactional email, Resend with SMTP fallback is a practical start.

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

export const simpleEmail = createEmailClient({
  adapters: [
    resend({ apiKey: process.env.RESEND_API_KEY! }),
    smtp({
      host: process.env.SMTP_HOST!,
      port: Number(process.env.SMTP_PORT ?? 587),
      auth: {
        user: process.env.SMTP_USER!,
        pass: process.env.SMTP_PASS!,
      },
    }),
  ],
  defaultAdapter: "resend",
  fallback: ["smtp"],
  retry: {
    retries: 1,
  },
});
```

SMTP is not a safe fallback for every message. It is best for simple address, header, text, and HTML sends.

For attachment-capable transactional email, use another API adapter as backup.

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

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

This route can send attachments through both adapters. Keep message metadata out of this route unless every configured adapter maps it.

## 3. Create the client [#3-create-the-client]

Use one client per send pipeline. Give each adapter a unique routing name when you mount more than one adapter of the same type.

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

export const email = createEmailClient({
  adapters: [
    resend({ apiKey: process.env.RESEND_API_KEY! }),
    smtp({
      name: "backup-smtp",
      host: process.env.SMTP_HOST!,
      port: Number(process.env.SMTP_PORT ?? 587),
      auth: {
        user: process.env.SMTP_USER!,
        pass: process.env.SMTP_PASS!,
      },
    }),
  ],
  defaultAdapter: "resend",
  fallback: ["backup-smtp"],
  retry: {
    retries: 1,
  },
  plugins: [
    defaultsPlugin({
      headers: { "X-App": "checkout" },
      sendMetadata: { service: "checkout" },
    }),
    observabilityPlugin({
      log(event) {
        console.log(event.type, event.provider, event.attempt);
      },
    }),
  ],
});
```

Use `sendMetadata` in `defaultsPlugin` for default hook and observability context on every send. Per-send `metadata` in `email.send(..., { metadata })` is also hook context; it merges with and overrides `sendMetadata` defaults for that send. Only put `metadata` on the `EmailMessage` when every route in the path supports provider message metadata.

## 4. Add retries and fallback [#4-add-retries-and-fallback]

Retries happen inside the current adapter. Fallback happens after that adapter has finally failed.

```ts
await email.send(
  {
    from: "Acme <receipts@acme.com>",
    to: "user@example.com",
    subject: "Receipt",
    text: "Thanks for your order.",
  },
  {
    idempotencyKey: "receipt:order_123",
    metadata: {
      route: "checkout.receipt",
      template: "receipt",
    },
  },
);
```

Use `fallbackAdapters` to override the default fallback for one send.

```ts
await email.send(message, {
  adapter: "resend",
  fallbackAdapters: [],
});
```

An empty fallback list is useful when the default backup route cannot send that message shape.

## 5. Add safe observability [#5-add-safe-observability]

Use hooks or the observability plugin to record route behavior without leaking message content.

```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({
      name: "backup-smtp",
      host: process.env.SMTP_HOST!,
      port: Number(process.env.SMTP_PORT ?? 587),
      auth: {
        user: process.env.SMTP_USER!,
        pass: process.env.SMTP_PASS!,
      },
    }),
  ],
  defaultAdapter: "resend",
  fallback: ["backup-smtp"],
  hooks: {
    beforeSend(event) {
      console.log("email.attempt", event.provider, event.attempt);
    },
    onRetry(event) {
      console.warn("email.retry", event.provider, event.nextAttempt);
    },
    onError(event) {
      console.error("email.error", event.provider, event.error);
    },
    afterSend(event) {
      console.log("email.sent", event.provider, event.response.id);
    },
  },
});
```

Safe logs usually need route name, attempt number, template or route metadata, provider status, and message ID. They usually do not need recipients, full subjects, HTML, text, API keys, SMTP passwords, or raw provider tokens.

## 6. Add tests with memory and capture [#6-add-tests-with-memory-and-capture]

Use `failingProvider()` for the primary route and `memoryProvider()` for the fallback route.

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

describe("receipt email route", () => {
  test("falls back to the backup route", async () => {
    const backup = memoryProvider("backup");
    const email = createEmailClient({
      adapters: [failingProvider("primary"), backup],
      fallback: ["backup"],
      plugins: [capturePlugin()],
    });

    const response = await email.send({
      from: "test@example.com",
      to: "user@example.com",
      subject: "Receipt",
      text: "Thanks.",
    });

    expect(response.provider).toBe("backup");
    expect(backup.raw.sent).toHaveLength(1);
    expect(email.capture.events.map((event) => event.type)).toContain("error");
    expect(email.capture.events.map((event) => event.type)).toContain("afterSend");
  });

  test("captures retry activity", async () => {
    let attempts = 0;
    const retrying = {
      name: "retrying",
      send() {
        attempts += 1;

        if (attempts === 1) {
          throw new EmailProviderError("Temporary failure", {
            provider: "retrying",
            retryable: true,
          });
        }

        return { provider: "retrying", id: "ok" };
      },
    };

    const email = createEmailClient({
      adapters: [retrying],
      retry: { retries: 1, delay: () => 0 },
      plugins: [capturePlugin()],
    });

    const response = await email.send({
      from: "test@example.com",
      to: "user@example.com",
      subject: "Retry",
      text: "Hello",
    });

    expect(response.provider).toBe("retrying");
    expect(email.capture.events.map((event) => event.type)).toContain("retry");
  });
});
```

Test fallback routes with the same message fields your production route uses.

## 7. Verify setup with the CLI [#7-verify-setup-with-the-cli]

List supported adapters:

```bash
npx --yes --package @opencoredev/email-sdk email-sdk adapters
```

Check required configuration for one adapter:

```bash
RESEND_API_KEY="re_..." npx --yes --package @opencoredev/email-sdk email-sdk doctor --adapter resend
```

Validate message shape and adapter field support without sending:

```bash
npx --yes --package @opencoredev/email-sdk email-sdk send \
  --adapter resend \
  --from "Acme <hello@acme.com>" \
  --to "user@example.com" \
  --subject "Hello" \
  --text "It works" \
  --dry-run
```

`--dry-run` does not prove deliverability. It proves that the selected adapter can represent the message shape before making a provider request.

## 8. Run one live smoke send [#8-run-one-live-smoke-send]

Run one live smoke send from the same environment that will send production email. Use an internal test recipient and explicit approval before sending external or user-visible email.

Provider readiness depends on verified senders or domains, API scopes, sandbox mode, recipient allow-lists, regions, rate limits, and provider policy. Email SDK can make the send path explicit; it cannot make a provider account ready.

## Production checklist [#production-checklist]

* The primary adapter and fallback adapters can represent the same required message fields.
* Unsupported provider fields fail before silent data loss.
* Externally visible transactional sends include idempotency keys.
* Retry count and fallback routes are intentional for each send path.
* Observability records route, attempt, retry, success, and error state without secrets or full message bodies.
* Tests cover the normal route, retry behavior, and fallback route with the production message shape.
* CLI `doctor` and `--dry-run` pass before a live smoke send.
* One live smoke send has passed from the target environment.
