Create your first plugin
Build an audit-log plugin end to end — middleware that records every send and a typed client extension that exposes the log.
This guide builds a real plugin from scratch: an audit log that records the outcome of every send and exposes the entries through a typed email.auditLog property. Along the way you use the two plugin capabilities that matter most — middleware for the send pipeline and extendClient for typed client extensions.
A plugin is a plain object: { id, adapters?, hooks?, middleware?, extendClient? }. The full lifecycle is in writing plugins; exact types in the plugin API.
Define the entry shape and factory
Type the extension first. EmailPlugin<TExtension> is generic over what extendClient returns — that is what makes email.auditLog fully typed later.
import type { EmailPlugin } from "@opencoredev/email-sdk";
export type AuditLogEntry = {
at: Date;
status: "sent" | "failed";
provider: string;
attempt: number;
messageId?: string;
};
export type AuditLog = {
readonly entries: AuditLogEntry[];
clear(): void;
};
export function auditLogPlugin(): EmailPlugin<{ auditLog: AuditLog }> {
const entries: AuditLogEntry[] = [];
return {
id: "audit-log",
// middleware and extendClient added in the next steps
};
}Keep recipients and bodies out of the entry shape — an audit log should answer "did the receipt for order 42 go out, through which route?" without becoming a PII store. Put correlation data in send-scope metadata instead.
Record outcomes with middleware
Middleware runs on the send pipeline. afterSend fires once per successful delivery; onError fires when a route has exhausted its retries. Add both to the returned object:
middleware: [
{
afterSend(event) {
entries.push({
at: new Date(),
status: "sent",
provider: event.provider,
attempt: event.attempt,
messageId: event.response.messageId ?? event.response.id,
});
},
onError(event) {
entries.push({
at: new Date(),
status: "failed",
provider: event.provider,
attempt: event.attempt,
});
},
},
],Exceptions thrown from afterSend and onError are swallowed — observers can never break a send. If you want a plugin that blocks sends (policy checks, allow-lists), throw from middleware beforeSend, which runs before validation and may also transform the message.
Expose the log with extendClient
extendClient returns properties merged onto the client. The keys must not collide with built-in client members (send, adapters, ...) or another plugin's extension — collisions throw an EmailValidationError at createEmailClient time.
extendClient() {
return {
auditLog: {
entries,
clear() {
entries.length = 0;
},
},
};
},The complete plugin is the factory from step 1 with the middleware and extendClient blocks inside the returned object.
Mount it and use the typed extension
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
import { auditLogPlugin } from "./audit-log-plugin";
export const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
plugins: [auditLogPlugin()],
});
await email.send({
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Welcome",
text: "Your account is ready.",
});
console.log(email.auditLog.entries);
// [{ at: ..., status: "sent", provider: "resend", attempt: 1, messageId: "..." }]email.auditLog is typed — TypeScript infers the intersection of all plugin extensions from the plugins array, so email.auditLog.entries[0]?.status autocompletes.
Test it
Pair the plugin with the testing adapters to verify both paths:
import { expect, test } from "bun:test";
import { createEmailClient } from "@opencoredev/email-sdk";
import { failingProvider, memoryProvider } from "@opencoredev/email-sdk/testing";
import { auditLogPlugin } from "./audit-log-plugin";
const message = {
from: "Acme <hello@acme.com>",
to: "user@example.com",
subject: "Welcome",
text: "Hello",
};
test("records successful sends", async () => {
const email = createEmailClient({
adapters: [memoryProvider()],
plugins: [auditLogPlugin()],
});
await email.send(message);
expect(email.auditLog.entries).toHaveLength(1);
expect(email.auditLog.entries[0]).toMatchObject({ status: "sent", provider: "memory" });
});
test("records failures", async () => {
const email = createEmailClient({
adapters: [failingProvider()],
plugins: [auditLogPlugin()],
});
await expect(email.send(message)).rejects.toThrow("Provider failed");
expect(email.auditLog.entries[0]).toMatchObject({ status: "failed", provider: "failing" });
});bun test passes with no network and no credentials.
Rules of thumb
- One stable
idper plugin; make it configurable if users may mount two instances. middleware.beforeSendfor anything that blocks or transforms;afterSend,onError, and hooks for observation only.- Keep
extendClientsurfaces small, and never reuse built-in client member names. - Each factory call should own its state — two clients with two
auditLogPlugin()instances must not share entries.
Next
Test email behavior
Assert sends, fallback routing, and retries in unit tests with the memory adapter, failing adapter, and capture plugin — no provider account needed.
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.
