Email SDK
Concepts

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.

lib/email.ts
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:

EventFiresExtra fields
beforeSendBefore every attempt, on every adapter
afterSendOnce, on the attempt that succeededresponse (normalized, provider always set)
onRetryWhen the same adapter will be retried, before the backoff sleeperror, nextAttempt, delayMs
onErrorOnce per adapter, after its final failure — before fallback continueserror

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:

HooksPlugin middleware beforeSend
Registered viaClient hooks option or plugin.hooksPlugins only (plugin.middleware)
RunsPer attempt, per adapterOnce per send, before validation and routing
Knows the adapterYes (provider on every event)No — it runs before the route is resolved
Can change the sendNeverYes — returns { message?, options? }
If it throwsSwallowed, send unaffectedSend fails
A transform belongs in middleware, not a hook
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.message is 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:

  • observabilityPlugin emits email.sent / email.retry / email.error events with a redacted message shape — counts and flags, no bodies or recipients. Use it for production logging, metrics, and tracing.
  • capturePlugin records every hook event to an inspectable store — use it to assert send behavior in tests.

Next

On this page