Production send pipeline
Combine adapters, validation, retries, fallback routes, observability, tests, and CLI checks.
What this guide builds
This guide wires Email SDK as a production transactional email send pipeline:
- Choose the message fields your app actually sends.
- Pick compatible primary and fallback adapters.
- Create one shared email client.
- Add retries and fallback routes.
- Add secret-safe observability.
- Test the send path without real providers.
- Verify adapter setup with the CLI.
- 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
Start by listing the fields your production emails need.
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 Field support before adding a fallback route.
2. Pick compatible adapters
For simple text/html transactional email, Resend with SMTP fallback is a practical start.
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.
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
Use one client per send pipeline. Give each adapter a unique routing name when you mount more than one adapter of the same type.
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
Retries happen inside the current adapter. Fallback happens after that adapter has finally failed.
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.
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
Use hooks or the observability plugin to record route behavior without leaking message content.
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
Use failingProvider() for the primary route and memoryProvider() for the fallback route.
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
List supported adapters:
npx --yes --package @opencoredev/email-sdk email-sdk adaptersCheck required configuration for one adapter:
RESEND_API_KEY="re_..." npx --yes --package @opencoredev/email-sdk email-sdk doctor --adapter resendValidate message shape and adapter field support without sending:
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
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
- 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
doctorand--dry-runpass before a live smoke send. - One live smoke send has passed from the target environment.
