Hooks
Observe every send attempt (retries, fallback routing, failures) without 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 does not break a send. To transform messages, use plugin middleware instead. Keeping observation and transformation separate is deliberate: you can add logging anywhere without worrying about breaking delivery.
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 not merged into the message. That makes it different from 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.
One exception: a recipient-variables batch on an adapter without native batch support becomes one real send per recipient, so afterSend fires once per delivered recipient and onError once per failed one.
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, by returning { 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, because observability must not mask a send result. The flip side: don't put delivery-critical logic in a hook, since its errors disappear.
- 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.
