Email SDK
GuidesBuild

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.

acme-mail.ts
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.

acme-mail.ts
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:

acme-mail.ts
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:

acme-mail.ts
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:

plugin.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)],
  };
}
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.

acme-mail.test.ts
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, id or messageId, and raw on every response.
  • context.signal forwarded into provider requests; context.idempotencyKey forwarded when supported.
  • Unsupported fields throw EmailValidationError — never dropped.
  • Provider failures throw EmailProviderError with status and an honest retryable.
  • Injected fetch accepted and documented.

Next

On this page