Hooks
Observe every send attempt — retries, fallback routing, failures — without ever changing the message or the result.
Hooks are read-only observers of the send pipeline: logs, metrics, traces. They fire for every adapter attempt, they cannot change the message, and a throwing hook never breaks a send. To transform messages, use plugin middleware instead — that distinction is the whole design.
import { createEmailClient } from "@opencoredev/email-sdk";
import { resend } from "@opencoredev/email-sdk/resend";
const email = createEmailClient({
adapters: [resend({ apiKey: process.env.RESEND_API_KEY! })],
retry: { retries: 2 },
hooks: {
beforeSend({ provider, attempt, metadata }) {
console.log("email.attempt", { provider, attempt, route: metadata?.route });
},
afterSend({ provider, attempt, response }) {
console.log("email.sent", { provider, attempt, id: response.id });
},
onRetry({ provider, nextAttempt, delayMs, error }) {
console.warn("email.retry", { provider, nextAttempt, delayMs, error });
},
onError({ provider, attempt, error }) {
console.error("email.failed", { provider, attempt, error });
},
},
});
await email.send(message, { metadata: { route: "checkout.receipt" } });The metadata here is send-scope: it flows to every hook event for correlation but is never merged into the message — unlike message.metadata, which adapters map to the wire.
The four events
Every event carries the same base payload:
Prop
Type
Each event adds its own fields:
| Event | Fires | Extra fields |
|---|---|---|
beforeSend | Before every attempt, on every adapter | — |
afterSend | Once, on the attempt that succeeded | response (normalized, provider always set) |
onRetry | When the same adapter will be retried, before the backoff sleep | error, nextAttempt, delayMs |
onError | Once per adapter, after its final failure — before fallback continues | error |
So a send with retries: 2 and one fallback adapter can fire beforeSend up to six times, onRetry up to four, onError up to twice, and afterSend at most once.
Hooks observe, middleware transforms
Hooks are deliberately powerless. When you need to change a send — add default headers, stamp idempotency keys, rewrite recipients — that is plugin middleware, a separate mechanism with different rules:
| Hooks | Plugin middleware beforeSend | |
|---|---|---|
| Registered via | Client hooks option or plugin.hooks | Plugins only (plugin.middleware) |
| Runs | Per attempt, per adapter | Once per send, before validation and routing |
| Knows the adapter | Yes (provider on every event) | No — it runs before the route is resolved |
| Can change the send | Never | Yes — returns { message?, options? } |
| If it throws | Swallowed, send unaffected | Send fails |
const brandPlugin = {
id: "brand",
middleware: [
{
beforeSend({ message }) {
return { message: { ...message, headers: { "X-App": "acme", ...message.headers } } };
},
},
],
};defaultsPlugin is this pattern packaged up. The full middleware surface (beforeSend, afterSend, onError) is in the plugin API reference.
Guarantees
- Failures are swallowed. A hook that throws (or rejects) is ignored — observability must never mask a send result. The flip side: never put delivery-critical logic in a hook.
- Hooks are awaited in order. Async hooks run sequentially: plugin hooks first, then client hooks. Slow hooks slow down sends, so keep them fast or fire-and-forget internally.
- Hooks see the final message. Middleware transforms run first, so
event.messageis what the adapter actually receives.
Production telemetry and tests
Hand-rolled console.log hooks leak recipients and bodies into logs sooner or later. Two built-in plugins cover the common cases:
observabilityPluginemitsemail.sent/email.retry/email.errorevents with a redacted message shape — counts and flags, no bodies or recipients. Use it for production logging, metrics, and tracing.capturePluginrecords every hook event to an inspectable store — use it to assert send behavior in tests.
