Email SDK

Writing plugins

Build an EmailPlugin. Choose hooks or middleware, register adapters, and add typed client extensions.

A plugin is a plain object: a stable id plus any combination of adapters, hooks, middleware, and extendClient. There is no base class and no registration ceremony. Export a factory function that returns the object.

import type { EmailPlugin } from "@opencoredev/email-sdk";

export function auditPlugin(): EmailPlugin {
  return {
    id: "audit",
    middleware: [
      {
        afterSend(event) {
          console.log("sent", event.provider, event.response.id);
        },
      },
    ],
  };
}

This page covers each capability and when to reach for it. For a guided build of a complete policy plugin, see Create your first plugin.

Hooks or middleware?

Both watch the send pipeline; only middleware can change it. Pick by intent:

You want to…UseWhy
Add defaults or rewrite the messagemiddleware.beforeSendRuns once per send, before validation; its return value replaces message/options.
Block a send (policy, guardrails)middleware.beforeSendThrown errors propagate and stop the send; hook errors are swallowed.
Log, count, or trace attemptshooksPer-attempt events including onRetry; failures never mask the send.
React to the final outcomemiddleware.afterSend / onErrorFire on success and on per-route failure; observe-only, errors swallowed.
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 can also transform: return { message?, options? } and the pipeline continues with your values. The returned message replaces the message wholesale; returned options shallow-merge over the current send options. The defaults plugin is exactly this pattern.

Plugin hooks use the same shape as the client hooks option and run before client hooks. See Hooks for the event fields.

Register adapters

Plugins can ship providers, which is how community adapter packages offer one-call setup:

import type { EmailPlugin } from "@opencoredev/email-sdk";

export function acmeMail(options: { apiKey: string }): EmailPlugin {
  return {
    id: "acme-mail",
    adapters: [createAcmeAdapter(options)], // an EmailProvider
  };
}
const email = createEmailClient({
  plugins: [acmeMail({ apiKey: process.env.ACME_MAIL_API_KEY! })],
});

For dynamic registration, adapters can be a function receiving an EmailPluginContext: inspect already-registered routes via ctx.adapters, read ctx.defaultAdapter, and call ctx.addAdapter().

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 [];
    },
  };
}

Two rules, both enforced with an EmailValidationError: adapter factories must be synchronous (createEmailClient is sync, so returning a Promise throws), and duplicate adapter names throw. Building the adapter itself is its own topic: Create an adapter.

Extend the client

extendClient returns properties merged onto the client. Type the plugin as EmailPlugin<TExtension> and the extension flows into the client type. createEmailClient infers the intersection of all plugin extensions, so callers get autocomplete with no casts:

export function routeNamesPlugin(): EmailPlugin<{ routeNames: string[] }> {
  return {
    id: "route-names",
    extendClient(ctx) {
      return { routeNames: [...ctx.adapters.keys()] };
    },
  };
}

const email = createEmailClient({
  adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
  plugins: [routeNamesPlugin()],
});

email.routeNames; // string[], typed, no cast

Extensions run after the client is built. A key that already exists on the client (built-ins like send or adapters, or a key claimed by an earlier plugin) throws: Email plugin "<id>" tried to extend the client with reserved key "<key>". Keep extensions small: a store, a helper, a few readonly values. The capture plugin shows the pattern, including a configurable typed key.

Registration rules

createEmailClient processes plugins in array order and fails fast on conflicts:

  • Duplicate plugin idEmailValidationError. Factories that can be mounted twice should accept an id option.
  • Duplicate adapter name (across direct adapters and all plugins) → EmailValidationError.
  • Async adapters factory → EmailValidationError.
  • extendClient key collision → EmailValidationError.
  • Plugin hooks run before client hooks; middleware runs in plugin order.

Next

On this page