@rudderjs/mail is the framework's email layer. You define an email as a Mailable class, configure a transport (log for dev, SMTP for production), and send with Mail.to(address).send(...). Switching transports is a config change.
Setup
pnpm add @rudderjs/mailFor SMTP add the Nodemailer driver:
pnpm add nodemailer
pnpm add -D @types/nodemailer// config/mail.ts
import { Env } from '@rudderjs/support'
import type { MailConfig } from '@rudderjs/mail'
export default {
default: Env.get('MAIL_MAILER', 'log'),
from: {
address: Env.get('MAIL_FROM_ADDRESS', 'hello@example.com'),
name: Env.get('MAIL_FROM_NAME', 'Rudder App'),
},
mailers: {
log: { driver: 'log' },
smtp: {
driver: 'smtp',
host: Env.get('MAIL_HOST', 'smtp.example.com'),
port: Env.getNumber('MAIL_PORT', 587),
username: Env.get('MAIL_USERNAME', ''),
password: Env.get('MAIL_PASSWORD', ''),
encryption: Env.get('MAIL_ENCRYPTION', 'tls'), // 'tls' | 'ssl' | 'none'
},
},
} satisfies MailConfigThe provider is auto-discovered. The log driver writes mail to the console — use it in development to see what gets sent without delivering anything.
Mailable classes
Define each email as a class extending Mailable. Use the protected subject(), html(), and text() helpers inside build():
import { Mailable } from '@rudderjs/mail'
export class WelcomeEmail extends Mailable {
constructor(private readonly user: User) { super() }
build() {
return this
.subject(`Welcome, ${this.user.name}!`)
.html(`<h1>Hello, ${this.user.name}!</h1><p>Thanks for joining.</p>`)
.text(`Hello, ${this.user.name}! Thanks for joining.`)
}
}Generate stubs with pnpm rudder make:mail Welcome.
For richer templates, render the body with @rudderjs/view (vanilla mode):
import { html } from '@rudderjs/view'
import { Mailable } from '@rudderjs/mail'
export class WelcomeEmail extends Mailable {
build() {
const body = html`
<h1>Welcome, ${this.user.name}!</h1>
<p>Confirm your email by clicking <a href="${this.url}">here</a>.</p>
`
return this
.subject('Welcome')
.html(body.toString())
.text(`Welcome, ${this.user.name}!`)
}
}Sending mail
import { Mail } from '@rudderjs/mail'
await Mail.to('user@example.com').send(new WelcomeEmail(user))
await Mail
.to('alice@example.com', 'bob@example.com')
.cc('manager@example.com')
.bcc('archive@example.com')
.send(new WelcomeEmail(user))Mail.to(...), cc(...), and bcc(...) each accept one or more addresses.
Sending mail in the background
Mailables work as queue jobs out of the box — wrap the send in a job and queue it:
import { Job } from '@rudderjs/queue'
import { Mail } from '@rudderjs/mail'
import { WelcomeEmail } from '../Mail/WelcomeEmail.js'
import { User } from '../Models/User.js'
export class SendWelcomeEmail extends Job {
constructor(private readonly userId: string) { super() }
async handle() {
const user = await User.find(this.userId)
await Mail.to(user.email).send(new WelcomeEmail(user))
}
}
await SendWelcomeEmail.dispatch(user.id)See Queues for the broader job model.
Drivers
Log
Writes the rendered email (subject, recipients, HTML, text) to the console. Default for development. No external dependencies.
{ driver: 'log' }SMTP
Sends mail via Nodemailer. Works with any SMTP server — Postmark, SendGrid, AWS SES, your own MTA.
{
driver: 'smtp',
host: 'smtp.example.com',
port: 587,
username: '...',
password: '...',
encryption: 'tls', // 'tls' | 'ssl' | 'none'
}For services with their own Nodemailer transport (Postmark, SendGrid, Mailgun), implement a custom adapter — see the next section.
Custom drivers
Implement MailAdapter for proprietary providers (Resend, Loops, in-house) and register the adapter through the registry. MailRegistry.set(adapter) takes a single adapter — there is no name argument; the registry holds one active adapter at a time.
import { MailRegistry, type MailAdapter } from '@rudderjs/mail'
class ResendAdapter implements MailAdapter { /* ... */ }
MailRegistry.set(new ResendAdapter())For multi-mailer apps, route at the call site (branching on env / feature flag) rather than expecting the registry to multiplex.
Testing
Mail.fake() returns a FakeMailAdapter instance — assertions live on the returned fake, not on Mail:
import { Mail } from '@rudderjs/mail'
import { WelcomeEmail } from '../app/Mail/WelcomeEmail.js'
const fake = Mail.fake()
await UserService.signup({ email: 'a@b.com' })
fake.assertSent(WelcomeEmail)
// Predicate receives { mailable, options } — recipient lives on options.to
fake.assertSent(WelcomeEmail, (entry) => entry.options.to.includes('a@b.com'))
fake.assertSentCount(1)
fake.assertSentTimes(WelcomeEmail, 1)
fake.assertNotSent(PasswordResetEmail)
fake.assertNothingSent()
fake.restore() // clear the fake adapter and reset the registryCombined sent + queued assertions
When the code under test might either dispatch synchronously OR via the queue (a feature-flagged path, a retry policy, etc.) the combined helpers assert against both channels together so the test doesn't have to know which one ran:
fake.assertOutgoing(WelcomeEmail) // sent OR queued
fake.assertOutgoing(WelcomeEmail, ({ mailable, options }) => options.to.includes('a@b.com'))
fake.assertOutgoingCount(2) // sent + queued combined
fake.assertNothingOutgoing() // neither sent nor queued
fake.outgoing() // all entries across both channels
fake.outgoing(WelcomeEmail) // filtered by classExact-count variants are also available per-channel:
fake.assertSentTimes(WelcomeEmail, 2)
fake.assertQueuedTimes(WelcomeEmail, 1)The fake captures every send into memory — no real delivery, no queue side effects. Call fake.restore() in afterEach so subsequent tests start with a fresh registry.
Pitfalls
- Forgetting
from. There's no boot-time guard — iffrom.addressis empty, mail silently goes out with the registry default (noreply@example.com). SetMAIL_FROM_ADDRESSin.env. logdriver in production. Mail goes to your stdout, not the recipient's inbox. Switch tosmtp(or a custom driver) before deploying.- SPF / DKIM not set up. Even with valid SMTP, mail without proper DNS lands in spam. Configure SPF, DKIM, and DMARC for your sending domain.
- Sending from a request handler with a slow SMTP server. Wrap in a queue job — synchronous SMTP can take seconds and tie up your handler. See the queue example above.