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… | Use | Why |
|---|---|---|
| Add defaults or rewrite the message | middleware.beforeSend | Runs once per send, before validation; its return value replaces message/options. |
| Block a send (policy, guardrails) | middleware.beforeSend | Thrown errors propagate and stop the send; hook errors are swallowed. |
| Log, count, or trace attempts | hooks | Per-attempt events including onRetry; failures never mask the send. |
| React to the final outcome | middleware.afterSend / onError | Fire 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 castExtensions 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
id→EmailValidationError. Factories that can be mounted twice should accept anidoption. - Duplicate adapter name (across direct
adaptersand all plugins) →EmailValidationError. - Async
adaptersfactory →EmailValidationError. extendClientkey collision →EmailValidationError.- Plugin hooks run before client
hooks; middleware runs in plugin order.
Next
Create your first plugin
Guided build of a policy plugin with a typed client extension.
Plugin API reference
Exact types for EmailPlugin, EmailPluginContext, and middleware events.
Publish a community plugin
Package, document, and list your plugin in the community registry.
Publish a community adapter
Ship a provider package with a one-call plugin setup path.
