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.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
npm install nodemailerBuild 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
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"
},
});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:
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:
SMTP_USER=token-login@your-domain.com
SMTP_PASS=your-token-passwordverify() 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:
// Fail fast at boot rather than on the first customer email.
await transporter.verify();
console.log("SMTP connection ready");// 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.
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:
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>",
}); 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">:
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
],
});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:
// 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();streamTransport builds the full message and hands it back instead of delivering it — useful in development and in tests:
// Development: capture messages instead of delivering them.
const transporter = nodemailer.createTransport({
streamTransport: true,
newline: "unix",
buffer: true,
});| Error | Cause 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 error | secure: 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. |
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.
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.
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.
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.
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.
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.