Docs / Express.js

Send email in Express.js over SMTP

Express has no mail layer of its own, so sending email from an Express app means using Nodemailer — the de-facto SMTP client for Node.js. This guide wires Nodemailer up to Postwing, drops it into a real route, and covers HTML mail, attachments and pooled bulk sending.

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 the transport

Build the transporter once, at module scope, and import it wherever you send. Creating one per request opens a fresh TCP connection and TLS handshake every time:

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

export const transporter = nodemailer.createTransport({
  host: "smtp.postwing.app",
  port: 587,
  secure: false,          // false on 587 — STARTTLS is negotiated by requireTLS
  requireTLS: true,       // refuse to send if the upgrade to TLS fails
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,   // note: "pass", not "password"
  },
});
Nodemailer ignores unknown keys, so password: produces a login attempt with no password at all and the server answers 535 Authentication failed. This is the most common mistake in Nodemailer configurations.

To use implicit TLS on port 465 instead:

javascript
const transporter = nodemailer.createTransport({
  host: "smtp.postwing.app",
  port: 465,
  secure: true,           // implicit TLS — encrypted from the first byte
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});

Credentials belong in the environment, not in the source file:

.env
SMTP_USER=token-login@your-domain.com
SMTP_PASS=your-token-password

Verify the connection at startup

verify() performs a connection and login without sending anything, so a broken configuration surfaces when the process boots rather than when a customer submits a form:

javascript
// Fail fast at boot rather than on the first customer email.
await transporter.verify();
console.log("SMTP connection ready");

Send from an Express route

app.js
// app.js
import express from "express";
import { transporter } from "./mailer.js";

const app = express();
app.use(express.json());

app.post("/api/contact", async (req, res) => {
  const { email, message } = req.body;

  try {
    const info = await transporter.sendMail({
      from: '"Acme" <noreply@your-domain.com>',
      to: "sales@your-domain.com",
      replyTo: email,
      subject: "New contact form submission",
      text: message,
      html: `<p>${escapeHtml(message)}</p>`,
    });

    res.json({ ok: true, messageId: info.messageId });
  } catch (err) {
    console.error("Email failed", err);
    res.status(502).json({ ok: false });
  }
});

app.listen(3000);

Note the replyTo: the from address stays on your verified domain — which is what DKIM and SPF are published for — while replies still reach the person who filled in the form. Putting the visitor's address in from instead is what makes contact-form mail fail DMARC.

Send an HTML email

Pass both text and html. Nodemailer builds a multipart/alternative message from the pair; sending HTML with no text part is a well-known spam signal:

javascript
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.",   // always include a text part
  html: "<h1>Order confirmed</h1><p>Your order ships tomorrow.</p>",
});

Send an email with attachments

Attachments come from a path, a string, a Buffer or a stream. Giving one a cid lets you reference it inline from the HTML with <img src="cid:logo">:

javascript
await transporter.sendMail({
  from: '"Acme" <noreply@your-domain.com>',
  to: "customer@example.com",
  subject: "Your invoice",
  text: "The invoice for March is attached.",
  attachments: [
    { filename: "invoice.pdf", path: "/srv/invoices/2026-03.pdf" },
    { filename: "report.csv", content: csvString },          // from a string
    { filename: "logo.png", content: pngBuffer, cid: "logo" }, // inline via cid:logo
  ],
});

Send to many recipients

For bulk sending, enable pooling so a small set of connections is kept warm instead of reconnecting per message, and send one message per recipient:

javascript
// Reuse a handful of connections instead of reconnecting per message.
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,
  maxMessages: 100,
  rateLimit: 10,        // messages per second
});

for (const user of users) {
  await bulk.sendMail({
    from: '"Acme" <noreply@your-domain.com>',
    to: user.email,     // one recipient per message
    subject: "Your weekly report",
    text: renderReport(user),
  });
}

bulk.close();
Attention!
When using BCC, CC - a separate email will be created for each recipient in the system and your limits will be used.

Test without sending real email

streamTransport builds the full message and hands it back instead of delivering it — useful in development and in tests:

javascript
// Development: capture messages instead of delivering them.
const transporter = nodemailer.createTransport({
  streamTransport: true,
  newline: "unix",
  buffer: true,
});

Troubleshooting

ErrorCause and fix
EAUTH — Invalid login: 535 Usually password: instead of pass:. Otherwise the token login or password is wrong.
ETIMEDOUT / ECONNREFUSED Outbound port blocked by the host. Switch to 8587 or 8465.
Handshake hangs, no errorsecure: true on port 587. Use secure: false with requireTLS: true, or move to port 465.
ESOCKET — wrong version number The mirror image: secure: false on port 465. Set secure: true.
EENVELOPE — 550 sender rejected The from domain is not verified, or the token does not cover it.
Too many messages per connection Bulk loop on a single connection. Enable pool: true and set maxMessages.

Frequently asked questions

Why does Nodemailer fail with 'Invalid login: 535 Authentication failed'?

Almost always the wrong key name: the option is auth.pass, not auth.password. Nodemailer silently sends no password for an unknown key, so the server rejects the login. Check that too before assuming the credentials themselves are wrong.

Should secure be true or false in Nodemailer?

secure: false with port 587 means 'start plain, then upgrade with STARTTLS' — add requireTLS: true so it refuses to send if the upgrade fails. secure: true is for port 465, where the connection is encrypted from the first byte. Setting secure: true on 587 makes the handshake hang.

Is it safe to set rejectUnauthorized: false?

No. It disables TLS certificate verification, which makes the connection trivially interceptable, and it hides the real problem — usually an out-of-date CA bundle or a proxy. Leave verification on; a valid certificate needs no override.

Why do emails work on my laptop but time out on the server?

Most cloud providers block outbound ports 25, 465 and 587. Use the matching high port instead — 8587 for STARTTLS or 8465 for implicit TLS — and the connection will establish.

Should I create the Nodemailer transporter per request?

No — create it once at module scope and reuse it. Building a transporter per request opens a new TCP connection and TLS handshake every time. For bulk sending, enable pool: true so a small set of connections is kept warm.

How do I send HTML email from a template in Express?

Render the HTML with whatever view engine you already use — EJS, Pug or Handlebars — and pass the resulting string as the html option. Set text as well, since a message with no plain-text part is a spam signal.

Next steps