I've hit this pain three times across three different Node.js projects.
You're using SMTP in dev, SendGrid in production, and one day you decide to try Resend because you've heard great things. So you go into your code, swap out the nodemailer transport, install a different package, rewrite the config shape, hunt down every place you referenced transporter.sendMail(), update environment variables, and test everything again from scratch.
It takes half a day. It shouldn't.
Laravel solved this years ago. You configure your providers, your application code never knows which one is active, and you switch by changing MAIL_MAILER=resend in .env. That's it.
I wanted that in Node.js. It didn't exist. So I built it.
Introducing laramail
npm install laramail
laramail is a Laravel-style mail system for Node.js and TypeScript. Same API across every provider. Switch providers with one config change. Test email sending without mocks.
The Core Problem: Provider Lock-in
Here's what sending email with nodemailer + Resend looks like:
// Resend has its own SDK — can't use nodemailer 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>',
});
Now switch to SendGrid:
// Completely different package, completely different API
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 has a different package, a different API shape, a different config format. Switching requires code changes everywhere you send email.
The laramail Way
Configure your providers once:
import { Mail } from 'laramail';
Mail.configure({
default: process.env.MAIL_DRIVER, // 'smtp' | 'sendgrid' | 'resend' | '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,
},
},
});
Send email — the same code, always:
await Mail.to('user@example.com').send(new WelcomeEmail(user));
Switch from Resend to SendGrid:
# Before
MAIL_DRIVER=resend
# After
MAIL_DRIVER=sendgrid
No code changes. Zero.
Mailable Classes
Self-contained, reusable, testable email objects — just like Laravel:
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>Your account is ready.</p>`);
}
}
await Mail.to(user.email).send(new WelcomeEmail(user));
Testing with Mail.fake()
This is the part I'm most proud of.
Testing email in Node.js is usually painful — you either hit a real server or write verbose mocks. Mail.fake() intercepts outgoing email and gives you readable assertions:
describe('Registration', () => {
beforeEach(() => Mail.fake());
afterEach(() => Mail.restore());
it('sends a welcome email on signup', async () => {
await registerUser({ name: 'John', email: 'john@example.com' });
Mail.assertSent(WelcomeEmail);
Mail.assertSent(WelcomeEmail, (mail) => mail.hasTo('john@example.com'));
Mail.assertSent(WelcomeEmail, (mail) => mail.subjectContains('Welcome'));
Mail.assertSentCount(WelcomeEmail, 1);
});
});
No mocks. No SMTP server. Just Mail.fake() and assertions that read like plain English.
Provider Failover
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 fails, laramail automatically retries with SendGrid.
Staging Redirect
Never accidentally email real users from staging again:
if (process.env.NODE_ENV === 'staging') {
Mail.alwaysTo('dev@example.com');
}
Every email goes to dev@example.com. Regardless of what Mail.to() says.
What's Included
- 6 providers: SMTP, SendGrid, AWS SES, Mailgun, Resend, Postmark
- 3 template engines: Handlebars, EJS, Pug
- Markdown emails with built-in components
- Queue support via BullMQ
- Rate limiting (sliding window, per-mailer)
- Email events (sending/sent/failed hooks)
-
Email preview via CLI (
laramail preview) -
Custom providers via
Mail.extend() -
TypeScript-first — fully typed, no
@types/needed - 620+ tests
Get Started
npm install laramail
- GitHub: github.com/impruthvi/laramail — star it if this solves your problem
- Docs: laramail.impruthvi.me
- npm: npmjs.com/package/laramail
If you've been copy-pasting provider-specific email code across projects — I built this for you. If something's broken or missing, open an issue. I read every one.
Top comments (0)