Create an adapter
Implement the EmailProvider contract for a new provider — payload mapping, fail-fast field validation, retryable error mapping, and tests with injected fetch.
This guide builds a complete adapter for a fictional provider, Acme Mail, the same way the SDK's own adapters are built: a factory that returns an EmailProvider, fail-fast validation of unsupported fields, error mapping with retryability, a plugin wrapper for distribution, and tests that never touch the network.
The contract is one object: a name used for routing and a send(message, context) that returns a normalized response or throws a typed error. Exact types are in the adapter contract reference.
Scaffold the factory
Accept credentials plus two options every adapter should have: baseUrl for proxies and fetch for tests. Pass context.signal into the request so callers can abort sends.
import { EmailProviderError } from "@opencoredev/email-sdk";
import type { EmailMessage, EmailProvider } from "@opencoredev/email-sdk";
export type AcmeMailOptions = {
apiKey: string;
baseUrl?: string;
fetch?: typeof fetch;
};
export function acmeMail(options: AcmeMailOptions): EmailProvider<{ baseUrl: string }> {
const baseUrl = options.baseUrl ?? "https://api.acme-mail.example";
const fetcher = options.fetch ?? fetch;
return {
name: "acme-mail",
raw: { baseUrl },
async send(message, context) {
const response = await fetcher(`${baseUrl}/v1/send`, {
method: "POST",
signal: context.signal,
headers: {
Authorization: `Bearer ${options.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(toAcmePayload(message)),
});
if (!response.ok) {
throw await toAcmeError(response);
}
const body = (await response.json()) as { id?: string };
return { provider: "acme-mail", id: body.id, messageId: body.id, raw: body };
},
};
}The response shape is { provider, id?, messageId?, accepted?, rejected?, raw? }. Always set provider and keep the untouched API body in raw. The context also carries idempotencyKey and attempt — forward the key when the provider supports it (Resend sends it as an Idempotency-Key header).
Reject unsupported fields before the request
Acme Mail's API accepts addresses, subject, body, and tags — no headers, attachments, or metadata. Do not drop those fields: throw an EmailValidationError listing them, matching the SDK's own adapters. This is what makes the adapter safe to use as a fallback route.
import { EmailValidationError } from "@opencoredev/email-sdk";
function assertAcmeFields(message: EmailMessage) {
const unsupported: string[] = [];
if (hasEntries(message.headers)) unsupported.push("headers");
if (message.attachments?.length) unsupported.push("attachments");
if (hasEntries(message.metadata)) unsupported.push("metadata");
if (unsupported.length > 0) {
throw new EmailValidationError(
`acme-mail does not support these EmailMessage fields: ${unsupported.join(", ")}.`,
{ adapter: "acme-mail", unsupported },
);
}
}
function hasEntries(value: object | unknown[] | undefined) {
if (!value) return false;
return Array.isArray(value) ? value.length > 0 : Object.keys(value).length > 0;
}Core validation (missing from, no recipient, neither html nor text) already ran before your adapter is called — only check what is specific to your provider.
Map the payload
Keep the mapping in one pure function so it is trivially testable. EmailAddress is string | { email, name? } and recipient fields accept one value or an array, so normalize both:
function toAcmePayload(message: EmailMessage) {
assertAcmeFields(message);
return {
from: formatAddress(message.from),
to: formatList(message.to),
cc: formatList(message.cc),
bcc: formatList(message.bcc),
reply_to: formatList(message.replyTo),
subject: message.subject,
html: message.html,
text: message.text,
tags: message.tags,
};
}
function formatAddress(address: EmailMessage["from"]) {
if (typeof address === "string") return address;
return address.name ? `${address.name} <${address.email}>` : address.email;
}
function formatList(addresses: EmailMessage["cc"]) {
if (!addresses) return undefined;
return (Array.isArray(addresses) ? addresses : [addresses]).map(formatAddress);
}Map errors with retryability
Throw EmailProviderError with status, the parsed body in details, and an honest retryable flag. The client's retry loop and isRetryableEmailError are driven by that flag — the SDK's adapters treat 408, 409, 425, 429, and 5xx as retryable:
async function toAcmeError(response: Response) {
const body = (await response.json().catch(() => undefined)) as { message?: string } | undefined;
return new EmailProviderError(
body?.message
? `acme-mail failed with ${response.status}: ${body.message}`
: `acme-mail failed with HTTP ${response.status}.`,
{
provider: "acme-mail",
status: response.status,
retryable: [408, 409, 425, 429].includes(response.status) || response.status >= 500,
details: body,
},
);
}Network failures thrown by fetch need no handling — the client wraps them as retryable provider errors automatically.
Wrap it as a plugin
Apps can mount the adapter directly via adapters: [acmeMail(...)]. For a shareable package, also export a plugin wrapper so consumers get a one-line install in plugins:
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)],
};
}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! })],
});Test with injected fetch
The injected fetch makes the adapter fully testable without mocks or network. Cover the three behaviors that matter: payload mapping, error retryability, and unsupported-field rejection.
import { expect, test } from "bun:test";
import { EmailProviderError, EmailValidationError } from "@opencoredev/email-sdk";
import { acmeMail } from "./acme-mail";
const message = {
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Hello",
text: "Hi",
};
test("maps the message to the provider payload", async () => {
const calls: unknown[] = [];
const adapter = acmeMail({
apiKey: "test",
fetch: async (_url, init) => {
calls.push(JSON.parse(String(init?.body)));
return Response.json({ id: "msg_123" });
},
});
const response = await adapter.send(message, { attempt: 1 });
expect(response.messageId).toBe("msg_123");
expect(calls[0]).toMatchObject({ from: "Acme <hello@acme.com>", to: ["user@example.com"] });
});
test("marks rate limits as retryable", async () => {
const adapter = acmeMail({
apiKey: "test",
fetch: async () => Response.json({ message: "slow down" }, { status: 429 }),
});
const error = await adapter.send(message, { attempt: 1 }).catch((cause) => cause);
expect(error).toBeInstanceOf(EmailProviderError);
expect(error.retryable).toBe(true);
});
test("rejects unsupported fields before any request", async () => {
const adapter = acmeMail({
apiKey: "test",
fetch: async () => {
throw new Error("must not be called");
},
});
await expect(
adapter.send({ ...message, attachments: [{ filename: "a.txt", content: "x" }] }, { attempt: 1 }),
).rejects.toBeInstanceOf(EmailValidationError);
});Run bun test — three green tests and zero network calls.
Checklist
- One stable adapter
name; document it as the routing name. provider,idormessageId, andrawon every response.context.signalforwarded into provider requests;context.idempotencyKeyforwarded when supported.- Unsupported fields throw
EmailValidationError— never dropped. - Provider failures throw
EmailProviderErrorwithstatusand an honestretryable. - Injected
fetchaccepted and documented.
