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. No base class, 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 — hooks 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 — 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