Advanced
Failover and Middleware
Add transport failover and shared middleware to the email example
This page takes the small email + SMTP setup and makes it more production-shaped without changing the route contract.
You still define the same welcome route. The difference is that delivery now has backups and shared behavior around it:
- one primary SMTP transport
- one backup SMTP transport
- failover between them
- tracing for each send
- event logging for visibility
- rate limiting per recipient
Full example
import { createClient, createNotify } from '@betternotify/core';
import { withEventLogger, withRateLimit, withTracing } from '@betternotify/core/middlewares';
import { consoleEventSink } from '@betternotify/core/sinks';
import { inMemoryRateLimitStore } from '@betternotify/core/stores';
import { inMemoryTracer } from '@betternotify/core/tracers';
import { emailChannel, multiTransport } from '@betternotify/email';
import { smtpTransport } from '@betternotify/smtp';
import { z } from 'zod';
const email = emailChannel({
defaults: {
from: { name: 'My App', email: process.env.SMTP_USER! },
},
});
const tracer = inMemoryTracer();
const rateLimitStore = inMemoryRateLimitStore();
const rpc = createNotify({ channels: { email } })
.use(withTracing({ tracer }))
.use(withEventLogger({ sink: consoleEventSink() }))
.use(
withRateLimit({
store: rateLimitStore,
key: ({ args }) => String(args.to),
max: 3,
window: 60_000,
}),
);
const catalog = rpc.catalog({
welcome: rpc
.email()
.input(
z.object({
name: z.string(),
verifyUrl: z.string().url(),
}),
)
.subject(({ input }) => `Welcome, ${input.name}!`)
.template({
render: async ({ input }) => ({
text: `Welcome, ${input.name}! Verify here: ${input.verifyUrl}`,
html: `<p>Welcome, ${input.name}! <a href="${input.verifyUrl}">Verify</a></p>`,
}),
}),
});
const emailTransport = multiTransport({
name: 'smtp-failover',
strategy: 'failover',
transports: [
{
transport: smtpTransport({
host: process.env.SMTP_HOST!,
port: Number(process.env.SMTP_PORT ?? 587),
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
}),
},
{
transport: smtpTransport({
host: process.env.BACKUP_SMTP_HOST!,
port: Number(process.env.BACKUP_SMTP_PORT ?? 587),
auth: {
user: process.env.BACKUP_SMTP_USER!,
pass: process.env.BACKUP_SMTP_PASS!,
},
}),
},
],
});
const mail = createClient({
catalog,
transportsByChannel: { email: emailTransport },
});
await mail.welcome.send({
to: 'john@example.com',
input: {
name: 'John Doe',
verifyUrl: 'https://example.com/verify?token=abc123',
},
});What changes here
The route API stays the same. You still call:
await mail.welcome.send({ to, input });What changes is the pipeline behind that call:
multiTransport({ strategy: 'failover' })tries the first SMTP transport, then advances to the backup if the first one fails.withTracing(...)wraps the send in a span, so you can inspect notification work alongside the rest of your app.withEventLogger(...)emits lifecycle events for each send.withRateLimit(...)prevents a single recipient from being sent the same route too aggressively.
inMemoryTracer() and inMemoryRateLimitStore() keep the example short. In production, replace them with your own tracer and a shared rate-limit store.
Where to go next
- Read Multi-Transport for other strategies such as
round-robin,race, andparallel. - Read Middleware for the middleware execution model and where plugin middleware fits.