# Brevo (/docs/adapters/brevo)
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { brevo } from "@opencoredev/email-sdk/brevo";
const email = createEmailClient({
adapters: [brevo({ apiKey: process.env.BREVO_API_KEY! })],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | -------------- | -------- | ------------------------------------ |
| `apiKey` | `string` | Yes | Brevo API key. |
| `baseUrl` | `string` | No | Defaults to `https://api.brevo.com`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
See field support for headers, tags, metadata, and attachments.
# Cloudflare Email Sending (/docs/adapters/cloudflare)
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { cloudflare } from "@opencoredev/email-sdk/cloudflare";
const email = createEmailClient({
adapters: [
cloudflare({
apiToken: process.env.CLOUDFLARE_API_TOKEN!,
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
}),
],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| ----------- | -------------- | -------- | --------------------------------------------------------- |
| `apiToken` | `string` | Yes | Cloudflare API token with Email Sending permission. |
| `accountId` | `string` | Yes | Cloudflare account ID used in the Email Sending endpoint. |
| `baseUrl` | `string` | No | Defaults to `https://api.cloudflare.com/client/v4`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
## CLI [#cli]
```bash
npx --yes --package @opencoredev/email-sdk email-sdk send \
--adapter cloudflare \
--api-token "$CLOUDFLARE_API_TOKEN" \
--account-id "$CLOUDFLARE_ACCOUNT_ID" \
--from "hello@example.com" \
--to "user@example.com" \
--subject "Hello" \
--text "It works"
```
Cloudflare Email Sending requires a Cloudflare DNS domain onboarded to Email Service. New accounts may be limited to verified recipient addresses, and Email Sending is available on Workers Paid plans.
Cloudflare's REST API accepts `to`, `cc`, and `bcc` as plain email-address strings. The adapter preserves display names for `from` and `replyTo`, and rejects named recipients before the request instead of silently dropping the names.
See field support for supported message fields.
# SDK field support (/docs/adapters/field-support)
Email SDK keeps one message shape, but email APIs do not all expose the same features. This page documents what each Email SDK adapter maps today. Stable adapters either map a field or reject it with a validation error. They should not silently drop message data.
## How to read this page [#how-to-read-this-page]
* `Yes` means the adapter maps that `EmailMessage` field.
* `No` means the adapter rejects that field before calling the provider.
* `Values` means Email SDK sends each tag's value to the provider.
* `One tag` means only one tag can be represented by that provider API. Sending more than one tag fails before the provider request.
## Choose routes by message shape [#choose-routes-by-message-shape]
Fallback routes are only safe when the backup adapter can represent the same message fields that matter to your app. Choose primary and backup routes from the fields your production messages use, not just from provider popularity.
| Need | Start with |
| -------------------------------- | --------------------------------------------------------- |
| Most complete API field mapping | Postmark, SendGrid, AWS SES, Mailgun, Brevo |
| Resend-style DX with attachments | Resend |
| Cheap or self-managed delivery | SMTP |
| Product/event email | Loops, Plunk |
| Provider fallback for production | Resend plus SMTP, or one primary API plus Postmark |
| Attachment-heavy transactional | Resend, Postmark, SendGrid, Mailgun, MailerSend, Mailtrap |
If you are unsure, start with Resend for the first send, then choose fallback routes from this table based on the exact fields your app sends. Do not add a fallback adapter until it can represent the same message shape as your primary route.
## Fallback compatibility checklist [#fallback-compatibility-checklist]
* Does the backup adapter support every `EmailMessage` field your message uses?
* Does the backup preserve attachments when receipts, exports, or files matter?
* Does the backup preserve metadata or tags if your app uses them for provider dashboards, analytics, or routing?
* Does the backup support reply-to and headers if support workflows depend on them?
* Has the backup provider account been live-verified in the target environment?
## API adapters [#api-adapters]
These adapters cover most transactional email fields. They are the best fit when your app needs CC, BCC, reply-to, custom headers, tags, metadata, or attachments.
| Adapter | CC | BCC | Reply-To | Headers | Metadata | Tags | Attachments |
| ----------------------- | --- | --- | -------- | ------- | -------- | ------- | ----------- |
| Resend | Yes | Yes | Yes | Yes | No | Yes | Yes |
| Postmark | Yes | Yes | Yes | Yes | Yes | One tag | Yes |
| SendGrid | Yes | Yes | Yes | Yes | Yes | Values | Yes |
| Cloudflare | Yes | Yes | Yes | Yes | No | No | Yes |
| AWS SES | Yes | Yes | Yes | Yes | No | Yes | Yes |
| Mailgun | Yes | Yes | Yes | Yes | Yes | Values | Yes |
| MailerSend | Yes | Yes | Yes | Yes | No | Values | Yes |
| Brevo | Yes | Yes | Yes | Yes | Yes | Values | Yes |
| Mailchimp Transactional | Yes | Yes | No | Yes | Yes | Values | Yes |
| Mailtrap | Yes | Yes | Yes | Yes | Yes | One tag | Yes |
## Narrow adapters [#narrow-adapters]
These adapters are useful, but their public APIs do not match the full `EmailMessage` shape. Email SDK keeps them stable by rejecting fields it cannot represent.
| Adapter | CC | BCC | Reply-To | Headers | Metadata | Tags | Attachments |
| --------- | --- | --- | -------- | ------- | -------- | ------ | ----------- |
| SparkPost | No | No | Yes | Yes | Yes | Values | Yes |
| Loops | No | No | No | No | Yes | No | Yes |
| Plunk | No | No | Yes | Yes | Yes | No | Yes |
| Scaleway | Yes | Yes | Yes | Yes | No | No | Yes |
| ZeptoMail | Yes | Yes | Yes | No | No | No | Yes |
| MailPace | Yes | Yes | Yes | No | No | No | No |
## SMTP transport [#smtp-transport]
SMTP is built in and does not use Nodemailer. It maps address fields and headers directly to the SMTP message, but it does not map provider-only concepts like tags or metadata.
| Adapter | CC | BCC | Reply-To | Headers | Metadata | Tags | Attachments |
| ------- | --- | --- | -------- | ------- | -------- | ---- | ----------- |
| SMTP | Yes | Yes | Yes | Yes | No | No | No |
## Validation behavior [#validation-behavior]
Unsupported fields fail fast. For example, Resend rejects `metadata`, Mailchimp Transactional rejects `replyTo`, and SMTP rejects attachments. Fail-fast validation protects fallback routes from silent data loss: if a field matters, you find out before the provider request.
Loops attachments map to the provider's transactional attachments payload. Loops documents that transactional attachments require support enablement on the account, so accounts without that enablement should omit `attachments`.
The SDK's local checks are not a live deliverability guarantee. Provider accounts still need verified senders or domains, the correct region, API scopes, and any sandbox or recipient allow-list configuration required by that service.
## Attachment rules [#attachment-rules]
Attachment `content` is treated as raw content by default and encoded to Base64 for API adapters that require Base64.
```ts
attachments: [
{
filename: "receipt.txt",
content: "Thanks for your order.",
contentType: "text/plain",
},
];
```
If you already have Base64 content, mark it:
```ts
attachments: [
{
filename: "receipt.pdf",
content: base64Pdf,
contentEncoding: "base64",
contentType: "application/pdf",
},
];
```
Adapters that support attachments can also read a local path:
```ts
attachments: [
{
filename: "receipt.pdf",
path: "./receipt.pdf",
contentType: "application/pdf",
},
];
```
# Adapters (/docs/adapters)
Email SDK ships with 17 adapters. Import only the adapter you use; the package does not ask you
to configure providers that are not on your send path.
Every adapter follows the same contract: map the fields it supports and throw a validation error for
fields the provider API cannot represent. Use the SDK field support guide
before choosing fallback routes.
## Which adapter should I start with? [#which-adapter-should-i-start-with]
| If you want... | Start with |
| -------------------------------------- | --------------------------------------------------------- |
| The fastest first send | Resend |
| Mature transactional delivery controls | Postmark, SendGrid, AWS SES, Mailgun, or Brevo |
| A backup route for production delivery | A primary API adapter plus Postmark or SMTP |
| Product-triggered emails | Loops or Plunk |
| A cheap or self-managed transport | SMTP |
| Heavy attachment support | Resend, Postmark, SendGrid, Mailgun, MailerSend, Mailtrap |
## Want to build a community adapter? [#want-to-build-a-community-adapter]
Do not add third-party provider experiments to the official package by default. Publish a separate community package, export an adapter plugin, and list it in the community registry if you want discoverability.
## What the status labels mean [#what-the-status-labels-mean]
Provider cards use three labels:
* `Official` means the adapter ships in `@opencoredev/email-sdk`.
* `Payload-tested` means repository tests cover request mapping, responses, and fail-fast validation with injected fetch calls.
* `Built-in transport` means the adapter sends through Email SDK's own transport implementation instead of a provider API.
* `Live account required` means final delivery still depends on provider setup: verified domains, sender identities, API scopes, sandbox settings, regions, rate limits, and provider policy.
* `SMTP server required` means final delivery depends on the SMTP host, credentials, and TLS settings you provide.
Email SDK can catch unsupported fields before a request. It cannot make a provider account ready to send from an unverified domain.
## Import pattern [#import-pattern]
```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!,
},
}),
],
fallback: ["smtp"],
});
```
## Adapter groups [#adapter-groups]
| Group | Adapters |
| -------------- | ------------------------------------------------------------------------------- |
| Popular APIs | Resend, Postmark, SendGrid, Mailgun, MailerSend, Brevo, Mailchimp Transactional |
| Infrastructure | Cloudflare Email Sending, SparkPost, Mailtrap, Scaleway, ZeptoMail, MailPace |
| Product-led | Loops, Plunk |
| Transport | Built-in SMTP |
# Loops (/docs/adapters/loops)
Loops transactional sends require a `transactionalId`.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { loops } from "@opencoredev/email-sdk/loops";
const email = createEmailClient({
adapters: [
loops({
apiKey: process.env.LOOPS_API_KEY!,
transactionalId: process.env.LOOPS_TRANSACTIONAL_ID!,
}),
],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| ----------------- | -------------- | -------- | ------------------------------------ |
| `apiKey` | `string` | Yes | Loops API key. |
| `transactionalId` | `string` | Yes | Transactional email ID. |
| `baseUrl` | `string` | No | Defaults to `https://app.loops.so`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
Loops transactional sends support one recipient, metadata through `dataVariables`, and attachments through the transactional attachments payload. Loops documents that attachments require support enablement on the account, so accounts without that enablement should omit `attachments`. Unsupported fields throw before the API call.
# Mailchimp Transactional (/docs/adapters/mailchimp)
Mailchimp Transactional is the API formerly known as Mandrill.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { mailchimp } from "@opencoredev/email-sdk/mailchimp";
const email = createEmailClient({
adapters: [mailchimp({ apiKey: process.env.MAILCHIMP_API_KEY! })],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | -------------- | -------- | ---------------------------------------------- |
| `apiKey` | `string` | Yes | Mailchimp Transactional API key. |
| `baseUrl` | `string` | No | Defaults to `https://mandrillapp.com/api/1.0`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
See field support for supported message fields.
# MailerSend (/docs/adapters/mailersend)
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { mailersend } from "@opencoredev/email-sdk/mailersend";
const email = createEmailClient({
adapters: [mailersend({ apiKey: process.env.MAILERSEND_API_KEY! })],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | -------------- | -------- | ----------------------------------------- |
| `apiKey` | `string` | Yes | MailerSend API token. |
| `baseUrl` | `string` | No | Defaults to `https://api.mailersend.com`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
See field support for supported message fields.
MailerSend custom headers map to `headers`. MailerSend documents custom headers as available on Professional and Enterprise accounts, so lower-plan accounts may need to omit `headers`.
# Mailgun (/docs/adapters/mailgun)
## Before live sends [#before-live-sends]
Create a Mailgun API key for the sending domain and use the base URL for the domain region. EU domains need the EU API base URL; US domains use the default. Verify DNS and sender setup before treating a successful SDK call as production-ready delivery.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { mailgun } from "@opencoredev/email-sdk/mailgun";
const email = createEmailClient({
adapters: [
mailgun({
apiKey: process.env.MAILGUN_API_KEY!,
domain: process.env.MAILGUN_DOMAIN!,
}),
],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | -------------- | -------- | -------------------------------------------------------------------------------------------------- |
| `apiKey` | `string` | Yes | Mailgun API key. |
| `domain` | `string` | Yes | Sending domain. |
| `baseUrl` | `string` | No | Defaults to `https://api.mailgun.net`. Use the EU API base URL if your domain is in the EU region. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
Mailgun sends attachments as multipart form data. See field support for the full mapping.
# MailPace (/docs/adapters/mailpace)
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { mailpace } from "@opencoredev/email-sdk/mailpace";
const email = createEmailClient({
adapters: [mailpace({ apiKey: process.env.MAILPACE_API_KEY! })],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | -------------- | -------- | ---------------------------------------------- |
| `apiKey` | `string` | Yes | MailPace server token. |
| `baseUrl` | `string` | No | Defaults to `https://app.mailpace.com/api/v1`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
MailPace supports CC, BCC, and reply-to. Unsupported fields throw before the API call.
# Mailtrap (/docs/adapters/mailtrap)
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { mailtrap } from "@opencoredev/email-sdk/mailtrap";
const email = createEmailClient({
adapters: [mailtrap({ apiKey: process.env.MAILTRAP_API_KEY! })],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | -------------- | -------- | ------------------------------------------- |
| `apiKey` | `string` | Yes | Mailtrap API token. |
| `baseUrl` | `string` | No | Defaults to `https://send.api.mailtrap.io`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
See field support for supported message fields.
Mailtrap maps `replyTo` to `reply_to`, metadata to `custom_variables`, and the first returned `message_ids` value to the SDK response ID.
# Plunk (/docs/adapters/plunk)
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { plunk } from "@opencoredev/email-sdk/plunk";
const email = createEmailClient({
adapters: [plunk({ apiKey: process.env.PLUNK_API_KEY! })],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | -------------- | -------- | -------------------------------------------- |
| `apiKey` | `string` | Yes | Plunk API key. |
| `baseUrl` | `string` | No | Defaults to `https://next-api.useplunk.com`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
Plunk maps metadata to `data`, `replyTo` to `reply`, custom headers to `headers`, attachments to `attachments`, and the first returned `data.emails[].email` value to the SDK response ID. Unsupported fields throw before the API call.
# Postmark (/docs/adapters/postmark)
The Postmark adapter calls the Postmark Email API directly. It does not add a runtime dependency.
## Before live sends [#before-live-sends]
Create a Postmark server token for the server and message stream you want to send through. Make sure the sender signature or domain is approved in Postmark before using a production `from` address.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { postmark } from "@opencoredev/email-sdk/postmark";
const email = createEmailClient({
adapters: [
postmark({
serverToken: process.env.POSTMARK_SERVER_TOKEN!,
messageStream: "outbound",
}),
],
});
```
## Send [#send]
```ts
await email.send({
from: "Acme ",
to: "user@example.com",
subject: "Receipt",
html: "Thanks for your order.
",
metadata: {
orderId: "ord_123",
},
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------------- | ------------------------ | -------- | ------------------------------------------ |
| `serverToken` | `string` | Yes | Postmark server token. |
| `baseUrl` | `string` | No | Defaults to `https://api.postmarkapp.com`. |
| `messageStream` | `string` | No | Postmark message stream. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
| `headers` | `Record` | No | Extra request headers. |
## Response [#response]
The adapter maps Postmark `MessageID` to `id` and `messageId`.
## CLI smoke test [#cli-smoke-test]
```bash
POSTMARK_SERVER_TOKEN="..." npx --yes --package @opencoredev/email-sdk email-sdk send \
--adapter postmark \
--from "Acme " \
--to user@example.com \
--subject "Postmark test" \
--text "It works"
```
# Resend (/docs/adapters/resend)
The Resend adapter calls the Resend Email API directly. It does not add a runtime dependency.
## Before live sends [#before-live-sends]
Create a Resend API key and verify the sender domain or sender address you plan to use in `from`. A dry run can validate the Email SDK message shape, but only a live send from your target environment proves the Resend account, sender, and recipient policy are ready.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
});
```
## Send [#send]
```ts
await email.send(
{
from: "Acme ",
to: "user@example.com",
subject: "Welcome",
html: "Your workspace is ready.
",
tags: [{ name: "type", value: "welcome" }],
},
{
idempotencyKey: "welcome:user_123",
},
);
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | ------------------------ | -------- | ------------------------------------- |
| `apiKey` | `string` | Yes | Resend API key. |
| `baseUrl` | `string` | No | Defaults to `https://api.resend.com`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
| `headers` | `Record` | No | Extra request headers. |
## Response [#response]
The adapter returns the Resend `id` as `id` and `messageId`.
## CLI smoke test [#cli-smoke-test]
```bash
RESEND_API_KEY="re_..." npx --yes --package @opencoredev/email-sdk email-sdk send \
--adapter resend \
--from "Acme " \
--to user@example.com \
--subject "Resend test" \
--text "It works"
```
# Scaleway (/docs/adapters/scaleway)
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { scaleway } from "@opencoredev/email-sdk/scaleway";
const email = createEmailClient({
adapters: [
scaleway({
secretKey: process.env.SCALEWAY_SECRET_KEY!,
projectId: process.env.SCALEWAY_PROJECT_ID!,
}),
],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| ----------- | -------------- | -------- | --------------------------------------- |
| `secretKey` | `string` | Yes | Scaleway secret key. |
| `projectId` | `string` | Yes | Scaleway project ID. |
| `region` | `string` | No | Defaults to `fr-par`. |
| `baseUrl` | `string` | No | Defaults to `https://api.scaleway.com`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
Scaleway maps address objects, CC/BCC, attachments, custom headers, and `replyTo` through the Transactional Email REST API. Unsupported fields throw before the API call.
# SendGrid (/docs/adapters/sendgrid)
## Before live sends [#before-live-sends]
Create a SendGrid API key with Mail Send permission and verify the sender identity or domain used in `from`. SendGrid account review, sandbox mode, or sender verification can block delivery even when the SDK payload validates.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { sendgrid } from "@opencoredev/email-sdk/sendgrid";
const email = createEmailClient({
adapters: [sendgrid({ apiKey: process.env.SENDGRID_API_KEY! })],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | -------------- | -------- | --------------------------------------- |
| `apiKey` | `string` | Yes | SendGrid API key. |
| `baseUrl` | `string` | No | Defaults to `https://api.sendgrid.com`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
See field support for headers, tags, metadata, and attachments.
## CLI [#cli]
```bash
SENDGRID_API_KEY="SG..." npx --yes --package @opencoredev/email-sdk email-sdk send --adapter sendgrid --from hello@example.com --to user@example.com --subject "Hello" --text "It works"
```
# AWS SES (/docs/adapters/ses)
The AWS SES adapter calls the SES v2 SendEmail API directly with SigV4-signed fetch requests. It does not add a runtime dependency on the AWS SDK.
## Before live sends [#before-live-sends]
Use credentials that can call SES v2 `SendEmail` in the selected region. Verify the sender identity in that same region, and confirm whether the AWS account is still in the SES sandbox before sending to arbitrary recipients.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { ses } from "@opencoredev/email-sdk/ses";
const email = createEmailClient({
adapters: [
ses({
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
sessionToken: process.env.AWS_SESSION_TOKEN,
region: process.env.AWS_REGION!,
}),
],
});
```
## Send [#send]
```ts
await email.send({
from: "Acme ",
to: "user@example.com",
subject: "Welcome",
html: "Your workspace is ready.
",
tags: [{ name: "type", value: "welcome" }],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| ---------------------- | -------------- | -------- | ----------------------------------------------------- |
| `accessKeyId` | `string` | Yes | AWS access key ID. |
| `secretAccessKey` | `string` | Yes | AWS secret access key. |
| `region` | `string` | Yes | SES region, for example `us-east-1`. |
| `sessionToken` | `string` | No | Required for temporary credentials. |
| `baseUrl` | `string` | No | Defaults to `https://email.{region}.amazonaws.com`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
| `charset` | `string` | No | Defaults to `UTF-8`. |
| `configurationSetName` | `string` | No | SES configuration set for sends through this adapter. |
## CLI [#cli]
```bash
AWS_ACCESS_KEY_ID=... \
AWS_SECRET_ACCESS_KEY=... \
AWS_REGION=us-east-1 \
npx --yes --package @opencoredev/email-sdk email-sdk send --adapter ses \
--from "Acme " \
--to user@example.com \
--subject "Welcome" \
--html "Your workspace is ready.
"
```
## Response [#response]
The adapter returns the SES `MessageId` as `id` and `messageId`.
# SMTP (/docs/adapters/smtp)
The SMTP adapter is built in. It uses Node/Bun TCP and TLS sockets directly, so it does not need Nodemailer.
When `auth` is configured with `secure: false`, the adapter sends `STARTTLS` before authenticating. Set `allowInsecureAuth: true` only for a trusted local server that does not support TLS.
## Before live sends [#before-live-sends]
Confirm the SMTP host, port, TLS mode, username, and password with your provider. SMTP is a good fallback for simple transactional messages, but it does not support provider-only fields such as tags, metadata, or attachments.
## Configure [#configure]
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { smtp } from "@opencoredev/email-sdk/smtp";
const email = createEmailClient({
adapters: [
smtp({
host: "smtp.purelymail.com",
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
}),
],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| ------------------- | -------------------------------- | -------- | ----------------------------------------- |
| `host` | `string` | Yes | SMTP host. |
| `port` | `number` | No | Common values are `587`, `465`, and `25`. |
| `secure` | `boolean` | No | Use TLS from the start of the connection. |
| `auth` | `{ user: string; pass: string }` | No | SMTP credentials. |
| `defaults` | `{ replyTo?: string }` | No | Default reply-to header. |
| `tls` | `Record` | No | TLS options. |
| `requireTLS` | `boolean` | No | Require STARTTLS. |
| `allowInsecureAuth` | `boolean` | No | Opt in to SMTP AUTH without TLS. |
| `name` | `string` | No | Defaults to `smtp`. |
| `heloName` | `string` | No | EHLO name sent to the server. |
| `timeoutMs` | `number` | No | Socket timeout. |
## Multiple SMTP routes [#multiple-smtp-routes]
Rename adapters when you have multiple SMTP routes.
```ts
const email = createEmailClient({
adapters: [
smtp({ name: "purelymail", host: "smtp.purelymail.com" }),
smtp({ name: "backup-smtp", host: "smtp.backup.example" }),
],
defaultAdapter: "purelymail",
fallback: ["backup-smtp"],
});
```
## CLI [#cli]
```bash
npx --yes --package @opencoredev/email-sdk email-sdk send \
--adapter smtp \
--host smtp.purelymail.com \
--user hello@example.com \
--pass "$SMTP_PASS" \
--from "Acme " \
--to user@example.com \
--subject "SMTP test" \
--text "It works"
```
# SparkPost (/docs/adapters/sparkpost)
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { sparkpost } from "@opencoredev/email-sdk/sparkpost";
const email = createEmailClient({
adapters: [sparkpost({ apiKey: process.env.SPARKPOST_API_KEY! })],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | -------------- | -------- | ----------------------------------------------- |
| `apiKey` | `string` | Yes | SparkPost API key. |
| `baseUrl` | `string` | No | Defaults to `https://api.sparkpost.com/api/v1`. |
| `sandbox` | `boolean` | No | Enables SparkPost sandbox mode. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
SparkPost does not expose normalized CC/BCC in this adapter. See field support.
# ZeptoMail (/docs/adapters/zeptomail)
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { zeptomail } from "@opencoredev/email-sdk/zeptomail";
const email = createEmailClient({
adapters: [zeptomail({ token: process.env.ZEPTOMAIL_TOKEN! })],
});
```
## Options [#options]
| Option | Type | Required | Notes |
| --------- | -------------- | -------- | ----------------------------------------------------------------------------- |
| `token` | `string` | Yes | ZeptoMail API token. The adapter adds the `Zoho-enczapikey` prefix if needed. |
| `baseUrl` | `string` | No | Defaults to `https://api.zeptomail.com`. |
| `fetch` | `typeof fetch` | No | Useful for tests or custom runtimes. |
ZeptoMail supports recipients, reply-to, and attachments. Unsupported fields throw before the API call.
# Agent skill (/docs/agents/skill)
This repo includes an agent skill at:
```txt
skills/email-sdk/SKILL.md
```
Install it with the skills.sh CLI:
```bash
npx skills add opencoredev/email-sdk --skill email-sdk
```
Use it when an agent wires Email SDK into an app, reviews adapter setup, or updates these docs.
## What it tells agents [#what-it-tells-agents]
* Use `bun` and `bunx`.
* Refresh the current README, package version, package exports, Fumadocs pages, and TypeScript declarations before implementing.
* Keep adapter credentials in environment variables.
* Import adapters from their own entry points.
* Do not add Nodemailer for SMTP; Email SDK includes its own SMTP transport.
* Use fallbacks only when the backup adapter can send the same class of email.
* Add idempotency keys for externally visible transactional sends.
* Run a narrow validation before calling the work done.
## Prompt example [#prompt-example]
```txt
Use the repo-local Email SDK skill at skills/email-sdk/SKILL.md.
Wire Resend as the primary adapter and SMTP as fallback.
Keep secrets in environment variables.
Add one narrow test around the send path.
```
The skill is dynamic on purpose. It teaches agents where to fetch the current docs and source first, so the skill does not need a manual update every time the SDK gains another adapter or option.
# Adapter model (/docs/concepts/adapter-model)
An adapter is an Email SDK module that knows how to send through one service or transport.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
import { postmark } from "@opencoredev/email-sdk/postmark";
const email = createEmailClient({
adapters: [
resend({ apiKey: process.env.RESEND_API_KEY! }),
postmark({ serverToken: process.env.POSTMARK_SERVER_TOKEN! }),
],
});
```
## Routing names [#routing-names]
Each adapter registers a routing name. Email SDK uses that name for defaults, fallbacks, and per-send overrides.
| Adapter | Routing name |
| ----------------------- | ------------ |
| Resend | `resend` |
| SMTP | `smtp` |
| Postmark | `postmark` |
| SendGrid | `sendgrid` |
| Cloudflare | `cloudflare` |
| AWS SES | `ses` |
| Mailgun | `mailgun` |
| MailerSend | `mailersend` |
| Brevo | `brevo` |
| Mailchimp Transactional | `mailchimp` |
| SparkPost | `sparkpost` |
| Loops | `loops` |
| Plunk | `plunk` |
| Mailtrap | `mailtrap` |
| Scaleway | `scaleway` |
| ZeptoMail | `zeptomail` |
| MailPace | `mailpace` |
## Send with one adapter [#send-with-one-adapter]
```ts
await email.send(message, {
adapter: "postmark",
});
```
## Adapters are route names [#adapters-are-route-names]
Adapter names are the route identifiers used by `defaultAdapter`, per-send `adapter`, client-level `fallback`, per-send `fallbackAdapters`, and `withAdapter`.
Use Fallbacks and retries to understand route order. Use Field support before choosing backup routes.
## Bind one adapter [#bind-one-adapter]
```ts
const transactional = email.withAdapter("postmark");
await transactional.send(message);
```
## Use adapter escape hatches [#use-adapter-escape-hatches]
Every adapter can expose `raw`. Keep provider-specific code close to the adapter and keep normal application code on the shared `send` path.
```ts
const adapter = email.adapter("resend");
console.log(adapter.raw);
```
The older `provider`, `defaultProvider`, `email.provider()`, and `email.withProvider()` names still work as aliases.
# Fallbacks and retries (/docs/concepts/fallbacks-and-retries)
## The short version [#the-short-version]
Retries happen inside the currently selected adapter. Fallbacks happen after that adapter has finally failed.
Use retries for transient failures on the same route, such as rate limits or temporary provider errors. Use fallbacks when another compatible adapter should try the same transactional email after the selected adapter fails.
Fallback routes are only safe when the backup adapter can represent the same message fields that matter to your app. Check Field support before adding a backup route.
## Execution order [#execution-order]
For one `send` call, Email SDK:
1. Runs `beforeSend` middleware from plugins.
2. Validates the normalized message shape.
3. Builds route order from the selected adapter and fallback adapters.
4. Tries the selected adapter.
5. Retries that adapter when retry rules allow it.
6. Moves to the next fallback adapter after final failure.
7. Returns the first successful normalized response.
8. Throws if every attempted route fails.
The route order is the selected adapter, followed by each fallback adapter. Duplicate route names are ignored.
If a route name is not registered, Email SDK throws `EmailProviderNotFoundError` immediately for that route.
## Configure a default fallback route [#configure-a-default-fallback-route]
This route sends through Resend first. If Resend fails, the SDK tries SMTP.
```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: "smtp.purelymail.com",
port: 587,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
}),
],
retry: {
retries: 1,
},
fallback: ["smtp"],
});
```
In this setup, Email SDK tries Resend first. If Resend fails with a retryable error, it retries Resend once. If Resend still fails, Email SDK tries SMTP.
## Override routes per send [#override-routes-per-send]
Use `adapter` to choose the primary route for one send. Use `fallbackAdapters` to replace the client-level `fallback` list for that send.
```ts
await email.send(message, {
adapter: "resend",
fallbackAdapters: ["smtp"],
});
```
`provider` and `fallbackProviders` are aliases for `adapter` and `fallbackAdapters`.
Disable a default fallback for one send by passing an empty fallback list:
```ts
await email.send(message, {
adapter: "resend",
fallbackAdapters: [],
});
```
Use this when a message includes fields that the default backup route cannot represent.
## Recommended fallback routes [#recommended-fallback-routes]
Choose fallback routes by message shape, not by provider popularity.
| Message type | Good fallback | Avoid |
| ----------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| Plain text/html transactional email | Resend -> SMTP, Resend -> Postmark | Product-email adapters (Loops, Plunk) that do not preserve the same class of email |
| Attachments | Resend -> Postmark, SendGrid, Mailgun, MailerSend | SMTP, Loops, Plunk, Scaleway, MailPace |
| Metadata-dependent sends | Postmark/SendGrid/Mailgun/Brevo pairs | Resend or SMTP if metadata matters |
| Tag-heavy sends | SendGrid/Mailgun/Brevo/MailerSend pairs | Postmark when more than one tag matters |
| Product/event template sends | Loops or Plunk primary, with an explicitly equivalent route | Generic SMTP fallback that changes the template semantics |
| Simple backup transport | Primary API -> SMTP | SMTP for attachments, metadata, or tags |
## Unsafe fallback routes [#unsafe-fallback-routes]
Do not treat SMTP as a universal backup. SMTP is useful for simple transactional messages, but it does not map provider-only fields like tags or metadata, and this SDK's SMTP transport does not send attachments.
Do not fall back from a rich API adapter to a narrow product-email adapter unless that adapter sends the same class of email. Loops and Plunk are useful product/event adapters, but they are not generic backups for messages with CC, BCC, reply-to, headers, tags, or attachments.
Do not rely on fallback to fix an incompatible message shape. Adapter-level unsupported-field validation may fail during an adapter attempt. If the next route drops or cannot represent fields your app depends on, the fallback route is not safe.
## Attachment-capable fallback [#attachment-capable-fallback]
Use a second API adapter when attachments matter.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { postmark } from "@opencoredev/email-sdk/postmark";
import { resend } from "@opencoredev/email-sdk/resend";
const email = createEmailClient({
adapters: [
resend({ apiKey: process.env.RESEND_API_KEY! }),
postmark({ serverToken: process.env.POSTMARK_SERVER_TOKEN! }),
],
defaultAdapter: "resend",
fallback: ["postmark"],
retry: {
retries: 1,
},
});
await email.send(
{
from: "Acme ",
to: "user@example.com",
subject: "Receipt",
text: "Thanks for your order.",
attachments: [
{
filename: "receipt.txt",
content: "Order #123",
contentType: "text/plain",
},
],
},
{
idempotencyKey: "receipt:order_123",
metadata: {
route: "checkout.receipt",
},
},
);
```
This example keeps the send metadata in `send` options for hooks and observability. It does not put metadata on the `EmailMessage`, because Resend does not map message metadata.
## How retries work [#how-retries-work]
The client-level `retry` option applies to each adapter attempt path. The send-level `retries` option overrides the client-level retry count for that send.
```ts
await email.send(message, {
retries: 2,
});
```
Fetch-based adapters mark these HTTP responses as retryable:
| Status | Meaning |
| ------ | ------------------------------- |
| `408` | Request timeout |
| `409` | Conflict |
| `425` | Too early |
| `429` | Rate limited |
| `5xx` | Adapter or network-side failure |
Some network-style runtime failures can also be retryable. Programming errors and unrelated runtime errors should fail instead of being retried.
## What causes fallback [#what-causes-fallback]
Fallback is not only for retryable errors. Retryability controls whether Email SDK should retry the same adapter. Fallback controls whether Email SDK should try the next adapter after the current adapter has finally failed.
If every attempted adapter fails, Email SDK throws the single failure when only one route was attempted. When multiple routes fail, it throws an SDK-level error with code `all_providers_failed` and the collected failures in `details`.
## Idempotency [#idempotency]
Use an idempotency key for externally visible email that may be retried or sent through fallback routes.
```ts
await email.send(message, {
idempotencyKey: "receipt:order_123",
});
```
Adapters that support idempotency receive the key through provider context. Provider support still depends on the provider API.
## Observability [#observability]
Hooks can show which route was attempted, which retry was scheduled, which route failed, and which route finally succeeded.
```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!,
},
}),
],
fallback: ["smtp"],
retry: { retries: 1 },
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);
},
},
});
```
Do not log API keys, SMTP passwords, raw tokens, full message bodies, or unnecessary recipient data.
## Testing fallback behavior [#testing-fallback-behavior]
Use `memoryProvider()` for the successful backup route and `failingProvider()` for the primary route.
```ts
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: "test@example.com",
to: "user@example.com",
subject: "Fallback",
text: "Hello",
});
expect(response.provider).toBe("backup");
expect(backup.raw.sent).toHaveLength(1);
```
Test fallback routes with the same message shape your production route sends.
## Next [#next]
# Hooks (/docs/concepts/hooks)
Hooks are for logs, metrics, tracing, and retry visibility.
```ts
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
hooks: {
beforeSend(event) {
console.log("email.send", event.provider, event.message.subject);
},
afterSend(event) {
console.log("email.sent", event.response.id);
},
onRetry(event) {
console.warn("email.retry", event.provider, event.nextAttempt);
},
onError(event) {
console.error("email.error", event.provider, event.error);
},
},
});
```
## Hook events [#hook-events]
Each hook receives:
| Field | Notes |
| ---------- | ----------------------------------- |
| `provider` | Routing name used for this attempt. |
| `message` | The original `EmailMessage`. |
| `attempt` | Current attempt number. |
| `metadata` | Optional metadata passed to `send`. |
`afterSend` also receives `response`. `onRetry` receives `nextAttempt` and `delayMs`. `onError` receives `error`.
## Observe retries and fallback [#observe-retries-and-fallback]
Hooks expose the send pipeline without changing adapter code:
* `beforeSend` runs for each adapter attempt.
* `onRetry` shows retry scheduling for the same adapter.
* `onError` shows adapter failures before fallback may continue.
* `afterSend` shows the adapter that actually succeeded.
For safer default redaction, use the observability plugin.
## Keep logs safe [#keep-logs-safe]
Do not log API keys, SMTP passwords, raw tokens, full message bodies, or unnecessary recipient data. For most production logs, routing name, status, subject category, template name, and message ID are enough.
# Install (/docs/getting-started/install)
Email SDK is published on npm as `@opencoredev/email-sdk`.
The command-line binary is named `email-sdk`. That means the package name and the command name are different on purpose:
| What you want | Use this |
| ------------------------------ | ------------------------------------------------------------------------------ |
| Install the package | `npm install @opencoredev/email-sdk` or your package manager's install command |
| Run the installed CLI | `npx email-sdk ...` |
| Run the CLI without installing | `npx --yes --package @opencoredev/email-sdk email-sdk ...` |
Do not install the unscoped `email-sdk` package from npm. It is a different package.
## Install in your app [#install-in-your-app]
Choose the package manager your app already uses.
Then import the client and adapters from the scoped package:
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
```
The SDK and CLI run in server-side Node 20+ and Bun runtimes.
## Run the CLI once [#run-the-cli-once]
Use the scoped package name when you want a quick adapter check without adding the package to a project.
```bash
npx --yes --package @opencoredev/email-sdk email-sdk adapters
```
Check a provider configuration:
```bash
RESEND_API_KEY="re_..." npx --yes --package @opencoredev/email-sdk email-sdk doctor --adapter resend
```
## Run the CLI from an installed project [#run-the-cli-from-an-installed-project]
After installing `@opencoredev/email-sdk`, run the installed binary:
```bash
npx email-sdk adapters
```
The command is still `email-sdk`; the package you installed is still `@opencoredev/email-sdk`. Avoid `npx email-sdk` outside an installed project because the unscoped npm package is unrelated.
Bun users can use the same package and binary. If you want Bun to execute the one-off CLI command, use `bunx --bun --package @opencoredev/email-sdk email-sdk adapters`.
## Prerequisites [#prerequisites]
* Node 20 or newer is supported for SDK and CLI usage.
* Bun 1.1 or newer is also supported.
* Do not expose provider API keys in browser or client-side code.
* Provider credentials must be set in environment variables or passed with CLI flags.
## Verify the install [#verify-the-install]
Check the published package and CLI version:
```bash
npx --yes --package @opencoredev/email-sdk email-sdk version
```
List supported adapters:
```bash
npx --yes --package @opencoredev/email-sdk email-sdk adapters
```
# Quickstart (/docs/getting-started/quickstart)
Install the scoped package with the package manager your app already uses:
The SDK and CLI work in server-side Node 20+ and Bun runtimes. The installed CLI command is named `email-sdk`. If you only need the CLI for a quick check, run `npx --yes --package @opencoredev/email-sdk email-sdk ...` instead. See Install for the package and CLI distinction.
## Choose a first adapter [#choose-a-first-adapter]
If you are new to Email SDK, start with Resend. It has the shortest setup path, supports the common transactional fields, and is the default example throughout these docs.
Use Postmark, SendGrid, AWS SES, Mailgun, or Brevo when your app needs broader provider-specific controls. Use SMTP when you already have a trusted SMTP service and only need address fields, headers, and plain message delivery.
## Send through Resend [#send-through-resend]
Set `RESEND_API_KEY`, then create a client with the Resend adapter.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
});
await email.send({
from: "Acme ",
to: "user@example.com",
subject: "Welcome to Acme",
text: "Your account is ready.",
});
```
## Check the send result [#check-the-send-result]
`send` resolves with the normalized adapter response. The `provider` value tells you which adapter
actually handled the send.
```ts
const result = await email.send(message);
console.log(result.provider);
console.log(result.id);
```
If the adapter returns a message ID, Email SDK exposes it through the normalized `id` field. Some
adapters also include `messageId` as an alias when the provider uses that name directly. Provider
responses are still available through `raw` when an adapter includes them.
## Verify from the CLI [#verify-from-the-cli]
Use `doctor` to check that the selected adapter has the required environment variables.
```bash
RESEND_API_KEY="re_..." npx --yes --package @opencoredev/email-sdk email-sdk doctor --adapter resend
```
Use `--dry-run` to validate the message shape and adapter field support without sending email.
```bash
npx --yes --package @opencoredev/email-sdk email-sdk send \
--adapter resend \
--from "Acme " \
--to "user@example.com" \
--subject "Hello" \
--text "It works" \
--dry-run
```
When the package is installed in your project, the same command can run through the installed binary:
```bash
RESEND_API_KEY="re_..." npx email-sdk doctor --adapter resend
```
## Add a safe fallback route [#add-a-safe-fallback-route]
Add SMTP when you want a second route for a simple transactional email. The SMTP adapter is built in
and does not require Nodemailer. When SMTP auth is configured on a non-secure connection, Email SDK
upgrades with STARTTLS before authenticating.
Only use fallback adapters that can send the same message shape. For example, SMTP supports address
fields and headers, but not provider-only fields like tags, metadata, or attachments.
Check Field support before choosing backup routes.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
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({
host: "smtp.purelymail.com",
port: 587,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
}),
],
fallback: ["smtp"],
});
```
Your application still sends the same way:
```ts
await email.send({
from: "Acme ",
to: "user@example.com",
subject: "Receipt",
text: "Thanks for your order.",
});
```
For route recipes, retries, observability, and tests, continue with Production send pipeline.
# Create an adapter (/docs/guides/authoring/create-adapter)
An adapter connects Email SDK's normalized `EmailMessage` to one provider API. It is the thing that actually sends.
A plugin can register that adapter for package-style reuse. It is the thing most community packages should ask users to install.
| You are writing... | It should do... | Users mount it with... |
| ------------------ | -------------------------------------------------------------- | ---------------------- |
| Adapter | Map `EmailMessage` into one provider request and response. | `adapters: [...]` |
| Adapter plugin | Register one adapter from a reusable package or shared module. | `plugins: [...]` |
Start with the plain adapter because it is easier to test. Wrap it as a plugin when the adapter should be reused across projects or published as a community package.
## Adapter shape [#adapter-shape]
```ts
import type { EmailProvider } from "@opencoredev/email-sdk";
export type AcmeMailOptions = {
apiKey: string;
fetch?: typeof fetch;
};
export function acmeMail(options: AcmeMailOptions): EmailProvider {
const request = options.fetch ?? fetch;
return {
name: "acme-mail",
async send(message, context) {
const response = await request("https://api.acme-mail.example/send", {
method: "POST",
signal: context.signal,
headers: {
Authorization: `Bearer ${options.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(toAcmePayload(message, context)),
});
if (!response.ok) {
throw new Error(`Acme Mail send failed: ${response.status}`);
}
const body = await response.json();
return {
provider: "acme-mail",
id: body.id,
messageId: body.message_id,
accepted: body.accepted,
rejected: body.rejected,
raw: body,
};
},
};
}
```
## Map the payload [#map-the-payload]
Keep provider-specific mapping in a helper. That keeps the adapter easy to test.
```ts
import type { EmailMessage, EmailProviderContext } from "@opencoredev/email-sdk";
function toAcmePayload(message: EmailMessage, context: EmailProviderContext) {
return {
from: message.from,
to: message.to,
subject: message.subject,
html: message.html,
text: message.text,
headers: message.headers,
tags: message.tags,
metadata: {
...message.metadata,
idempotencyKey: context.idempotencyKey,
attempt: context.attempt,
},
};
}
```
## Use the adapter directly [#use-the-adapter-directly]
Direct adapter registration is useful inside one app, one monorepo, or tests.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { acmeMail } from "./acme-mail";
const email = createEmailClient({
adapters: [acmeMail({ apiKey: process.env.ACME_MAIL_API_KEY! })],
});
```
## Wrap it as an adapter plugin [#wrap-it-as-an-adapter-plugin]
Adapter plugins are best for community packages and shared internal packages. The plugin does not send by itself; it returns metadata and the adapter list Email SDK should register.
```ts
import type { EmailPlugin } from "@opencoredev/email-sdk";
import { acmeMail, type AcmeMailOptions } from "./acme-mail";
export function acmeMailPlugin(options: AcmeMailOptions): EmailPlugin {
return {
id: "acme-mail",
adapters: [acmeMail(options)],
};
}
```
Consumers can now mount it with the same `plugins` array as other extensions:
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { acmeMailPlugin } from "email-sdk-acme-mail";
const email = createEmailClient({
plugins: [acmeMailPlugin({ apiKey: process.env.ACME_MAIL_API_KEY! })],
});
```
If the package is public, follow [Publish a community adapter](/docs/guides/authoring/publish-community-adapter) after the adapter tests pass.
## Handle unsupported fields [#handle-unsupported-fields]
Do not silently drop fields your provider cannot send. Throw a clear error instead.
```ts
if (message.attachments?.length) {
throw new Error("Acme Mail adapter does not support attachments.");
}
```
This is especially important for fallback routes. A backup adapter that drops attachments, tags, or reply-to values can make a send look successful while changing the email.
## Test the adapter [#test-the-adapter]
```ts
import { describe, expect, test } from "bun:test";
import { acmeMail } from "./acme-mail";
describe("acmeMail", () => {
test("sends a normalized message", async () => {
const calls: unknown[] = [];
const adapter = acmeMail({
apiKey: "test",
fetch: async (_url, init) => {
calls.push(JSON.parse(String(init?.body)));
return Response.json({ id: "provider_123", message_id: "msg_123" });
},
});
const response = await adapter.send(
{
from: "test@example.com",
to: "user@example.com",
subject: "Hello",
text: "Hi",
},
{ attempt: 1 },
);
expect(response.messageId).toBe("msg_123");
expect(calls).toHaveLength(1);
});
});
```
If your adapter accepts injected `fetch`, document it. It makes tests easier and keeps users from reaching for global mocks.
## Checklist [#checklist]
* Use one stable adapter `name`.
* Return `provider`, `id` or `messageId`, and `raw`.
* Pass `context.signal` into provider requests.
* Respect `context.idempotencyKey` when the provider supports it.
* Throw on unsupported fields instead of dropping them.
* Add tests for payload mapping and provider response mapping.
* If publishing, export both `{provider}` and `{provider}Plugin`.
* If publishing, declare `@opencoredev/email-sdk` as a peer dependency.
See [Adapter contract](/docs/reference/adapter-contract) for the exact type reference.
# Create your first plugin (/docs/guides/authoring/create-first-plugin)
This guide builds a small policy plugin that requires each send to include a metadata value. The same shape works for tenant checks, route enforcement, audit defaults, or environment-specific guardrails.
## What you will build [#what-you-will-build]
The plugin will:
1. Accept a required metadata key.
2. Run before provider validation.
3. Block the send when the key is missing.
4. Add a tiny typed helper to the returned client.
## Create the plugin file [#create-the-plugin-file]
```ts
import type { EmailPlugin } from "@opencoredev/email-sdk";
export type RequireMetadataPluginOptions = {
key: string;
};
export function requireMetadataPlugin(
options: RequireMetadataPluginOptions,
): EmailPlugin<{ requiredEmailMetadata: string }> {
return {
id: `require-metadata:${options.key}`,
middleware: [
{
beforeSend(event) {
const value = event.options?.metadata?.[options.key];
if (value === undefined || value === null || value === "") {
throw new Error(`Missing email metadata: ${options.key}`);
}
},
},
],
extendClient() {
return {
requiredEmailMetadata: options.key,
};
},
};
}
```
## Use it [#use-it]
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
import { requireMetadataPlugin } from "./require-metadata-plugin";
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
plugins: [requireMetadataPlugin({ key: "tenantId" })],
});
await email.send(
{
from: "billing@example.com",
to: "user@example.com",
subject: "Receipt",
text: "Thanks for your order.",
},
{
metadata: {
tenantId: "tenant_123",
},
},
);
```
The returned client now has a typed `email.requiredEmailMetadata` property.
## Block a send [#block-a-send]
```ts
await email.send({
from: "billing@example.com",
to: "user@example.com",
subject: "Receipt",
text: "Thanks for your order.",
});
```
That call throws before any adapter is called. `beforeSend` errors are not swallowed because policy plugins need to be able to stop unsafe sends.
## Add tests [#add-tests]
```ts
import { describe, expect, test } from "bun:test";
import { createEmailClient } from "@opencoredev/email-sdk";
import { memoryProvider } from "@opencoredev/email-sdk/testing";
import { requireMetadataPlugin } from "./require-metadata-plugin";
describe("requireMetadataPlugin", () => {
test("blocks sends without the required metadata", async () => {
const memory = memoryProvider();
const email = createEmailClient({
adapters: [memory],
plugins: [requireMetadataPlugin({ key: "tenantId" })],
});
await expect(
email.send({
from: "test@example.com",
to: "user@example.com",
subject: "Hello",
text: "Hi",
}),
).rejects.toThrow("Missing email metadata: tenantId");
expect(memory.raw.sent).toHaveLength(0);
});
});
```
## Plugin rules [#plugin-rules]
* Use a stable `id`.
* Make IDs unique when mounting multiple instances.
* Use `beforeSend` for behavior that can block a send.
* Use `afterSend`, `onError`, and hooks for observability work.
* Keep client extensions small and avoid names like `send`, `adapter`, or `defaultAdapter`.
For exact types, see [Plugin API](/docs/plugins/api).
# Publish a community adapter (/docs/guides/authoring/publish-community-adapter)
Community adapters are third-party npm packages. They let someone support a provider without adding that provider to `@opencoredev/email-sdk`, the official CLI, or the official adapter maintenance surface.
That split is intentional:
| Surface | Who owns it | Where it lives |
| ---------------------- | -------------------------------- | ------------------------------------------------ |
| Official adapter | Email SDK maintainers | `@opencoredev/email-sdk/{adapter}` |
| Community adapter | The community package maintainer | Their npm package, such as `email-sdk-acme-mail` |
| Community docs listing | Email SDK docs, by pull request | `apps/fumadocs/content/community/plugins.json` |
| Verified listing | Email SDK docs, for one version | Same registry entry plus verification metadata |
Most community adapters should start as `status: "community"`. That means listed, discoverable, and clearly third-party. It does not mean endorsed, bundled, or maintained by OpenCore.
## Adapter or plugin? [#adapter-or-plugin]
An adapter is the provider implementation. It maps Email SDK's normalized `EmailMessage` into one provider API request.
```ts
createEmailClient({
adapters: [acmeMail({ apiKey: process.env.ACME_MAIL_API_KEY! })],
});
```
A plugin is the package-friendly wrapper. It registers the adapter through the `plugins` array, which is the cleanest setup for reusable community packages.
```ts
createEmailClient({
plugins: [acmeMailPlugin({ apiKey: process.env.ACME_MAIL_API_KEY! })],
});
```
Ship both. The plain adapter keeps advanced users close to the provider. The plugin wrapper gives most users a stable one-line integration.
## Publishing path [#publishing-path]
1. Create a separate package owned by the community maintainer.
2. Implement the adapter contract from [Create an adapter](/docs/guides/authoring/create-adapter).
3. Export both the adapter factory and plugin factory.
4. Publish the package to npm from the maintainer's own repo.
5. Add a short README with install, setup, field support, and test instructions.
6. Optionally open a pull request to list the package in the Email SDK community registry.
Do not add community adapters to `packages/email-sdk/src`, the official adapter catalog, the official CLI adapter list, or the official package exports unless the project has agreed to maintain that provider as official.
## Recommended package shape [#recommended-package-shape]
```txt
email-sdk-acme-mail/
src/
index.ts
acme-mail.ts
plugin.ts
acme-mail.test.ts
package.json
tsconfig.json
README.md
```
## Export both forms [#export-both-forms]
Export a plain adapter factory and an adapter plugin factory from the package root.
```ts
export { acmeMail } from "./acme-mail";
export { acmeMailPlugin } from "./plugin";
export type { AcmeMailOptions } from "./acme-mail";
```
The plain adapter keeps advanced users close to the provider. The plugin form gives most apps the cleanest setup:
```ts
createEmailClient({
plugins: [acmeMailPlugin({ apiKey: process.env.ACME_MAIL_API_KEY! })],
});
```
## Package exports [#package-exports]
Community packages should use Email SDK as a peer dependency. That keeps apps from installing two copies of the core types.
```json
{
"name": "email-sdk-acme-mail",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"peerDependencies": {
"@opencoredev/email-sdk": "^0.4.0"
}
}
```
Avoid install scripts and binaries. Adapter packages should be boring library packages: importable code, types, tests, and provider docs.
## Naming [#naming]
| Thing | Recommendation |
| --------- | ------------------------------------ |
| Package | `email-sdk-{provider}` |
| Adapter | Provider slug, such as `acme-mail` |
| Plugin ID | Same slug unless you need variants |
| Env var | Provider slug in uppercase, plus key |
| Export | `{provider}` and `{provider}Plugin` |
Example:
| Thing | Example |
| --------- | ---------------------------- |
| Package | `email-sdk-acme-mail` |
| Adapter | `acme-mail` |
| Plugin ID | `acme-mail` |
| Env var | `ACME_MAIL_API_KEY` |
| Export | `acmeMail`, `acmeMailPlugin` |
## Document field support [#document-field-support]
Every adapter README should say whether these fields are supported:
* `html` and `text`
* `cc`, `bcc`, and `replyTo`
* `headers`
* `attachments`
* `tags`
* `metadata`
* `idempotencyKey`
If a field is not supported, say whether the adapter throws. Community adapters should throw on unsupported non-empty fields instead of dropping them.
Use a small table in the package README:
| Field | Support | Notes |
| ---------------- | ------- | ----------------------------------------- |
| `html`, `text` | Yes | Sent as provider body fields. |
| `cc`, `bcc` | Yes | Mapped to provider recipient fields. |
| `replyTo` | No | Throws when provided. |
| `headers` | No | Provider does not support custom headers. |
| `attachments` | No | Throws when provided. |
| `tags` | Yes | Mapped to provider tags. |
| `metadata` | Partial | Included when values are strings. |
| `idempotencyKey` | No | Provider does not expose this feature. |
This is not paperwork. Field support determines whether the adapter is safe as a fallback route. A backup provider that silently drops attachments or reply-to addresses can make a send look successful while changing the email.
## Include a smoke test [#include-a-smoke-test]
At minimum, test:
1. Payload mapping.
2. Response mapping.
3. Unsupported field errors.
4. `AbortSignal` forwarding.
5. Plugin registration through `createEmailClient`.
```ts
import { describe, expect, test } from "bun:test";
import { createEmailClient } from "@opencoredev/email-sdk";
import { acmeMailPlugin } from "./index";
describe("acmeMailPlugin", () => {
test("registers an adapter", () => {
const email = createEmailClient({
plugins: [acmeMailPlugin({ apiKey: "test" })],
});
expect(email.adapters.has("acme-mail")).toBe(true);
expect(email.defaultAdapter).toBe("acme-mail");
});
});
```
## README checklist [#readme-checklist]
* Install command for the community package and `@opencoredev/email-sdk`.
* Minimal `createEmailClient` example.
* Required environment variables.
* Field support table.
* Error behavior for unsupported fields.
* Test command.
* Link to Email SDK adapter contract.
* Link to the provider API docs used by the adapter.
Keep the README short enough that a user can decide if the adapter fits their fallback route.
## List it in Email SDK docs [#list-it-in-email-sdk-docs]
After publishing to npm, the maintainer can open a pull request that adds an entry to `apps/fumadocs/content/community/plugins.json`.
```json
{
"name": "Acme Mail",
"package": "email-sdk-acme-mail",
"kind": "adapter",
"status": "community",
"description": "Adds an Acme Mail provider adapter for Email SDK.",
"href": "https://www.npmjs.com/package/email-sdk-acme-mail",
"repo": "https://github.com/acme/email-sdk-acme-mail",
"maintainer": "acme",
"pluginId": "acme-mail",
"adapter": "acme-mail",
"importName": "acmeMailPlugin"
}
```
Then run:
```bash
bun run community:check
```
That check validates the registry shape. In CI, verified and official entries also get a published package metadata check. Community entries are listed as third-party packages without verification metadata.
## Community, verified, or official? [#community-verified-or-official]
| Status | Use it when... |
| ----------- | ------------------------------------------------------------------------- |
| `community` | A third-party maintainer published the package and wants discoverability. |
| `verified` | A specific published version passed the registry verification checklist. |
| `official` | The adapter is maintained by OpenCore or ships in this repository. |
Verification applies to one version. If the package releases again, the registry should keep the old `verifiedVersion` until the new version is reviewed.
## What not to do [#what-not-to-do]
* Do not publish under the `@opencoredev` scope.
* Do not ask users to import from `@opencoredev/email-sdk/{community-provider}`.
* Do not add provider secrets or live credentials to examples.
* Do not hide provider limitations behind successful sends.
* Do not mark a listing `verified` before a package version has actually passed verification.
* Do not mark a third-party package `official`.
## Next [#next]
* [Create an adapter](/docs/guides/authoring/create-adapter) for implementation details.
* [Adapter contract](/docs/reference/adapter-contract) for the exact TypeScript types.
* [Community registry](/docs/reference/community-registry) for listing and verification fields.
# Publish a community plugin (/docs/guides/authoring/publish-community-plugin)
Community plugins are npm packages that export an `EmailPlugin` factory. The docs site lists them from a static JSON registry; there is no hosted plugin service.
If your package sends through a new provider, use [Publish a community adapter](/docs/guides/authoring/publish-community-adapter) instead. Adapter packages still export plugins, but they need extra field-support and provider-mapping documentation.
## Package shape [#package-shape]
```txt
email-sdk-require-metadata/
src/
index.ts
require-metadata.ts
require-metadata.test.ts
package.json
README.md
```
Export the plugin factory from the package root.
```ts
export { requireMetadataPlugin } from "./require-metadata";
export type { RequireMetadataOptions } from "./require-metadata";
```
Apps should be able to use the package in one line inside `plugins`.
```ts
createEmailClient({
plugins: [requireMetadataPlugin({ key: "tenantId" })],
});
```
## Package requirements [#package-requirements]
Use `@opencoredev/email-sdk` as a peer dependency.
```json
{
"name": "email-sdk-require-metadata",
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"peerDependencies": {
"@opencoredev/email-sdk": "^0.4.0"
}
}
```
Verified packages must not define `preinstall`, `install`, or `postinstall` scripts.
## Security expectations [#security-expectations]
Verified packages must be boring:
* Public source repository.
* npm trusted publishing or provenance.
* No install scripts.
* No binaries.
* Small runtime dependency surface.
* No network calls during import or plugin construction.
* Tests that prove the plugin registers with `createEmailClient`.
These checks reduce supply-chain risk, but they do not make third-party code risk-free.
## Registry entry [#registry-entry]
Add the package to `apps/fumadocs/content/community/plugins.json`.
```json
{
"name": "Require metadata",
"package": "email-sdk-require-metadata",
"kind": "plugin",
"status": "community",
"description": "Blocks sends unless required metadata is present.",
"href": "https://www.npmjs.com/package/email-sdk-require-metadata",
"repo": "https://github.com/acme/email-sdk-require-metadata",
"maintainer": "acme",
"pluginId": "require-metadata"
}
```
Use `status: "verified"` only after the package passes the verification checklist for one published version.
## Verify locally [#verify-locally]
```bash
bun run community:check
```
CI runs the same registry check. Verified entries also get a package tarball audit in CI.
## Next [#next]
Adapter packages follow the same process, but should also document field support. See [Publish a community adapter](/docs/guides/authoring/publish-community-adapter).
# Guides (/docs/guides)
Use these guides when you are operating a production send path or building something that should live outside one send call: fallback routes, tests, reusable plugins, custom provider adapters, or community packages.
If you want to support a provider that Email SDK does not officially maintain, start with [Publish a community adapter](/docs/guides/authoring/publish-community-adapter). Community adapters live in their own npm packages and can be listed in the docs without becoming official.
## Operate [#operate]
## Build [#build]
## Where reference lives [#where-reference-lives]
Guides are procedural. Use the reference pages when you need exact fields and contracts:
| Need | Page |
| -------------------------- | ---------------------------------------------------- |
| Client options | [Client](/docs/reference/client) |
| Message shape | [Message](/docs/reference/message) |
| Adapter contract | [Adapter contract](/docs/reference/adapter-contract) |
| Plugin types and lifecycle | [Plugin API](/docs/plugins/api) |
| Provider field limits | [Field support](/docs/adapters/field-support) |
# 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 ",
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 [#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 ",
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 " \
--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.
# Test email behavior (/docs/guides/test-email-behavior)
Use the memory provider when you want to replace the provider. Use the capture plugin when you want to observe the normal send pipeline.
## Memory provider [#memory-provider]
The memory provider stores successful sends and never calls an external API.
```ts
import { describe, expect, test } from "bun:test";
import { createEmailClient } from "@opencoredev/email-sdk";
import { memoryProvider } from "@opencoredev/email-sdk/testing";
describe("welcome email", () => {
test("sends the expected subject", async () => {
const memory = memoryProvider();
const email = createEmailClient({
adapters: [memory],
});
await email.send({
from: "test@example.com",
to: "user@example.com",
subject: "Welcome",
text: "Hello",
});
expect(memory.raw.sent[0]?.message.subject).toBe("Welcome");
});
});
```
## Capture plugin [#capture-plugin]
The capture plugin records lifecycle events. It can be used with the memory provider or with a custom test adapter.
```ts
import { capturePlugin } from "@opencoredev/email-sdk/plugins/capture";
const memory = memoryProvider();
const email = createEmailClient({
adapters: [memory],
plugins: [capturePlugin()],
});
await email.send({
from: "test@example.com",
to: "user@example.com",
subject: "Welcome",
text: "Hello",
});
expect(email.capture.events.map((event) => event.type)).toEqual(["beforeSend", "afterSend"]);
```
## Assert defaults [#assert-defaults]
Defaults run before message validation, so tests can assert the final message that reached the provider.
```ts
import { defaultsPlugin } from "@opencoredev/email-sdk/plugins/defaults";
const memory = memoryProvider();
const email = createEmailClient({
adapters: [memory],
plugins: [
defaultsPlugin({
headers: { "X-App": "billing" },
sendMetadata: { service: "billing" },
}),
],
});
await email.send(
{
from: "billing@example.com",
to: "user@example.com",
subject: "Receipt",
text: "Thanks.",
},
{
metadata: { route: "receipt" },
},
);
expect(memory.raw.sent[0]?.message.headers).toMatchObject({ "X-App": "billing" });
```
## Assert fallback [#assert-fallback]
Use `failingProvider()` for the primary route and `memoryProvider()` for the backup route. Assert the response provider and the backup send store.
```ts
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: "test@example.com",
to: "user@example.com",
subject: "Fallback",
text: "Hello",
});
expect(response.provider).toBe("backup");
expect(backup.raw.sent).toHaveLength(1);
```
## Assert retry [#assert-retry]
Use a provider that fails once with a retryable error, then succeeds.
```ts
import { EmailProviderError, createEmailClient } from "@opencoredev/email-sdk";
import { capturePlugin } from "@opencoredev/email-sdk/plugins/capture";
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(attempts).toBe(2);
expect(email.capture.events.map((event) => event.type)).toContain("retry");
```
## Test checklist [#test-checklist]
* Use `memoryProvider()` for app tests that should not call real providers.
* Use `capturePlugin()` when you need lifecycle events.
* Assert provider field support with adapter unit tests.
* Add one test for fallback if the route matters.
* Test fallback routes with the same message shape used in production.
* Add one test for plugin ordering when multiple plugins mutate the same message.
# Email SDK (/docs)
Email SDK is a small TypeScript package for production transactional email send pipelines. It gives your application one typed message shape, explicit adapter routing, fail-fast provider compatibility checks, retries, fallback routes, observability hooks, reusable plugins, test capture, and CLI verification.
Use Email SDK when your app needs email sends that are portable, explicit, testable, observable, and safer to operate. Your application code still calls `send`; the configured adapters decide how that message maps to Resend, SMTP, Postmark, SendGrid, Mailgun, AWS SES, or another provider.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
});
await email.send({
from: "Acme ",
to: "user@example.com",
subject: "Welcome",
text: "Your account is ready.",
});
```
## Start here [#start-here]
## Core pieces [#core-pieces]
* One `createEmailClient` entry point and one normalized `EmailMessage` shape.
* Adapter routing through `defaultAdapter`, per-send `adapter`, `fallback`, and `fallbackAdapters`.
* Fail-fast field support checks so providers do not silently drop unsupported message data.
* Retries inside one adapter and fallback routes after an adapter has finally failed.
* Hooks and the observability plugin for logs, metrics, traces, retry visibility, and failures.
* Plugins for shared defaults, adapter registration, client extensions, observability, and test capture.
* Memory and failing test adapters plus the capture plugin for asserting send behavior without real providers.
* A Bun CLI for adapter discovery, setup checks, dry-runs, and smoke-test sends.
## What stays small [#what-stays-small]
Email SDK is not a campaign tool, queue, template engine, hosted analytics product, or full email operations suite. It is the layer many apps keep rebuilding: adapter setup, one consistent send call, typed errors, provider compatibility checks, and fallback routes that are explicit enough to debug.
## Provider behavior is not hidden [#provider-behavior-is-not-hidden]
Email providers do not all support the same fields. A fallback route is only safe when the backup adapter can represent the same class of message. Read Fallbacks and retries for route behavior, use Field support to choose compatible routes, then follow Production send pipeline when wiring a real app.
The official API adapters are covered by local payload and validation tests. A live provider send still depends on account setup: verified sender domains, sandbox mode, API scopes, regions, rate limits, and provider policy. Use the CLI dry run first, then run one real smoke send from the environment that will send production email.
# Plugin API (/docs/plugins/api)
This page is the type reference. For examples, start with Writing plugins.
## EmailPlugin [#emailplugin]
```ts
type EmailPlugin = {
id: string;
adapters?: EmailProvider[] | ((ctx: EmailPluginContext) => EmailProvider[]);
hooks?: EmailHooks;
middleware?: EmailSendMiddleware[];
extendClient?: (ctx: EmailPluginContext) => TExtension;
};
```
| Field | Notes |
| -------------- | ----------------------------------------------------- |
| `id` | Required stable plugin ID. Duplicate IDs throw. |
| `adapters` | Providers to register. Duplicate adapter names throw. |
| `hooks` | Observability callbacks that run before user `hooks`. |
| `middleware` | Send-pipeline middleware. |
| `extendClient` | Adds non-conflicting helper properties to the client. |
`createEmailClient` is synchronous, so plugin adapter factories must return adapters synchronously.
## EmailPluginContext [#emailplugincontext]
```ts
type EmailPluginContext = {
adapters: ReadonlyMap;
defaultAdapter: string;
addAdapter(adapter: EmailProvider): void;
};
```
## EmailSendMiddleware [#emailsendmiddleware]
```ts
type EmailSendMiddleware = {
beforeSend?: (event: EmailBeforeSendEvent) => MaybePromise;
afterSend?: (event: EmailAfterSendEvent) => MaybePromise;
onError?: (event: EmailErrorEvent) => MaybePromise;
};
```
## beforeSend [#beforesend]
Runs once per `send` call before message validation and before any adapter is called.
```ts
beforeSend(event) {
return {
message: {
...event.message,
headers: { "X-App": "billing" },
},
options: {
metadata: { template: "receipt" },
},
};
}
```
Thrown errors stop the send.
## afterSend [#aftersend]
Runs after a provider returns a normalized response.
```ts
afterSend(event) {
console.log(event.provider, event.response.id);
}
```
Thrown errors are swallowed.
## onError [#onerror]
Runs after a provider attempt fails.
```ts
onError(event) {
console.error(event.provider, event.error);
}
```
Fallback adapters can still run after this callback. Thrown errors are swallowed.
# Capture plugin (/docs/plugins/built-in/capture)
Use the capture plugin in tests when you need to assert what the email client tried to do.
```ts
import { createEmailClient } from "@opencoredev/email-sdk";
import { capturePlugin } from "@opencoredev/email-sdk/plugins/capture";
import { memoryProvider } from "@opencoredev/email-sdk/testing";
const memory = memoryProvider();
const email = createEmailClient({
adapters: [memory],
plugins: [capturePlugin()],
});
await email.send({
from: "test@example.com",
to: "user@example.com",
subject: "Welcome",
text: "Hello",
});
expect(email.capture.events).toHaveLength(2);
expect(memory.raw.sent).toHaveLength(1);
```
## What gets captured [#what-gets-captured]
| Event | Meaning |
| ------------ | ------------------------------------------ |
| `beforeSend` | The message entered the send pipeline. |
| `afterSend` | A provider returned a normalized response. |
| `retry` | Email SDK scheduled another attempt. |
| `error` | A provider attempt failed. |
Captured events include the message and whichever provider, attempt, metadata, response, or error details exist at that point in the lifecycle.
## Clear events [#clear-events]
```ts
email.capture.clear();
```
## Bring your own store [#bring-your-own-store]
```ts
import { capturePlugin, createEmailCaptureStore } from "@opencoredev/email-sdk/plugins/capture";
const store = createEmailCaptureStore();
const email = createEmailClient({
adapters: [memoryProvider()],
plugins: [capturePlugin(store)],
});
```
## Multiple capture stores [#multiple-capture-stores]
Use a custom `id` for plugin identity and a custom `clientKey` for the typed client property.
```ts
const email = createEmailClient({
adapters: [memoryProvider()],
plugins: [
capturePlugin({ id: "capture:primary", clientKey: "primaryCapture" }),
capturePlugin({ id: "capture:audit", clientKey: "auditCapture" }),
],
});
email.primaryCapture.clear();
email.auditCapture.clear();
```
## Capture vs memory provider [#capture-vs-memory-provider]
`capturePlugin()` records lifecycle events. It does not stop real provider calls.
`memoryProvider()` is the adapter you use when a test should avoid external email. In most tests, use both: memory avoids the network, capture proves the SDK pipeline behaved correctly.
# Defaults plugin (/docs/plugins/built-in/defaults)
Use the defaults plugin when many sends need the same headers, tags, metadata, reply-to address, or idempotency key format.
```ts
import { defaultsPlugin } from "@opencoredev/email-sdk/plugins/defaults";
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
plugins: [
defaultsPlugin({
headers: { "X-App": "billing" },
tags: [{ name: "service", value: "billing" }],
sendMetadata: { service: "billing" },
replyTo: "support@example.com",
idempotencyKeyPrefix: "billing_",
}),
],
});
```
## Use it for [#use-it-for]
* App or service headers.
* Shared tags for routing or provider dashboards.
* Hook metadata such as `service`, `route`, or `template`.
* A default reply-to address.
* Idempotency key prefixes.
## Options [#options]
| Option | Writes to | Override rule |
| ---------------------- | ------------------------ | --------------------------------------------------------------- |
| `headers` | `message.headers` | Message headers win. |
| `tags` | `message.tags` | Message tags are appended after default tags. |
| `metadata` | `message.metadata` | Message metadata wins. |
| `sendMetadata` | `sendOptions.metadata` | Per-send metadata wins. |
| `replyTo` | `message.replyTo` | Used only when the message has no `replyTo`. |
| `idempotencyKey` | Message and send options | Used only when no idempotency key exists. |
| `idempotencyKeyPrefix` | Message and send options | Added only when the key does not already start with the prefix. |
## Override a default [#override-a-default]
```ts
await email.send(
{
from: "billing@example.com",
to: "user@example.com",
subject: "Receipt",
text: "Thanks for your order.",
headers: {
"X-App": "checkout",
},
},
{
metadata: {
route: "checkout.receipt",
},
},
);
```
Here, `X-App: checkout` wins over the default `X-App: billing`.
## Stack defaults [#stack-defaults]
Use custom IDs for multiple defaults layers.
```ts
plugins: [
defaultsPlugin({
id: "defaults:app",
headers: { "X-App": "acme" },
}),
defaultsPlugin({
id: "defaults:billing",
tags: [{ name: "team", value: "billing" }],
}),
];
```
Defaults run before adapter validation. If a default adds a field the selected adapter cannot represent, Email SDK throws before calling the provider.
# Observability plugin (/docs/plugins/built-in/observability)
Use the observability plugin when you want to see email behavior without logging recipients or message bodies.
```ts
import { observabilityPlugin } from "@opencoredev/email-sdk/plugins/observability";
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
plugins: [
observabilityPlugin({
log(event) {
console.log(event.type, event.provider);
},
metric(event) {
statsd.increment(event.type, {
provider: event.provider,
});
},
trace(event) {
tracer.event("email", event);
},
}),
],
});
```
## Events [#events]
| Event | Fires when |
| ------------- | ------------------------------------------ |
| `email.sent` | A provider returns a normalized response. |
| `email.retry` | Email SDK schedules another retry attempt. |
| `email.error` | A provider attempt fails. |
Each event includes provider name, attempt number, metadata, and a redacted message summary.
## Redacted message shape [#redacted-message-shape]
```ts
type RedactedEmailMessage = {
subject: string;
toCount: number;
ccCount: number;
bccCount: number;
hasHtml: boolean;
hasText: boolean;
attachmentCount: number;
tagNames: string[];
metadataKeys: string[];
};
```
By default, the plugin does not include recipient addresses, HTML, text, attachment content, or metadata values.
## Custom redaction [#custom-redaction]
```ts
observabilityPlugin({
redactMessage(message) {
return {
subject: message.subject,
toCount: Array.isArray(message.to) ? message.to.length : 1,
template: message.metadata?.template ?? "unknown",
hasAttachments: Boolean(message.attachments?.length),
};
},
log(event) {
console.log(event);
},
});
```
Keep secrets, raw tokens, recipient lists, and body content out of logs unless your app explicitly needs and protects that data.
## Failure behavior [#failure-behavior]
Observability callbacks are non-blocking. If `log`, `metric`, or `trace` throws, Email SDK swallows that error so logging failures do not hide provider failures.
Use a custom `beforeSend` middleware plugin when a rule should block a send.
# Community plugins and adapters (/docs/plugins/community)
Community plugins and adapters are third-party npm packages. The registry is static and maintained in this repository by pull request.
Use this page for discovery. Use the publishing guides when you want to create a package.
## What belongs here? [#what-belongs-here]
| Package kind | What it adds | Example setup |
| ------------ | --------------------------------------------------- | ------------------------------------ |
| Adapter | A provider implementation for one email service. | `plugins: [acmeMailPlugin(...)]` |
| Plugin | Reusable send behavior, policy, hooks, or helpers. | `plugins: [requireMetadataPlugin()]` |
| Hybrid | A package that includes both provider and behavior. | `plugins: [providerWithDefaults()]` |
Adapters are listed here even though users mount the package through `plugins`. The adapter is the provider implementation; the plugin is the reusable registration wrapper.
## Labels [#labels]
| Label | Meaning |
| --------- | ---------------------------------------------------------- |
| Community | Listed by pull request. The package is not endorsed. |
| Verified | Passed the registry checks for a specific package version. |
| Official | Maintained by OpenCore or in this repository. |
Verified does not mean risk-free. It means the package passed the static checks documented in the registry schema.
## Add a package [#add-a-package]
Publish a package to npm, then open a pull request that adds an entry to `apps/fumadocs/content/community/plugins.json`.
If you want feedback before writing the entry, open the `Community package listing` issue template with the package and source links.
Start with:
* [Publish a community adapter](/docs/guides/authoring/publish-community-adapter) for provider packages.
* [Publish a community plugin](/docs/guides/authoring/publish-community-plugin) for behavior packages.
# Plugins (/docs/plugins)
Plugins are add-ons for an email client. They can register adapters, change a message before validation, record send activity, or expose a small typed helper on the client.
Plugins are how repeated send-pipeline behavior becomes reusable: defaults before validation, observability after attempts, capture in tests, and adapter registration for community routes. For a full production path, see Production send pipeline.
Most apps should start with the built-in plugins. If a behavior should be reused across apps, move it into a plugin.
## Built-in plugins [#built-in-plugins]
| Plugin | Import | Use it when |
| ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------- |
| Defaults | `@opencoredev/email-sdk/plugins/defaults` | Every send needs shared headers, tags, metadata, reply-to, or idempotency defaults. |
| Observability | `@opencoredev/email-sdk/plugins/observability` | You want logs, metrics, or traces without leaking recipients or email body content. |
| Capture | `@opencoredev/email-sdk/plugins/capture` | Tests need to inspect attempted sends, retries, responses, and errors. |
```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";
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
plugins: [
defaultsPlugin({
headers: { "X-App": "billing" },
sendMetadata: { service: "billing" },
}),
observabilityPlugin({
log(event) {
console.log(event.type, event.provider);
},
}),
],
});
```
## What plugins can do [#what-plugins-can-do]
| Capability | Example |
| ----------------- | ----------------------------------------------------------------- |
| Register adapters | A community provider package can add its own `EmailProvider`. |
| Change a message | Defaults can add headers before provider validation. |
| Observe sends | Observability can emit redacted success, retry, and error events. |
| Capture tests | Capture can expose `email.capture.events`. |
| Add helpers | A plugin can add a small typed property to the returned client. |
## What plugins cannot do [#what-plugins-cannot-do]
Email SDK plugins do not add databases, migrations, HTTP endpoints, route middleware, rate limits, or server/client plugin pairs. Those are framework-level features. Email SDK plugins stay focused on sending email.
## Common plugin shapes [#common-plugin-shapes]
| Shape | Where to start |
| -------------------------- | --------------------------------------------------------------------------------------- |
| Shared defaults | Use the [defaults plugin](/docs/plugins/built-in/defaults). |
| Redacted logging | Use the [observability plugin](/docs/plugins/built-in/observability). |
| Test capture | Use the [capture plugin](/docs/plugins/built-in/capture). |
| Custom provider | Follow [Create an adapter](/docs/guides/authoring/create-adapter). |
| Reusable behavior | Follow [Create your first plugin](/docs/guides/authoring/create-first-plugin). |
| Community adapter | Follow [Publish a community adapter](/docs/guides/authoring/publish-community-adapter). |
| Community behavior package | Follow [Publish a community plugin](/docs/guides/authoring/publish-community-plugin). |
## Order [#order]
Plugin order is deterministic.
1. Direct `adapters` register first.
2. Plugins run in array order.
3. Plugin adapters register in plugin order.
4. Duplicate plugin IDs throw.
5. Duplicate adapter names throw.
6. `beforeSend` middleware runs before message validation.
7. Plugin hooks run before user `hooks`.
Use a custom `id` when mounting more than one instance of the same plugin type. If the plugin extends the client, also give each instance a distinct extension key, such as `capturePlugin({ id: "capture:audit", clientKey: "auditCapture" })`.
## Next [#next]
# Writing plugins (/docs/plugins/writing-plugins)
Write a plugin when email behavior should be reused across clients, apps, or packages.
Start with one of these shapes:
| Shape | Use it for |
| ----------------- | ------------------------------------------------------------- |
| Adapter plugin | Package a provider as `plugins: [providerPlugin()]`. |
| Middleware plugin | Add defaults, policy, capture, or observability around sends. |
| Hook plugin | Reuse existing `EmailHooks` callbacks. |
| Client extension | Add a small typed helper to the returned client. |
## Adapter plugin [#adapter-plugin]
Adapter plugins are the best shape for community providers.
```ts
import type { EmailPlugin } from "@opencoredev/email-sdk";
export function communityMail(options: { apiKey: string }): EmailPlugin {
return {
id: "community-mail",
adapters: [
{
name: "community-mail",
async send(message, context) {
const response = await fetch("https://api.example.com/send", {
method: "POST",
signal: context.signal,
headers: {
Authorization: `Bearer ${options.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(message),
});
const body = await response.json();
return {
provider: "community-mail",
id: body.id,
messageId: body.id,
raw: body,
};
},
},
],
};
}
```
Application code stays clean:
```ts
const email = createEmailClient({
plugins: [communityMail({ apiKey: process.env.COMMUNITY_MAIL_API_KEY! })],
});
```
## Middleware plugin [#middleware-plugin]
Use `beforeSend` when the plugin needs to change or block a message.
```ts
import type { EmailPlugin } from "@opencoredev/email-sdk";
export function requireMetadata(key: string): EmailPlugin {
return {
id: `require-metadata:${key}`,
middleware: [
{
beforeSend(event) {
if (!event.options?.metadata?.[key]) {
throw new Error(`Missing email metadata: ${key}`);
}
},
},
],
};
}
```
`beforeSend` errors are not swallowed. Use it for policy.
Use `afterSend` and `onError` for observability work that should not block provider behavior.
## Programmatic adapter registration [#programmatic-adapter-registration]
Use `ctx.addAdapter` when a plugin creates adapters dynamically.
```ts
export function regionalMail(options: RegionOptions): EmailPlugin {
return {
id: "regional-mail",
adapters(ctx) {
ctx.addAdapter(createRegionAdapter("mail-us", options.us));
ctx.addAdapter(createRegionAdapter("mail-eu", options.eu));
return [];
},
};
}
```
Duplicate adapter names throw. If a factory both calls `ctx.addAdapter(adapter)` and returns the same adapter object, Email SDK registers it once.
## Client extension [#client-extension]
Use `extendClient` for tiny helpers or stores. Keep it boring.
```ts
export function routeNamesPlugin(): EmailPlugin<{
routeNames: string[];
}> {
return {
id: "route-names",
extendClient(ctx) {
return {
routeNames: [...ctx.adapters.keys()],
};
},
};
}
```
Extension keys cannot collide with built-in client keys like `send`, `sendBatch`, `adapter`, or `defaultAdapter`.
## Community adapter checklist [#community-adapter-checklist]
* Use a stable plugin `id` and adapter `name`.
* Keep credentials in plugin options.
* Support `context.signal` for network requests.
* Pass `context.idempotencyKey` when the provider supports idempotency.
* Return normalized `id`, `messageId`, `accepted`, or `rejected` fields when available.
* Put provider responses in `raw` when useful for debugging.
* Map every supported `EmailMessage` field.
* Reject unsupported fields before calling the provider.
* Test payloads with injected `fetch` or a local test server.
Community packages can export both a plain provider factory and an adapter plugin. Use the plugin shape when you want the one-call setup path.
# Adapter contract (/docs/reference/adapter-contract)
Custom adapters are plain objects that implement `EmailProvider`. They send one normalized Email SDK message through one provider.
Adapter plugins are `EmailPlugin` objects that register adapters. Use them when the adapter is packaged for reuse.
| Contract | Purpose |
| --------------- | ---------------------------------------------------- |
| `EmailProvider` | Provider implementation. Maps and sends the message. |
| `EmailPlugin` | Registration wrapper. Adds adapters to a client. |
```ts
import type { EmailProvider } from "@opencoredev/email-sdk";
export const customAdapter: EmailProvider = {
name: "custom",
async send(message, context) {
const response = await fetch("https://api.example.com/email", {
method: "POST",
signal: context.signal,
body: JSON.stringify(message),
});
const body = await response.json();
return {
provider: "custom",
id: body.id,
raw: body,
};
},
};
```
Use a plain adapter directly:
```ts
createEmailClient({ adapters: [customAdapter] });
```
Or package it as an adapter plugin. The plugin should return a stable `id` and one or more adapters.
```ts
import type { EmailPlugin } from "@opencoredev/email-sdk";
export function customAdapterPlugin(): EmailPlugin {
return {
id: "custom",
adapters: [customAdapter],
};
}
createEmailClient({ plugins: [customAdapterPlugin()] });
```
For community packages, export both forms from the package root:
```ts
export { customAdapter } from "./custom-adapter";
export { customAdapterPlugin } from "./plugin";
```
## `EmailProvider` [#emailprovider]
```ts
type EmailProvider = {
name: string;
send(message: EmailMessage, context: EmailProviderContext): MaybePromise;
raw?: TRaw;
};
```
## `EmailProviderContext` [#emailprovidercontext]
| Field | Type | Notes |
| ---------------- | ------------------------- | ---------------------------------------------- |
| `signal` | `AbortSignal` | Optional abort signal. |
| `idempotencyKey` | `string` | Optional key from the message or send options. |
| `attempt` | `number` | Attempt number for this adapter. |
| `metadata` | `Record` | Metadata passed to `send`. |
## Adapter responses [#adapter-responses]
```ts
type EmailProviderResponse = {
id?: string;
provider: string;
messageId?: string;
accepted?: string[];
rejected?: string[];
raw?: unknown;
};
```
Return the provider's raw response in `raw` when it helps callers debug or inspect provider-specific fields.
## `EmailPlugin` for adapter packages [#emailplugin-for-adapter-packages]
```ts
type EmailPlugin = {
id: string;
adapters?: EmailProvider[] | ((ctx: EmailPluginContext) => EmailProvider[]);
};
```
Adapter plugins should keep `adapters` synchronous. If the adapter needs credentials, accept them in the plugin factory and construct the adapter immediately.
```ts
export function customAdapterPlugin(options: { apiKey: string }): EmailPlugin {
const adapter = createCustomAdapter(options);
return {
id: "custom",
adapters: [adapter],
};
}
```
Duplicate plugin IDs and duplicate adapter names throw during `createEmailClient`. Use one stable adapter name so routing, fallbacks, logs, and tests all refer to the same provider.
# CLI (/docs/reference/cli)
The CLI is intentionally small. Use it to inspect adapters, check environment variables, validate a message with `--dry-run`, and send a smoke-test email from your terminal.
The npm package is `@opencoredev/email-sdk`; the CLI command it installs is `email-sdk`.
## Install or run [#install-or-run]
Run the CLI once with the scoped package:
```bash
npx --yes --package @opencoredev/email-sdk email-sdk adapters
```
Install the SDK in a project:
Then run the installed binary:
```bash
npx email-sdk adapters
```
Check the installed version before comparing behavior against the docs:
```bash
npx email-sdk version
```
The CLI supports Node 20+ and Bun 1.1+. The examples use `npx` because it is the broadest copy-paste path; Bun users can run the one-off CLI with `bunx --bun --package @opencoredev/email-sdk email-sdk adapters`.
## Send [#send]
```bash
RESEND_API_KEY="re_..." npx --yes --package @opencoredev/email-sdk email-sdk send \
--adapter resend \
--from "Acme " \
--to "user@example.com" \
--subject "Hello" \
--text "It works"
```
## Adapters [#adapters]
```bash
npx --yes --package @opencoredev/email-sdk email-sdk adapters
```
| Adapter | Required environment |
| ------------ | ---------------------------------------------------------- |
| `resend` | `RESEND_API_KEY` |
| `postmark` | `POSTMARK_SERVER_TOKEN` |
| `sendgrid` | `SENDGRID_API_KEY` |
| `cloudflare` | `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` |
| `ses` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` |
| `mailgun` | `MAILGUN_API_KEY`, `MAILGUN_DOMAIN` |
| `mailersend` | `MAILERSEND_API_KEY` |
| `brevo` | `BREVO_API_KEY` |
| `mailchimp` | `MAILCHIMP_API_KEY` |
| `sparkpost` | `SPARKPOST_API_KEY` |
| `loops` | `LOOPS_API_KEY`, `LOOPS_TRANSACTIONAL_ID` |
| `plunk` | `PLUNK_API_KEY` |
| `mailtrap` | `MAILTRAP_API_KEY` |
| `scaleway` | `SCALEWAY_SECRET_KEY`, `SCALEWAY_PROJECT_ID` |
| `zeptomail` | `ZEPTOMAIL_TOKEN` |
| `mailpace` | `MAILPACE_API_KEY` |
| `smtp` | `SMTP_HOST`, plus SMTP auth variables when needed |
If `--adapter` is omitted, the CLI selects the first adapter with all required environment variables set.
## Doctor [#doctor]
```bash
RESEND_API_KEY="re_..." npx --yes --package @opencoredev/email-sdk email-sdk doctor --adapter resend
```
`doctor` checks whether the selected adapter has the required environment variables or matching CLI credential flags.
## Version [#version]
```bash
npx email-sdk version
npx email-sdk --version
npx email-sdk -v
npx email-sdk version --json
```
`version` reads the installed `@opencoredev/email-sdk` package metadata, so it reports the SDK and CLI version from the package currently on your machine. The docs version picker uses the same package version from this repository.
## Flags [#flags]
| Flag | Adapter | Notes |
| ----------------------- | ----------------- | --------------------------------------------------------------- |
| `--adapter` | all | Adapter routing name. |
| `--provider` | all | Alias for `--adapter`. |
| `--from` | all | Sender address. |
| `--to` | all | Recipient address. Comma-separated values are allowed. |
| `--subject` | all | Message subject. |
| `--text` | all | Plain text body. |
| `--html` | all | HTML body. |
| `--cc` | send | Comma-separated CC addresses. |
| `--bcc` | send | Comma-separated BCC addresses. |
| `--reply-to` | send | Comma-separated reply-to addresses. |
| `--header` | send | Header in `Name: value` format. Repeatable. |
| `--tag` | send | Tag in `name=value` format. Repeatable. |
| `--metadata` | send | Metadata in `key=value` format. Repeatable. |
| `--attachment` | send | Local file path, optionally `path:content/type`. |
| `--message` | send | Read an `EmailMessage` JSON payload from a file. |
| `--dry-run` | all | Validate the message and adapter field support without sending. |
| `--json` | version, adapters | Print output as JSON. |
| `--api-key` | many | Overrides the adapter API key variable. |
| `--api-token` | Cloudflare | Overrides `CLOUDFLARE_API_TOKEN`. |
| `--account-id` | Cloudflare | Overrides `CLOUDFLARE_ACCOUNT_ID`. |
| `--base-url` | Cloudflare | Overrides `CLOUDFLARE_BASE_URL`. |
| `--server-token` | Postmark | Overrides `POSTMARK_SERVER_TOKEN`. |
| `--message-stream` | Postmark | Optional message stream. |
| `--access-key-id` | AWS SES | Overrides `AWS_ACCESS_KEY_ID`. |
| `--secret-access-key` | AWS SES | Overrides `AWS_SECRET_ACCESS_KEY`. |
| `--region` | AWS SES | Overrides `AWS_REGION`. |
| `--session-token` | AWS SES | Overrides `AWS_SESSION_TOKEN`. |
| `--configuration-set` | AWS SES | Overrides `AWS_SES_CONFIGURATION_SET`. |
| `--domain` | Mailgun | Overrides `MAILGUN_DOMAIN`. |
| `--transactional-id` | Loops | Overrides `LOOPS_TRANSACTIONAL_ID`. |
| `--project-id` | Scaleway | Overrides `SCALEWAY_PROJECT_ID`. |
| `--secret-key` | Scaleway | Overrides `SCALEWAY_SECRET_KEY`. |
| `--token` | ZeptoMail | Overrides `ZEPTOMAIL_TOKEN`. |
| `--host` | SMTP | Overrides `SMTP_HOST`. |
| `--port` | SMTP | Overrides `SMTP_PORT`. |
| `--secure` | SMTP | Set to `true` for secure SMTP. |
| `--require-tls` | SMTP | Require STARTTLS. |
| `--allow-insecure-auth` | SMTP | Allow SMTP AUTH without TLS. |
| `--user` | SMTP | Overrides `SMTP_USER`. |
| `--pass` | SMTP | Overrides `SMTP_PASS`. |
The CLI prints the normalized adapter response as JSON.
# Client (/docs/reference/client)
## `createEmailClient(options)` [#createemailclientoptions]
Creates an email client.
```ts
const email = createEmailClient({
adapters,
defaultAdapter,
fallback,
retry,
hooks,
plugins,
});
```
| Option | Type | Default |
| ----------------- | ------------------ | -------------------- |
| `adapters` | `EmailProvider[]` | `[]` |
| `providers` | `EmailProvider[]` | Alias for `adapters` |
| `defaultAdapter` | `string` | First adapter |
| `defaultProvider` | `string` | First adapter |
| `fallback` | `string[]` | `[]` |
| `retry` | `EmailRetryConfig` | No retries |
| `hooks` | `EmailHooks` | No hooks |
| `plugins` | `EmailPlugin[]` | `[]` |
Routing names must be unique. At least one adapter must be registered directly or through a plugin.
`fallback` is an ordered list of adapter routing names tried after the selected adapter fails. `retry` applies inside the current adapter before Email SDK advances to a fallback route.
## `email.send(message, options?)` [#emailsendmessage-options]
Sends one message.
```ts
const result = await email.send(message, {
adapter: "resend",
fallbackAdapters: ["smtp"],
retries: 2,
idempotencyKey: "receipt:order_123",
metadata: {
route: "checkout.receipt",
},
});
```
| Option | Type | Notes |
| ------------------- | ------------------------- | ------------------------------------------------------------------------- |
| `adapter` | `string` | Override the default routing name. |
| `provider` | `string` | Alias for `adapter`. |
| `fallbackAdapters` | `string[]` | Replace client-level `fallback` for this send. Pass `[]` for no fallback. |
| `fallbackProviders` | `string[]` | Alias for `fallbackAdapters`. |
| `retries` | `number` | Replace client-level retry count for this send. |
| `signal` | `AbortSignal` | Abort provider work when supported. |
| `idempotencyKey` | `string` | Passed to adapters that support it. |
| `metadata` | `Record` | Passed to hooks. |
## Execution order [#execution-order]
For one send, Email SDK:
1. Runs before-send middleware.
2. Validates the normalized message.
3. Builds the route order from the selected adapter and fallback adapters.
4. Tries the selected adapter.
5. Retries that adapter when retry rules allow it.
6. Moves to the next fallback adapter after final failure.
7. Returns the first successful response.
8. Throws if every route fails.
`provider` and `fallbackProviders` are compatibility aliases only. Prefer `adapter` and `fallbackAdapters` in new code.
## `email.sendBatch(messages, options?)` [#emailsendbatchmessages-options]
Sends messages one at a time and returns one result per item.
```ts
const results = await email.sendBatch([messageA, messageB]);
```
`sendBatch` does not throw for the first failed item. Failed sends are returned as `{ ok: false, index, error }`.
## `email.adapter(name)` [#emailadaptername]
Returns a registered adapter or throws `EmailProviderNotFoundError`.
```ts
const resendAdapter = email.adapter("resend");
```
`email.provider(name)` is kept as an alias.
## `email.withAdapter(name)` [#emailwithadaptername]
Returns a small client bound to one adapter.
```ts
const transactional = email.withAdapter("postmark");
await transactional.send(message);
```
`email.withProvider(name)` is kept as an alias.
# Community registry (/docs/reference/community-registry)
The community registry lives at `apps/fumadocs/content/community/plugins.json`. It is a static JSON file rendered by the docs site for third-party plugins, adapters, and hybrid packages.
Adapter packages use this registry too. Set `kind: "adapter"`, include the adapter routing name in `adapter`, and include the plugin factory export in `importName` when the package exposes one.
## Entry shape [#entry-shape]
```ts
type CommunityRegistryEntry = {
name: string;
package: string;
kind: "adapter" | "plugin" | "hybrid";
status: "community" | "verified" | "official";
description: string;
href: string;
repo: string;
maintainer: string;
pluginId?: string;
adapter?: string;
importName?: string;
verifiedVersion?: string;
verification?: {
reviewedAt: string;
reviewedBy: string;
provenance: boolean;
noInstallScripts: boolean;
runtimeDependencies: number;
notes?: string;
};
};
```
## Status labels [#status-labels]
| Status | Meaning |
| ----------- | ------------------------------------------------------- |
| `community` | Listed package. The package is not endorsed or audited. |
| `verified` | Passed registry checks for `verifiedVersion`. |
| `official` | Maintained by OpenCore or in the Email SDK repository. |
Verification applies to one package version. New releases must be checked before the registry entry updates `verifiedVersion`.
## Verified package rules [#verified-package-rules]
Verified entries must:
1. Use npm trusted publishing or provenance.
2. Use a public source repository that matches package metadata.
3. Avoid `preinstall`, `install`, and `postinstall` scripts.
4. Declare `@opencoredev/email-sdk` as a peer dependency.
5. Avoid package binaries.
6. Keep runtime dependencies small and documented.
7. Avoid suspicious install-time or import-time behavior.
Run the registry check before opening a pull request.
```bash
bun run community:check
```
In CI, verified and official entries also download the published npm tarball and check the package metadata, install scripts, peer dependency, binary field, repository link, runtime dependency count, and a small set of suspicious JavaScript tokens.
The check is intentionally static. It lowers obvious supply-chain risk, but it does not prove that third-party code is harmless. Users should still read the source, pin versions, and apply their normal dependency review process.
# Errors (/docs/reference/errors)
Email SDK exports four error classes.
```ts
import {
EmailProviderError,
EmailProviderNotFoundError,
EmailSdkError,
EmailValidationError,
} from "@opencoredev/email-sdk";
```
## Error fields [#error-fields]
`EmailSdkError` includes:
| Field | Type | Notes |
| ----------- | --------- | --------------------------------------- |
| `code` | `string` | Machine-readable error code. |
| `provider` | `string` | Routing name when available. |
| `status` | `number` | HTTP status when available. |
| `retryable` | `boolean` | Whether retry is reasonable. |
| `details` | `unknown` | Adapter response body or extra context. |
## Common errors [#common-errors]
| Error | When it happens |
| ---------------------------- | --------------------------------------- |
| `EmailValidationError` | A message or client config is invalid. |
| `EmailProviderNotFoundError` | The selected adapter is not registered. |
| `EmailProviderError` | An adapter call fails. |
| `EmailSdkError` | A general SDK-level failure occurs. |
## Retries and fallbacks [#retries-and-fallbacks]
`retryable` means retrying the same adapter is reasonable. It is not required for fallback. After an adapter finally fails, Email SDK can advance to the next configured fallback adapter.
`EmailValidationError` from top-level message validation stops the send before adapter attempts. Adapter-specific unsupported-field validation can surface during an adapter attempt, so do not rely on fallback to fix an incompatible message shape. Choose fallback adapters that can represent the same fields your message uses.
When multiple attempted routes fail, Email SDK throws an `EmailSdkError` with code `all_providers_failed` and the collected failures in `details`.
## Handling adapter errors [#handling-adapter-errors]
```ts
try {
await email.send(message);
} catch (error) {
if (error instanceof EmailProviderError && error.retryable) {
// Queue a later retry, alert, or switch to another flow.
}
throw error;
}
```
# Message (/docs/reference/message)
## `EmailMessage` [#emailmessage]
```ts
type EmailMessage = {
from: EmailAddress;
to: EmailAddress | EmailAddress[];
subject: string;
html?: string;
text?: string;
cc?: EmailAddress | EmailAddress[];
bcc?: EmailAddress | EmailAddress[];
replyTo?: EmailAddress | EmailAddress[];
headers?: Record | EmailHeader[];
attachments?: EmailAttachment[];
tags?: EmailTag[];
metadata?: Record;
idempotencyKey?: string;
};
```
Every message must include:
* `from`
* at least one `to`
* `subject`
* `html` or `text`
## Addresses [#addresses]
An address can be a string or an object with a display name.
```ts
const from = "Acme ";
const to = {
email: "user@example.com",
name: "Ada Lovelace",
};
```
## Attachments [#attachments]
```ts
type EmailAttachment = {
filename: string;
content?: string | Uint8Array | ArrayBuffer | Blob;
contentEncoding?: "raw" | "base64";
path?: string;
contentType?: string;
contentId?: string;
disposition?: "attachment" | "inline";
};
```
Use `content` for in-memory attachments. String content is treated as raw content and encoded to Base64 for API providers that require it. Use `contentEncoding: "base64"` when the string is already Base64.
Use `path` when you want Email SDK to read a local file before sending. See field support for adapter-specific attachment support.
## Headers and tags [#headers-and-tags]
Headers can be an object or a list:
```ts
headers: {
"X-Order-ID": "ord_123",
}
```
Tags are provider-specific. Resend accepts `tags`. Postmark maps the first tag to its `Tag` field.