Back to blog
NodemailerNode.jsEmailTypeScriptDeveloper Guide

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.

SendPigeon TeamMarch 11, 20268 min read

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.

TL;DR

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 trackingNothing. You know it left your server.Full lifecycle: sent → delivered → opened → clicked
BouncesBounce 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/SPFYou generate keys, add DNS records, rotate them.You add DNS records once during setup. The API handles the rest.
Spam complaintsYou need a feedback loop with each ISP.Handled. Complaints trigger suppression automatically.
RetriesBuild it yourself.Built in.
AnalyticsBuild 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