Every Node.js project I've joined has the same file somewhere.
A giant jest.mock() block for nodemailer. Some manual spy setup. A comment that says "don't touch this." And at least one developer who spent three hours debugging it when transporter.sendMail silently stopped being called in tests.
I kept rebuilding the same abstraction on every project. Eventually I stopped rebuilding it and just shipped it as a library.
The fix: Mail.fake()
Mail.fake();
await Mail.to('user@example.com').send(new WelcomeEmail(user));
Mail.assertSent(WelcomeEmail, (mail) => mail.hasTo('user@example.com'));
No SMTP server. No network. No mock setup. The test fails if the wrong email goes to the wrong address. That's it.
What actually goes wrong with nodemailer mocks
Here's the pattern I kept seeing:
// Some version of this exists in nearly every Node.js codebase
jest.mock('nodemailer', () => ({
createTransport: jest.fn().mockReturnValue({
sendMail: jest.fn().mockResolvedValue({ messageId: 'test-id' }),
}),
}));
It works. Until it doesn't.
- Someone upgrades nodemailer and the mock stops matching the real API
- A new developer adds a new email send call and the mock swallows it silently
- You want to assert the email went to the right address and suddenly you're digging through
sendMail.mock.calls[0][0].to - You switch from nodemailer to Resend and now your entire mock layer needs to be rebuilt
Mail.fake() solves all of this. It intercepts at the abstraction layer, not the transport layer. Your tests don't know or care what provider you're using.
A real test
import { Mail } from 'laramail';
import { WelcomeEmail } from './emails/WelcomeEmail';
describe('Registration', () => {
beforeEach(() => Mail.fake());
afterEach(() => Mail.restore());
it('sends a welcome email on signup', async () => {
await registerUser({ name: 'John', email: 'john@example.com' });
// Did the right email class get sent?
Mail.assertSent(WelcomeEmail);
// Did it go to the right address?
Mail.assertSent(WelcomeEmail, (mail) =>
mail.hasTo('john@example.com')
);
// Did it have the right subject?
Mail.assertSent(WelcomeEmail, (mail) =>
mail.subjectContains('Welcome')
);
// Was it sent exactly once? Was nothing else sent?
Mail.assertSentCount(WelcomeEmail, 1);
Mail.assertNotSent(PasswordResetEmail);
});
});
Assertions that read like plain English. No mock.calls[0][0].
The Mailable class
The WelcomeEmail in the example above is a Mailable — a self-contained, testable email object. This is the pattern from Laravel that I missed most in Node.js.
import { Mailable } from 'laramail';
class WelcomeEmail extends Mailable {
constructor(private user: { name: string; email: string }) {
super();
}
build() {
return this
.subject(`Welcome, ${this.user.name}!`)
.html(`
<h1>Hello ${this.user.name}!</h1>
<p>Thanks for joining. Your account is ready.</p>
`);
}
}
await Mail.to(user.email).send(new WelcomeEmail(user));
Your email logic lives in one place. Easy to test, easy to reuse, easy to find.
Laravel-style mail for Node.js: the provider problem
Testing is why I built laramail. Provider lock-in is why I kept working on it.
I hit this three times across three projects. You use SMTP in dev, SendGrid in production, and one day you want to try Resend. Sending with Resend looks like this:
// Resend ships its own SDK — nodemailer is not in the picture at all
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: 'noreply@example.com',
to: 'user@example.com',
subject: 'Welcome!',
html: '<h1>Hello!</h1>',
});
And SendGrid looks like this:
// Different package, different API shape, different config
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
await sgMail.send({
to: 'user@example.com',
from: 'noreply@example.com',
subject: 'Welcome!',
html: '<h1>Hello!</h1>',
});
Every provider is a different package with a different API and a different config format. Switching means touching every place you send email, then re-testing all of it. It takes half a day. It should take one line.
Laravel solved this years ago: configure the providers once, and application code never learns which one is active.
Setup
import { Mail } from 'laramail';
Mail.configure({
default: process.env.MAIL_DRIVER, // 'smtp' | 'resend' | 'sendgrid' | 'ses' | 'mailgun' | 'postmark'
from: { address: 'noreply@example.com', name: 'My App' },
mailers: {
smtp: {
driver: 'smtp',
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
},
resend: {
driver: 'resend',
apiKey: process.env.RESEND_API_KEY,
},
sendgrid: {
driver: 'sendgrid',
apiKey: process.env.SENDGRID_API_KEY,
},
},
});
Your send call never changes:
await Mail.to('user@example.com').send(new WelcomeEmail(user));
Switching provider is an environment variable:
# Before
MAIL_DRIVER=resend
# After
MAIL_DRIVER=sendgrid
No code changes. Zero.
Provider failover
Two providers configured means you can chain them:
Mail.configure({
default: 'resend',
failover: ['resend', 'sendgrid'], // try resend first, fall back to sendgrid
mailers: {
resend: { driver: 'resend', apiKey: process.env.RESEND_API_KEY },
sendgrid: { driver: 'sendgrid', apiKey: process.env.SENDGRID_API_KEY },
},
});
If Resend is down, laramail retries through SendGrid. The user gets their email either way.
Local development without an SMTP server
The log driver writes the rendered email to stdout instead of sending it, which is usually what you want on a laptop:
Mail.configure({ default: 'log', mailers: { log: { driver: 'log' } } });
And if you use a provider laramail does not ship, Mail.extend() registers your own:
Mail.extend('mycustom', () => new MyCustomTransport());
One more thing: staging redirect
In staging you never want to accidentally email real users. One line fixes it:
if (process.env.NODE_ENV === 'staging') {
Mail.alwaysTo('dev@example.com');
}
Every email your staging environment sends goes to dev@example.com. CC and BCC are cleared automatically.
What's included
Mail.fake()+Mail.assertSent()— zero-setup email testing- Mailable classes — self-contained, reusable, testable email objects
- 6 providers — SMTP, SendGrid, AWS SES, Mailgun, Resend, Postmark
- Provider failover — auto-chain if a provider fails
- Rate limiting — sliding window, configurable per-mailer
- Queue support — Bull / BullMQ
- Template engines — Handlebars, EJS, Pug
- Markdown emails — with built-in components
- Log transport — console output in development, no SMTP needed
- Staging redirect —
Mail.alwaysTo() - CLI tools —
laramail preview,laramail send-test, and more - 774 tests, TypeScript-first, full API documentation
The one-line pitch: AdonisJS mailer, but framework-agnostic. Works with Express, Fastify, NestJS, or any Node.js app.
Get started
npm install laramail
- GitHub — star it if this solves a problem you've hit
- Documentation — full API reference, including the testing docs
- npm
If you've been living with a fragile nodemailer mock on every project — I built this for you.
