Docs / Node.js / Nodemailer

Send email in Node.js over SMTP

Node has no built-in mail support, so sending email means talking SMTP — and in practice that means Nodemailer, which every Node framework's mail integration is built on. This guide configures it against Postwing and covers HTML mail, attachments, pooled bulk sending and retry handling.

You can get them on the token management page. For security reasons, a token is shown only once — at the moment it is created.

SMTP connection settings

SettingValue
SMTP hostsmtp.postwing.app
Port587
EncryptionSTARTTLS (the connection is upgraded to TLS before login)
UsernameThe login of an SMTP token for your domain
PasswordThe password of that token — shown once, when the token is created
Every mode is also available on a high port: 8465 (SSL/TLS), 8587 (STARTTLS) and 8025 (plain). Many hosting providers and clouds block outbound 25, 465 and 587 — if the connection times out, switch to the matching high port.

Install Nodemailer

bash
npm install nodemailer

Create a transport

mailer.js
// mailer.js
import nodemailer from "nodemailer";

export const transporter = nodemailer.createTransport({
  host: "smtp.postwing.app",
  port: 587,
  secure: false,        // false on 587 — the connection is upgraded via STARTTLS
  requireTLS: true,     // and fail rather than send in the clear if it cannot be
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,   // the option is "pass", not "password"
  },
});

Or with implicit TLS on port 465:

javascript
// Implicit TLS: encrypted from the first byte.
const transporter = nodemailer.createTransport({
  host: "smtp.postwing.app",
  port: 465,
  secure: true,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
Nodemailer silently ignores unknown options, so password: produces a login attempt with an empty password and the server replies 535 Authentication failed. Check this before anything else.

Verify the configuration

verify() connects and authenticates without sending, so a bad configuration fails at startup rather than in front of a user:

javascript
try {
  await transporter.verify();
  console.log("SMTP ready");
} catch (err) {
  console.error("SMTP configuration is broken:", err.message);
  process.exit(1);
}

Send an email

Pass both text and html — Nodemailer assembles a multipart/alternative message, and HTML with no text part is a well-known spam signal:

javascript
const info = await transporter.sendMail({
  from: '"Acme" <noreply@your-domain.com>',
  to: "customer@example.com",
  subject: "Your order #4417 is confirmed",
  text: "Thanks! Your order ships tomorrow.",
  html: "<h1>Order confirmed</h1><p>Your order ships tomorrow.</p>",
});

console.log("Accepted:", info.accepted);
console.log("Message id:", info.messageId);

The resolved info object tells you what the server accepted: accepted, rejected, response and messageId. Log messageId — it is what ties a send to a later delivery or bounce webhook.

Attachments and inline images

javascript
await transporter.sendMail({
  from: '"Acme" <noreply@your-domain.com>',
  to: "customer@example.com",
  subject: "Your invoice",
  text: "The invoice for March is attached.",
  html: '<p>The invoice is attached.</p><img src="cid:logo" width="120">',
  attachments: [
    { filename: "invoice.pdf", path: "/srv/invoices/2026-03.pdf" },
    { filename: "report.csv", content: csvString },
    { filename: "data.bin", content: buffer, encoding: "base64" },
    { filename: "logo.png", path: "./assets/logo.png", cid: "logo" },
    { filename: "remote.pdf", href: "https://example.com/file.pdf" },
  ],
});

Bulk sending with a connection pool

Reconnecting per message is slow and will eventually be rate-limited. Enable pooling and drive it from the idle event so you never queue more than the pool can handle:

javascript
const bulk = nodemailer.createTransport({
  host: "smtp.postwing.app",
  port: 587,
  requireTLS: true,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
  pool: true,
  maxConnections: 5,     // parallel SMTP connections
  maxMessages: 100,      // messages per connection before reconnecting
  rateDelta: 1000,
  rateLimit: 10,         // at most 10 messages per second
});

bulk.on("idle", () => {
  while (bulk.isIdle() && queue.length) {
    const user = queue.shift();
    bulk.sendMail({
      from: '"Acme" <noreply@your-domain.com>',
      to: user.email,
      subject: "Your weekly report",
      text: renderReport(user),
    }).catch((err) => console.error(user.email, err.message));
  }
});
Attention!
When using BCC, CC - a separate email will be created for each recipient in the system and your limits will be used.

Custom headers and one-click unsubscribe

Bulk mail to Gmail and Yahoo must carry a working one-click unsubscribe header. Nodemailer passes arbitrary headers straight through:

javascript
await transporter.sendMail({
  from: '"Acme" <noreply@your-domain.com>',
  to: "customer@example.com",
  subject: "Your weekly report",
  text: "...",
  replyTo: "support@your-domain.com",
  headers: {
    "List-Unsubscribe": "<mailto:unsubscribe@your-domain.com>",
    "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
  },
});

Retrying transient failures

A 4xx SMTP reply or a dropped socket is temporary; a 5xx is permanent and retrying it only burns your reputation:

javascript
async function sendWithRetry(message, attempts = 3) {
  for (let i = 1; i <= attempts; i++) {
    try {
      return await transporter.sendMail(message);
    } catch (err) {
      // 4xx replies and socket errors are transient; 5xx are permanent.
      const permanent = err.responseCode >= 500 && err.responseCode < 600;
      if (permanent || i === attempts) throw err;
      await new Promise((r) => setTimeout(r, 2 ** i * 1000));
    }
  }
}

Troubleshooting

Error codeCause and fix
EAUTHpass misspelled as password, or wrong token credentials.
ETIMEDOUT / ECONNREFUSED Outbound port blocked. Use 8587 or 8465.
ESOCKET — wrong version numbersecure: false on port 465. Set it to true.
Hangs, no errorsecure: true on port 587. Use requireTLS instead.
EENVELOPE Sender domain not verified, or a malformed recipient address.
EMESSAGE — 552 Message too large. Host big attachments and link to them instead.

Frequently asked questions

What is the difference between secure, requireTLS and ignoreTLS?

secure: true means the socket is TLS from the first byte, which is port 465. secure: false starts in plain text and, with requireTLS: true, must upgrade via STARTTLS before sending — that is port 587. ignoreTLS skips the upgrade entirely and sends credentials in the clear; do not use it.

Why does Nodemailer say 'Invalid login: 535 Authentication failed'?

The most frequent cause is writing auth.password instead of auth.pass. Nodemailer ignores the unknown key and authenticates with an empty password. If the key is right, the token login or password is wrong.

Should I create a new transporter for every email?

No. Each createTransport plus send opens a TCP connection and a TLS handshake. Create the transporter once at module scope and reuse it. For high volume, set pool: true so a fixed set of connections stays open.

How many emails can I send through one Nodemailer connection?

SMTP servers cap messages per connection, so a long loop on one connection will eventually be refused. With pool: true, set maxMessages so Nodemailer recycles the connection before the server does, and rateLimit to stay inside the server's rate.

How do I attach a file in Nodemailer?

Pass an attachments array. Each entry takes a filename plus one of path (a file on disk), content (a string, Buffer or stream), or href (a remote URL). Adding a cid lets the HTML reference it inline as <img src="cid:name">.

Why does sending hang with no error?

Usually secure: true on port 587 — the client waits for a TLS handshake the server never starts. Use secure: false with requireTLS: true on 587, or move to port 465 with secure: true.

Next steps