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.
| 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 nodemailer// 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:
// 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 },
});password: produces a login attempt with an empty password and the server replies 535 Authentication failed. Check this before anything else. verify() connects and authenticates without sending, so a bad configuration fails at startup rather than in front of a user:
try {
await transporter.verify();
console.log("SMTP ready");
} catch (err) {
console.error("SMTP configuration is broken:", err.message);
process.exit(1);
} Pass both text and html — Nodemailer assembles a multipart/alternative message, and HTML with no text part is a well-known spam signal:
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.
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" },
],
}); 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:
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));
}
});Bulk mail to Gmail and Yahoo must carry a working one-click unsubscribe header. Nodemailer passes arbitrary headers straight through:
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",
},
});A 4xx SMTP reply or a dropped socket is temporary; a 5xx is permanent and retrying it only burns your reputation:
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));
}
}
}| Error code | Cause and fix |
|---|---|
EAUTH | pass misspelled as password, or wrong token credentials. |
ETIMEDOUT / ECONNREFUSED | Outbound port blocked. Use 8587 or 8465. |
ESOCKET — wrong version number | secure: false on port 465. Set it to true. |
| Hangs, no error | secure: 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. |
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.
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.
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.
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.
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">.
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.