Email SDK
Concepts

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.

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 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:

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.

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:

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, by returning { 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, 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.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