EmailMiddlewareMiddleware

Middleware

Retry, rate-limit, circuit-breaker, dedupe, logging, and OAuth2 send middleware for @visulima/email

Middleware

The @visulima/email/middleware entry point ships composable middlewares that wrap the provider's sendEmail call — adding retries, rate limiting, circuit breaking, deduplication, logging, and OAuth2 credential injection without coupling any of that logic to your provider adapter.

import {
    retryMiddleware,
    rateLimitMiddleware,
    RATE_LIMIT_PRESETS,
    circuitBreakerMiddleware,
    dedupeMiddleware,
    loggingMiddleware,
    oauth2Middleware,
    composeMiddleware,
} from "@visulima/email/middleware";

Attaching Middleware

Register middleware on a Mail instance with mail.use(). It is chainable, and middlewares run in registration order — the first registered is the outermost wrapper around the provider call.

import { createMail } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { rateLimitMiddleware, retryMiddleware } from "@visulima/email/middleware";

const mail = createMail(resendProvider({ apiKey: "re_xxx" }))
    .use(rateLimitMiddleware({ rate: 10 }))
    .use(retryMiddleware({ retries: 3 }));

In the chain above, every send first passes through the rate limiter (outermost), then the retry middleware, then the provider. Because rate limiting wraps retries, retried attempts are also paced by the limiter.

retryMiddleware

Retries failed sends with full-jitter exponential backoff (random(0, baseDelay * 2 ** attempt)), which avoids the thundering-herd problem a fixed retry schedule causes. After all attempts are exhausted, an optional onDeadLetter hook fires so you can persist or alert on the final failure.

type RetryMiddlewareOptions = {
    retries?: number; // max attempts including the first (default: 3)
    baseDelay?: number; // base backoff in ms (default: 200)
    shouldRetry?: (result: Result<EmailResult>) => boolean; // default: retry every failure
    onDeadLetter?: (options: EmailOptions, result: Result<EmailResult>) => Promise<void> | void;
    sleep?: (ms: number) => Promise<void>; // injectable for tests
};
import { retryMiddleware } from "@visulima/email/middleware";

mail.use(
    retryMiddleware({
        retries: 5,
        baseDelay: 250,
        shouldRetry: (result) => !result.success && result.error?.message.includes("rate"),
        onDeadLetter: async (options, result) => {
            await deadLetterQueue.push({ to: options.to, error: result.error });
        },
    }),
);

rateLimitMiddleware

Throttles sends with a token-bucket limiter. When capacity is exhausted it awaits capacity rather than failing, smoothing bursts to your sustained rate.

type RateLimitMiddlewareOptions = {
    rate: number; // sustained sends per second
    burst?: number; // max burst capacity in tokens (default: rate)
    now?: () => number; // time source in ms (default: Date.now)
    sleep?: (ms: number) => Promise<void>; // injectable for tests
};

RATE_LIMIT_PRESETS provides per-second presets tuned to common providers' default plans — override them for your own quota:

ProviderSends / second
resend10
sendgrid100
mailgun100
postmark100
aws-ses14
import { rateLimitMiddleware, RATE_LIMIT_PRESETS } from "@visulima/email/middleware";

mail.use(rateLimitMiddleware({ rate: RATE_LIMIT_PRESETS.resend }));

// Or allow short bursts above the sustained rate:
mail.use(rateLimitMiddleware({ rate: RATE_LIMIT_PRESETS.sendgrid, burst: 200 }));

circuitBreakerMiddleware

Trips a circuit after repeated provider failures: while open it fails fast (returning a CIRCUIT_OPEN error) instead of hammering a struggling provider. After resetTimeout, it moves to half-open and lets a single probe request through; success closes the circuit, failure re-opens it.

type CircuitBreakerMiddlewareOptions = {
    failureThreshold?: number; // consecutive failures before opening (default: 5)
    resetTimeout?: number; // ms to stay open before a half-open probe (default: 30000)
    onStateChange?: (state: "closed" | "half-open" | "open") => void;
    now?: () => number; // time source in ms (default: Date.now)
};
import { circuitBreakerMiddleware } from "@visulima/email/middleware";

mail.use(
    circuitBreakerMiddleware({
        failureThreshold: 5,
        resetTimeout: 30_000,
        onStateChange: (state) => console.warn(`[email] circuit ${state}`),
    }),
);

dedupeMiddleware

Suppresses duplicate sends of the same message within a TTL window — useful behind at-least-once delivery sources (queues, webhooks) that may replay a message. Repeats return the original cached result instead of re-sending. The default key is a SHA-256 content hash of recipients, subject, and body.

