Create an adapter
Write your first custom provider 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
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
Keep provider-specific mapping in a helper. That keeps the adapter easy to test.
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
Direct adapter registration is useful inside one app, one monorepo, or tests.
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
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.
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:
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 after the adapter tests pass.
Handle unsupported fields
Do not silently drop fields your provider cannot send. Throw a clear error instead.
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
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
- Use one stable adapter
name. - Return
provider,idormessageId, andraw. - Pass
context.signalinto provider requests. - Respect
context.idempotencyKeywhen 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-sdkas a peer dependency.
See Adapter contract for the exact type reference.
