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 you're digging through
sendMail.mock.calls[0][0].to - You switch from nodemailer to Resend and your entire mock layer needs to be rebuilt
Mail.fake() 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 above is a Mailable — a self-contained, testable email object. This is the pattern from Laravel (and AdonisJS) 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.
Setup
import { Mail } from 'laramail';
Mail.configure({
default: process.env.MAIL_DRIVER,
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,
},
},
});
Switch providers by changing MAIL_DRIVER in your .env. No code changes anywhere else. That's a bonus side effect — the testing story is the reason I built this.
One more DX feature: staging redirect
In staging you never want to accidentally email real users. One line:
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 cleared automatically. Works with Mail.fake() too — fake mode bypasses it so your test assertions still see the original recipients.
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 — built-in components
- Log transport — console output in dev, zero SMTP config
-
Staging redirect —
Mail.alwaysTo() -
CLI tools —
laramail preview,laramail send-test, and more - 774 tests, TypeScript-first
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 testing docs
- npm
If you've been living with a fragile nodemailer mock on every project — I built this for you.
Top comments (0)