type DedupeMiddlewareOptions = {
    ttl?: number; // how long a key is remembered, in ms (default: 60000)
    keyFn?: (options: EmailOptions) => string; // default: content hash
    now?: () => number; // time source in ms (default: Date.now)
};
import { dedupeMiddleware } from "@visulima/email/middleware";

// Default content-hash dedupe within 5 minutes.
mail.use(dedupeMiddleware({ ttl: 5 * 60_000 }));

// Or dedupe on your own idempotency key from the message headers.
mail.use(
    dedupeMiddleware({
        keyFn: (options) => String(options.headers?.["X-Idempotency-Key"] ?? options.subject),
    }),
);

loggingMiddleware

Logs each send attempt and its outcome. Recipient addresses are redacted by default (e.g. j•••@example.com) to keep PII out of your logs. Pass redact: false in trusted environments, or a custom logger to route logs anywhere.

type LoggingMiddlewareOptions = {
    logger?: Pick<Console, "error" | "info">; // default: console
    redact?: boolean; // redact recipient addresses (default: true)
};
import { loggingMiddleware } from "@visulima/email/middleware";

mail.use(loggingMiddleware());

// With a custom logger and no redaction (e.g. a local dev environment):
mail.use(loggingMiddleware({ logger: myLogger, redact: false }));

oauth2Middleware

Injects an OAuth2 bearer credential into each outgoing message, caching it until just before expiry and refreshing on demand. Use it for Gmail / Microsoft 365, where access tokens are short-lived. The token can be injected as a request header (default) or handed to the onToken callback for SMTP XOAUTH2 clients.

type OAuth2Token = {
    accessToken: string;
    expiresAt?: number; // absolute epoch ms; omit for non-expiring tokens
};

type OAuth2MiddlewareOptions = {
    fetchToken: () => Promise<OAuth2Token> | OAuth2Token; // your refresh flow
    headerName?: string; // header to inject into (default: "Authorization")
    scheme?: string; // auth scheme prefix (default: "Bearer")
    skewMs?: number; // refresh this many ms before expiry (default: 60000)
    onToken?: (token: OAuth2Token) => void; // e.g. feed SMTP XOAUTH2
    now?: () => number; // time source in ms (default: Date.now)
};
import { oauth2Middleware } from "@visulima/email/middleware";

mail.use(
    oauth2Middleware({
        fetchToken: async () => {
            const { access_token, expires_in } = await refreshGoogleToken();

            return { accessToken: access_token, expiresAt: Date.now() + expires_in * 1000 };
        },
    }),
);

fetchToken is only called when the cached token is missing or within skewMs of expiry, so token refreshes are minimized across sends.

composeMiddleware

When you need to compose a middleware stack without a Mail instance — for testing, or to wrap a bare send function — use composeMiddleware(). It folds the middlewares (in registration order) around a terminal SendFunction.

import { composeMiddleware, retryMiddleware, dedupeMiddleware } from "@visulima/email/middleware";
import type { SendFunction } from "@visulima/email/middleware";

const terminal: SendFunction = async (options) => provider.sendEmail(options);

const send = composeMiddleware([dedupeMiddleware(), retryMiddleware({ retries: 3 })], terminal);

const result = await send(emailOptions);

A Realistic Stack

A production stack typically rate-limits to the provider's quota, fails fast when the provider is down, and retries transient failures. Order matters: register from outermost to innermost.

import { createMail } from "@visulima/email";
import { resendProvider } from "@visulima/email/providers/resend";
import { circuitBreakerMiddleware, RATE_LIMIT_PRESETS, rateLimitMiddleware, retryMiddleware } from "@visulima/email/middleware";

const mail = createMail(resendProvider({ apiKey: "re_xxx" }))
    .use(rateLimitMiddleware({ rate: RATE_LIMIT_PRESETS.resend }))
    .use(circuitBreakerMiddleware())
    .use(retryMiddleware({ retries: 3 }));

Every send is first paced by the rate limiter, then short-circuited by the circuit breaker if Resend is unhealthy, and finally retried with backoff on transient failures before reaching the provider.

Support

Contribute to our work and keep us going

Community is the heart of open source. The success of our packages wouldn't be possible without the incredible contributions of users, testers, and developers who collaborate with us every day.Want to get involved? Here are some tips on how you can make a meaningful impact on our open source projects.

Ready to help us out?

Be sure to check out the package's contribution guidelines first. They'll walk you through the process on how to properly submit an issue or pull request to our repositories.

Submit a pull request

Found something to improve? Fork the repo, make your changes, and open a PR. We review every contribution and provide feedback to help you get merged.

Good first issues

Simple issues suited for people new to open source development, and often a good place to start working on a package.
View good first issues