I Got Tired of Rewriting My Email Code Every Time I Switched Providers
Every time I switched email providers in Node.js, I had to rewrite transport code, update configs, and pray nothing broke. So I built laramail — Laravel-style email for Node.js with one-env-var provider switching and Mail.fake() testing.
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 problem years ago. You configure your providers once, your application code never knows which one is active, and you switch by changing MAIL_MAILER=resend in your .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
Instead of inline send calls everywhere, laramail gives you Mailable classes — self-contained, reusable, testable email objects:
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>
`);
}
}
// Send it
await Mail.to(user.email).send(new WelcomeEmail(user));
Your email logic lives in one place. You can test it, reuse it, and change its behavior without touching the call sites.
Testing with Mail.fake()
This is the feature I'm most proud of.
Testing email sending in Node.js is usually painful. You either hit a real SMTP server, set up a nodemailer mock, or skip the test entirely. Mail.fake() intercepts all outgoing email and gives you Laravel-style assertions:
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' });
// Assert the email was sent
Mail.assertSent(WelcomeEmail);
// Assert it went to the right address
Mail.assertSent(WelcomeEmail, (mail) =>
mail.hasTo('john@example.com')
);
// Assert the subject
Mail.assertSent(WelcomeEmail, (mail) =>
mail.subjectContains('Welcome')
);
// Assert nothing else was sent
Mail.assertSentCount(WelcomeEmail, 1);
Mail.assertNotSent(PasswordResetEmail);
});
});
No mocks. No interceptors. No SMTP server. Just Mail.fake() and assertions that read like plain English.
Provider Failover
Got two providers configured? Set up automatic 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. Your users get their email either way.
Staging Redirect
One of my favourite DX features. In staging, you never want to accidentally email real users. With Mail.alwaysTo(), every email gets redirected to one address regardless of what Mail.to() says:
if (process.env.NODE_ENV === 'staging') {
Mail.alwaysTo('dev@example.com');
}
Every email your staging environment sends goes to dev@example.com. No accidental customer emails, ever.
What's Included
- 6 providers: SMTP, SendGrid, AWS SES, Mailgun, Resend, Postmark
- 3 template engines: Handlebars, EJS, Pug
- Markdown emails with built-in components (buttons, panels, etc.)
- Queue support via BullMQ
- Rate limiting with sliding window, configurable per-mailer
- Email events — hooks for sending, sent, and failed
- Email preview via CLI (
laramail preview) - Custom providers via
Mail.extend() - TypeScript-first — fully typed, no
@types/package needed - 620+ tests with full coverage
Get Started
npm install laramail
- GitHub — star it if this solves a problem you've hit
- Documentation — full API reference
- npm
If you've been copy-pasting provider-specific email code across projects for years — I built this for you. If something doesn't work or a feature is missing, open an issue. I read every one.