DEV Community

Cover image for I Got Tired of Rewriting My Email Code Every Time I Switched Providers
Pruthvisinh Rajput
Pruthvisinh Rajput

Posted on

I Got Tired of Rewriting My Email Code Every Time I Switched Providers

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
Enter fullscreen mode Exit fullscreen mode

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>',
});
Enter fullscreen mode Exit fullscreen mode

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>',
});
Enter fullscreen mode Exit fullscreen mode

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,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Send email — the same code, always:

await Mail.to('user@example.com').send(new WelcomeEmail(user));
Enter fullscreen mode Exit fullscreen mode

Switch from Resend to SendGrid:

# Before
MAIL_DRIVER=resend

# After
MAIL_DRIVER=sendgrid
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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 },
  },
});
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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)