Nodemailer and Beyond: Sending Email in Node.js (SMTP vs API)
Two ways to send email from Node.js — Nodemailer over SMTP or an email API over HTTP. Side-by-side code, tradeoffs, and when to use each.
There are two ways to send email from Node.js: SMTP with Nodemailer, or HTTP with an email API. Most tutorials only show Nodemailer. This guide covers both so you can pick the right one.
Nodemailer (SMTP):
await transport.sendMail({ from, to, subject, html });
You manage: SMTP server, credentials, DKIM/SPF, bounces, retries.
Email API (HTTP):
await pigeon.send({ from, to, subject, html });
The API manages: delivery, authentication, bounces, analytics.
Use Nodemailer when you need SMTP control or already have infrastructure. Use an API when you want to send email and not think about the plumbing.
Setup: side by side
Nodemailer (SMTP)
npm install nodemailer @types/nodemailer
import nodemailer from "nodemailer";
const transport = nodemailer.createTransport({
host: "smtp.yourprovider.com",
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
You need: an SMTP server, credentials, host, port. Different for every provider.
Email API (SendPigeon)
npm install sendpigeon
import { SendPigeon } from "sendpigeon";
const pigeon = new SendPigeon(process.env.SENDPIGEON_API_KEY!);
You need: an API key. That's it.
Sending an email
Nodemailer
const info = await transport.sendMail({
from: '"Your App" <noreply@yourdomain.com>',
to: "user@example.com",
subject: "Your order shipped",
text: "Your package is on its way.",
html: "<h1>Your order has shipped</h1><p>Your package is on its way.</p>",
});
console.log("Sent:", info.messageId);
Email API
const { data, error } = await pigeon.send({
from: "noreply@yourdomain.com",
to: "user@example.com",
subject: "Your order shipped",
text: "Your package is on its way.",
html: "<h1>Your order has shipped</h1><p>Your package is on its way.</p>",
});
if (error) {
console.error("Failed:", error.message);
} else {
console.log("Sent:", data.id);
}
The code is similar. The difference is what happens after you call send.
What happens after you send
This is where the two approaches diverge.
| Nodemailer (SMTP) | Email API | |
|---|---|---|
| Delivery tracking | Nothing. You know it left your server. | Full lifecycle: sent → delivered → opened → clicked |
| Bounces | Bounce notifications go to your SMTP server. You parse them and maintain a suppression list. | Automatic. Hard bounces are suppressed so you never send to them again. |
| DKIM/SPF | You generate keys, add DNS records, rotate them. | You add DNS records once during setup. The API handles the rest. |
| Spam complaints | You need a feedback loop with each ISP. | Handled. Complaints trigger suppression automatically. |
| Retries | Build it yourself. | Built in. |
| Analytics | Build it yourself (tracking pixels, link rewriting). | Opens, clicks, delivery status out of the box. |
With Nodemailer, sendMail() succeeding means the SMTP server accepted the message. You don't know if it was delivered, opened, or bounced — unless you build that infrastructure yourself.
With an API, you get webhooks for every event and a dashboard to see what's happening.
Attachments
Both approaches support attachments. The syntax is slightly different.
Nodemailer
await transport.sendMail({
from: "billing@yourdomain.com",
to: "customer@example.com",
subject: "Your invoice",
html: "<p>Invoice attached.</p>",
attachments: [
{ filename: "invoice.pdf", path: "./invoices/123.pdf" },
{ filename: "receipt.txt", content: "Order #12345\nTotal: $99.00" },
],
});
Email API
import fs from "fs";
await pigeon.send({
from: "billing@yourdomain.com",
to: "customer@example.com",
subject: "Your invoice",
html: "<p>Invoice attached.</p>",
attachments: [
{
filename: "invoice.pdf",
content: fs.readFileSync("./invoices/123.pdf").toString("base64"),
},
],
});
Nodemailer reads files directly from the filesystem. An API sends base64-encoded content over HTTP.
Error handling
Nodemailer
Nodemailer throws on SMTP errors. You parse error messages to figure out what went wrong:
try {
await transport.sendMail({ from, to, subject, html });
} catch (err) {
const error = err as Error;
if (error.message.includes("ECONNREFUSED")) {
// SMTP server unreachable
} else if (error.message.includes("535")) {
// Authentication failed
} else if (error.message.includes("550")) {
// Recipient rejected
}
}
SMTP error codes are inconsistent across providers. You end up writing string-matching logic.
Email API
The SDK returns structured errors with status codes:
const { data, error } = await pigeon.send({ from, to, subject, html });
if (error) {
switch (error.status) {
case 400: // Invalid request (bad email, missing field)
case 401: // Invalid API key
case 429: // Rate limited
case 500: // Server error
}
}
Testing locally
Nodemailer + Ethereal
Nodemailer's team provides Ethereal, a fake SMTP service that captures emails:
const testAccount = await nodemailer.createTestAccount();
const transport = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
auth: { user: testAccount.user, pass: testAccount.pass },
});
const info = await transport.sendMail({ from, to, subject, html });
console.log("Preview:", nodemailer.getTestMessageUrl(info));
Works, but requires internet and the preview links are temporary.
Email API + local dev server
npx @sendpigeon-sdk/cli dev
Local SMTP on port 4125, web UI at localhost:4100. Same SDK for dev and production — set SENDPIGEON_DEV=true for local capture, remove it to send real emails. No code changes.
You can also use Mailpit or any other local email testing tool with either approach.
The problems you'll hit with Nodemailer at scale
Nodemailer works fine for low-volume apps. As you grow, you'll run into things it doesn't handle:
Serverless environments block SMTP
Vercel, Cloudflare Workers, and some AWS Lambda configs block outbound SMTP connections on port 587/465. Your sendMail() call hangs or times out with no clear error.
Email APIs use HTTP (port 443), which works everywhere.
Deliverability is on you
With Nodemailer, you configure DKIM keys, add SPF and DMARC records, rotate keys periodically, and monitor your sender reputation — all manually. If something breaks, your emails silently land in spam.
Use our deliverability checker to verify your setup, and see why emails go to spam for a diagnostic guide.
Bounces pile up
Without bounce processing, you keep sending to invalid addresses. This tanks your sender reputation. With Nodemailer, you build bounce handling yourself — parsing SMTP bounce notifications, maintaining suppression lists, handling soft vs hard bounces.
No visibility
Nodemailer's sendMail() returns a message ID and that's it. Did the email land in the inbox or spam? Was it opened? Did the link get clicked? You don't know without building tracking infrastructure.
When to use Nodemailer
Nodemailer is still the right choice when:
- You need raw SMTP control — custom SMTP extensions, connection pooling, SMTP pipelining
- You're sending between internal systems — deliverability doesn't matter because both sides are yours
- You already have SMTP infrastructure — a mail server, monitoring, bounce processing
- You're building email tooling — an email client, relay, or proxy
Connection pooling (Nodemailer-specific)
For high-volume SMTP, enable connection pooling:
const transport = nodemailer.createTransport({
pool: true,
maxConnections: 5,
maxMessages: 100,
host: "smtp.yourprovider.com",
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
This reuses TCP connections instead of opening a new one per email.
The middle path: Nodemailer + SendPigeon SMTP
You don't have to choose between Nodemailer and an API. SendPigeon offers SMTP relay at smtp.sendpigeon.dev — so you keep Nodemailer, keep your existing code pattern, and get managed deliverability behind it.
const transport = nodemailer.createTransport({
host: "smtp.sendpigeon.dev",
port: 587,
secure: false,
auth: {
user: process.env.SENDPIGEON_API_KEY,
pass: process.env.SENDPIGEON_API_KEY,
},
});
// Your existing sendMail() calls work unchanged
await transport.sendMail({
from: "hello@yourdomain.com",
to: "user@example.com",
subject: "Welcome!",
html: "<h1>You're in.</h1>",
});
What you get by swapping your SMTP credentials:
- DKIM signing handled automatically for verified domains
- Bounce processing — hard bounces suppressed, no manual parsing
- Delivery status — see sent, delivered, bounced in the dashboard
- No infrastructure to manage — no SMTP server to run or monitor
You can also enable open and click tracking by adding headers:
await transport.sendMail({
from: "hello@yourdomain.com",
to: "user@example.com",
subject: "Welcome!",
html: '<h1>Welcome</h1><p>Check out your <a href="https://app.com/dashboard">dashboard</a>.</p>',
headers: {
"X-SP-Track-Opens": "true",
"X-SP-Track-Clicks": "true",
},
});
This is a good option if you have a codebase already using Nodemailer and don't want to rewrite send calls. You keep SMTP, but the plumbing is managed.
When to use an email API
An API is the better fit when:
- You're building an app that sends email — transactional, notifications, marketing
- You deploy to serverless — Vercel, Cloudflare Workers, Lambda
- You don't want to manage SMTP servers — DNS, bounce processing, reputation monitoring
- You need delivery analytics — opens, clicks, deliverability metrics
- You need to send from multiple domains — one API key, unlimited domains
import { SendPigeon } from "sendpigeon";
const pigeon = new SendPigeon(process.env.SENDPIGEON_API_KEY!);
await pigeon.send({
from: "hello@yourdomain.com",
to: "user@example.com",
subject: "Welcome!",
html: "<h1>You're in.</h1>",
});
Start free — 3,000 emails/month, no credit card.
Next Steps
- Detailed comparison: SendPigeon vs Nodemailer
- Send from Node.js: How to Send Email in Node.js
- Queue your emails: How to Send Queued Email
- Fix deliverability: Why Are My Emails Going to Spam?
- Check your domain: Free Deliverability Checker
- Pick your framework: Framework Guides