How to Send Transactional Emails with Node.js
Sending a transactional email from Node.js comes down to two separate decisions: which library you use to talk to a provider, and which provider actually delivers the message. Most tutorials blur the two together. Keeping them separate makes the whole conversation clearer, especially when you eventually need to send different types of mail through different providers.
This guide walks through the two common library approaches (nodemailer over SMTP, and provider-specific SDKs), explains why production systems end up with more than one provider in the mix, and shows how a coordination layer makes that pattern manageable without rewriting application code every time routing changes.
The library layer and the provider layer
Two separate decisions:
- Library layer. The code you write to compose and dispatch the message. In Node.js, that’s usually
nodemailertalking to an SMTP host, or a provider-specific SDK like@aws-sdk/client-sesv2,@sendgrid/mail, ormailgun.js. - Provider layer. The service that actually delivers the message and owns the sending reputation. AWS SES, Mailgun, SendGrid, Postmark, Gmail, Maileroo, a self-hosted Postfix instance - all valid, all legitimate, each with its own pricing and deliverability profile.
Lock-in happens at the library layer, not the provider layer. Switching from SES to Postmark is a contract change; rewriting all your sending code because your SDK was SES-specific is where the actual pain sits.
| Approach | What you write | Provider flexibility | Multiple providers | Best for |
|---|---|---|---|---|
| nodemailer + SMTP | ~15 lines | Any SMTP host | Manual switch statements | One SMTP host, owning retries yourself |
| Provider SDK | Per-SDK API | One provider per SDK | Rewrite per provider | Committed to one provider’s deep feature set |
Option 1: nodemailer with SMTP
nodemailer is the default Node.js library for sending email. It speaks SMTP, which means it works with any provider that exposes an SMTP endpoint - and that’s almost all of them. Mailgun, SendGrid, Postmark, AWS SES, Gmail, Maileroo, your own Postfix server are all SMTP-compatible.
Install and send:
npm install nodemailer
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: 'smtp.example.com',
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
await transporter.sendMail({
from: '"Acme" <[email protected]>',
to: '[email protected]',
subject: 'Your order is confirmed',
html: '<p>Thanks for your order.</p>'
});
That’s a working send. Port 587 with STARTTLS is the standard for submission; see common SMTP configurations for host settings across popular providers. For Gmail-specific setup (App Passwords, two-factor authentication, free vs Workspace limits), see how to send transactional emails with Gmail - it’s enough of its own topic to deserve its own post.
What nodemailer leaves you to handle:
- Templates. You’re concatenating strings, or using a template library yourself.
- Retries. Nodemailer has a single-send retry parameter but no queue, no backoff policy, no durable retry on process restart.
- Error interpretation. You’ll want to understand SMTP status codes to decide which errors mean “retry later” and which mean “give up.”
- Analytics. You write your own logging and correlate bounces yourself.
- Multiple providers. If you ever want to send different types of email through different SMTP hosts, you’re creating multiple transporters and routing in code.
For a single-provider setup with simple needs, nodemailer is perfectly reasonable. The moment any of the bullets above becomes a meaningful chunk of your application code, it’s time to consider alternatives.
Option 2: the provider SDK (AWS SES example)
When teams outgrow “one SMTP host, no templates, no analytics,” they usually reach for a provider-specific SDK. AWS SES is the most common choice for Node.js apps scaling beyond Gmail’s limits, so that’s the example here. The same overall shape applies to @sendgrid/mail, mailgun.js, and postmark.
npm install @aws-sdk/client-sesv2
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
const ses = new SESv2Client({ region: 'us-east-1' });
await ses.send(new SendEmailCommand({
FromEmailAddress: '[email protected]',
Destination: { ToAddresses: ['[email protected]'] },
Content: {
Simple: {
Subject: { Data: 'Your order is confirmed' },
Body: { Html: { Data: '<p>Thanks for your order.</p>' } }
}
}
}));
Things you get with SES directly that nodemailer doesn’t give you:
- Higher throughput and better deliverability than Gmail SMTP, with dedicated or shared sending IPs.
- SES templates (JSON-defined, functional but limited compared to a proper templating UI).
- CloudWatch metrics for basic sending stats.
- Authentication handled by AWS credentials, which you probably already have configured.
Things SES alone still leaves to you:
- Templates are either in code or in SES’s JSON format. No visual editor, no version history.
- Analytics are CloudWatch metrics plus your own logging. Correlating bounces to individual users is your problem.
- Audiences and contact management are not part of SES. They live in your database.
But here’s the limitation that actually drives architectural decisions: you can only send through SES in this code path. The moment you want to route different email types through different providers - say, password resets through a premium provider for deliverability and notification digests through SES for cost - you need a second SDK plus branching logic to decide which one to call. The lock-in is in your code, not in SES itself. SES is a great backend. The problem is that nodemailer and provider SDKs both make you choose once, at the library layer.
Why you actually end up with multiple providers
Here’s the real reason production systems use more than one provider, and it isn’t outage failover. It’s matching each email’s sending cost to its business value.
Not all transactional emails are created equal. Some are worth a lot:
- Password resets. A lost one means a locked-out customer and a support ticket.
- Email verification. A lost one means a stuck signup flow and a churned user.
- User invites. A lost one means a team that never lands in the product.
- Order confirmations and receipts. A lost one means a refund request or a chargeback.
Others are low-stakes:
- Notification digests. “3 people liked your post this week” is fine if a few don’t land.
- Activity alerts. “Someone commented on your issue” survives a bounce here and there.
- Weekly recaps and re-engagement nudges.
High-value messages deserve a premium, high-deliverability provider like Postmark or SendGrid, even if they cost more per message. The business cost of a lost password reset is orders of magnitude higher than the incremental send cost. Low-value messages can go through a cheap provider like AWS SES (around $0.10 per thousand) where slightly lower delivery rates are acceptable because the per-message business value is low.
Done right, this saves real money. A SaaS sending a million notification digests plus fifty thousand password resets per month pays dramatically less if the digests go through SES and the resets go through Postmark than if everything goes through either one alone. Proper authentication at both providers - see how to set up SPF, DKIM, and DMARC - is what keeps both ends of the split delivering well.
The math is obvious. The implementation is where teams get stuck.
Hand-rolled in your application code, the pattern looks something like this:
import { sendViaSES } from './providers/ses.js';
import { sendViaPostmark } from './providers/postmark.js';
async function sendTransactionalEmail({ type, to, data }) {
switch (type) {
// High-ROI: premium provider
case 'password-reset':
case 'email-verification':
case 'user-invite':
case 'order-confirmation':
return sendViaPostmark(to, buildTemplate(type, data));
// Low-ROI: cheap provider
case 'notification-digest':
case 'activity-alert':
case 'weekly-recap':
return sendViaSES(to, buildTemplate(type, data));
default:
return sendViaSES(to, buildTemplate(type, data));
}
}
What’s wrong with that code:
- Two SDKs, two credential sets, two error-handling paths. Every provider has its own auth, payload shape, and error semantics.
- The routing is hardcoded. Moving
password-resetfrom Postmark to SendGrid is a code change and a deploy. - Templates get duplicated across providers, or forced into a fragile shared layer.
- Product and operations teams can’t change routing without a developer.
- Analytics are split. SES delivery stats live in CloudWatch, Postmark stats in Postmark’s dashboard, and nobody has a unified view.
This is the pattern a coordination layer is designed to eliminate.
Adding a coordination layer on top
A coordination layer sits between your application code and one or more delivery backends. Your code calls the coordination layer; the coordination layer decides which backend to use based on configuration, not code. Your existing providers stay in place - you keep your Postmark contract, your SES credentials, your sending reputation. What changes is where routing decisions live.
A few tools occupy this space. Courier is a broader notification platform covering email, SMS, push, and in-app messaging - a good fit if you need multi-channel. SendStreak is narrower: email only, and specifically designed to wrap backends you already own rather than replace them. The rest of this section uses SendStreak as the concrete example.
npm install --save-exact @sendstreak/sendstreak-node
const SendStreak = require('@sendstreak/sendstreak-node');
const sendStreak = new SendStreak(process.env.SENDSTREAK_API_KEY);
await sendStreak.sendMail('[email protected]', 'password-reset', {
userName: 'alice',
resetLink: 'https://acme.com/reset/abc123'
});
That’s a complete send. A few things to notice:
There’s no provider parameter. The SDK doesn’t know or care which backend delivers the message. Routing is decided by template configuration in the SendStreak dashboard: each template points at a preferred email server, and SendStreak uses that server when sending that template. If no preference is set, SendStreak picks one of your configured servers at random. See manage email servers for the full model.
Your cost routing is a configuration choice, not code. In the dashboard, point password-reset, email-verification, user-invite, and order-confirmation at your Postmark server. Point notification-digest, activity-alert, and weekly-recap at your AWS SES server. The application code stays the same two lines for every template. When you later decide to shift activity-alert back to Postmark because delivery matters more than you thought, an operator changes it in the dashboard - no deploy.
Your existing providers stay in place. Add your AWS SES credentials, your Postmark account, your Mailgun account, your self-hosted Postfix server - SendStreak talks to them. You keep your contracts, your sending reputation, and your provider relationships. SendStreak is not a delivery service; it’s the layer that coordinates the delivery services you already have.
Templates live in SendStreak. No string concatenation, no HTML embedded in application code. Variables pass through as the third argument to sendMail. See the templates documentation for the template model and the preferred-server-per-template configuration.
Unified analytics across backends. Deliveries, opens, clicks, and bounces for all your sending in one dashboard, regardless of which provider handled each message.
What a coordination layer does not do, honestly: SendStreak does not automatically retry a failed send on a different provider at the per-message level. Multi-provider routing is per-template (or random across available servers), not cross-provider failover. If you need per-message automatic failover between providers, that is a different architectural pattern and SendStreak is not the right tool. Most production systems don’t actually need per-message failover - what they need is cost-vs-deliverability routing by email value, which is what the per-template model solves cleanly.
SES alone vs SES + SendStreak: the concrete delta
To make the approach comparison concrete, here’s what changes when you put SendStreak on top of AWS SES. SES is the cleanest comparison target because it’s raw delivery infrastructure with minimal native templating, analytics, or audience management - every row of the table shows a real delta.
SES alone (nodemailer or @aws-sdk/client-sesv2) | SES + SendStreak | |
|---|---|---|
| Templates | String concatenation in code, or SES templates (basic, JSON-defined) | Dashboard templates with variables, version history, test sends |
| Analytics | CloudWatch metrics plus your own logging | Unified dashboard (deliveries, opens, clicks, bounces) |
| Audience / contact management | In your own database | In SendStreak, referenced by template |
| Cost-vs-deliverability routing by message type | Hardcoded switch plus a second provider SDK | Dashboard config - one preferred server per template |
| Adding a second backend for a different message class | Integrate a second SDK, build a router, redeploy | Add the backend in the dashboard, assign it to the template |
| Lock-in surface | Code depth (SDK calls, routing logic, error handling) plus SES reputation | Coordination layer only - your SES account stays intact if you leave |
| Application code per flow | ~30-50 lines, more if multi-provider | ~2 lines, same shape for every template |
Three honesty notes worth internalizing before adopting any coordination layer:
- It’s not cheaper than SES on its own. You still pay SES per-message rates. SendStreak adds its own per-message fee on top. The value isn’t the delivery cost - it’s the features you don’t have to build, and the ability to route cheap low-value messages through SES while routing high-value messages through a premium provider without changing application code.
- Routing is per-template, not per-attempt. SendStreak doesn’t automatically fail over a single message to another provider if SES errors out. If you need per-message failover, look elsewhere.
- A coordination layer is optional if you only ever send one kind of email through one provider forever. The case for adding it strengthens as your email types diversify and the cost-vs-value trade-off across messages becomes real.
Picking your setup
The choice is not “SendStreak or Mailgun” - those are at different layers. The real questions are, first, which provider or providers you send through, and second, whether you call them directly or through a coordination layer.
- One provider forever, one kind of email, no growing pains with templates or analytics. Use
nodemailerwith SMTP or the provider’s SDK directly. Simpler stack, fewer moving parts, nothing to overthink. - Your email mix has different business values - high-ROI messages like password resets, verifications, invites, and receipts plus lower-ROI notification traffic - and you want to route them through different providers for cost reasons without hardcoding a
switchin your application code. Add a coordination layer. SendStreak is the email-specific, bring-your-own-backend option designed for exactly this pattern. Courier is the broader option if you also need SMS, push, or chat. - Your provider choice stays yours in either case. AWS SES, Mailgun, Postmark, SendGrid, Gmail, Maileroo, and self-hosted SMTP are all legitimate backends - pick whichever ones fit your deliverability, pricing, and compliance needs, then either call them directly or point a coordination layer at them.
For picking providers, see best transactional email services for developers. For attaching providers to SendStreak once you’ve picked them, see manage email servers.
Further reading
- What is a transactional email?
- What is SMTP?
- What is SMTP relay?
- What are SMTP status codes?
- How to set up SPF, DKIM, and DMARC
- Best transactional email services for developers
- How to send transactional emails with Gmail (Node.js and Python)