The fastest way to send email from Node.js is with Nodemailer, described in its own docs as “the most popular email sending library for Node.js.” Install it, create an SMTP transporter, and call sendMail():
const nodemailer = require("nodemailer");
const transporter = nodemailer.createTransport({
host: "smtp.example.com",
port: 587,
secure: false, // upgrades to TLS via STARTTLS
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const info = await transporter.sendMail({
from: '"My App" <[email protected]>',
to: "[email protected]",
subject: "Hello from Node.js",
text: "Plain-text fallback.",
html: "<p>Plain-text fallback.</p>",
});
console.log("Message sent:", info.messageId);
That covers the minimal case. For real-world use you have two paths: SMTP (quick to set up, fine for low volume) or a provider HTTP API (faster delivery, status webhooks, better deliverability at scale). Both are covered below. For the concepts behind production sending and inbox placement, see what transactional email is.
Prerequisites
- Node.js 18 or later (LTS). The examples use
async/await. - A project initialized with
npm init. - Either an SMTP account (Gmail works for testing) or an email API key from a provider like Postmark, Resend, or Mailgun. To choose one, see our comparison of transactional email services.
On Gmail: smtp.gmail.com is fine for testing. It requires an app password (a 16-character token from Google Account > Security > App passwords) rather than your regular password. Free Gmail accounts are capped at 500 outgoing messages per day per Google’s own sending limits, so it is not suitable for production sending.
Step 1: Install Nodemailer
npm install nodemailer
Nodemailer has zero runtime dependencies. It works with CommonJS (require) and ESM (import). These examples use CommonJS throughout for compatibility.
Step 2: Create a Transporter
The transporter holds your connection settings. Create it once and reuse it across your application.
const nodemailer = require("nodemailer");
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, // e.g. "smtp.gmail.com"
port: 587,
secure: false, // false = STARTTLS on port 587; true = implicit TLS on port 465
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
Port 587 vs. 465: Use port: 587, secure: false for STARTTLS (the connection starts unencrypted and upgrades). Use port: 465, secure: true for implicit TLS (TLS handshake before any SMTP commands). Port 587 is the standard submission port per RFC 6409; port 465 is legacy but still widely supported.
Keep credentials in environment variables, not source code. A .env file loaded with dotenv is the minimal approach; secrets managers (AWS Secrets Manager, Doppler) are better for production.
Step 3: Send Plain-Text and HTML Email
sendMail() accepts a message object and returns a promise. Always include both text and html: mail clients that cannot render HTML fall back to text.
async function sendWelcomeEmail(toAddress) {
const info = await transporter.sendMail({
from: '"Acme App" <[email protected]>',
to: toAddress,
replyTo: "[email protected]",
subject: "Welcome to Acme",
text: "Thanks for signing up. Visit https://acme.com to get started.",
html: `
<p>Thanks for signing up.</p>
<p><a href="https://acme.com">Get started</a></p>
`,
});
return info.messageId;
}
The from field accepts a display name + address in "Name" <email> format. replyTo sets a separate reply address if you want replies going somewhere other than the sending address.
Step 4: Verify the Connection and Handle Errors
Before sending in a new environment, call transporter.verify() to test DNS resolution, TCP connection, TLS, and authentication without sending a message:
try {
await transporter.verify();
console.log("Server is ready to take our messages");
} catch (err) {
console.error("SMTP verification failed:", err.message);
process.exit(1);
}
verify() catches most configuration errors early: wrong host, bad credentials, blocked ports. Wrap sendMail() calls in try/catch too; SMTP servers can reject messages after authentication (wrong sender domain, rate limits, etc.).
try {
const info = await transporter.sendMail(message);
console.log("Sent:", info.messageId);
} catch (err) {
console.error("Send failed:", err.message);
// log, retry, or enqueue for later
}
Step 5: Attachments and Templated HTML
Add attachments as an array. Each entry needs at minimum a filename and either path (a file on disk) or content (a Buffer or string).
const info = await transporter.sendMail({
from: '"Reports" <[email protected]>',
to: "[email protected]",
subject: "Your monthly report",
text: "See the attached PDF.",
html: "<p>See the attached PDF.</p>",
attachments: [
{
filename: "report-june.pdf",
path: "/tmp/report-june.pdf",
},
],
});
For dynamic content, build the HTML string with template literals or a library like handlebars. Keep the template logic outside the transporter:
const { compile } = require("handlebars");
const template = compile("<p>Hi {{name}}, your order {{orderId}} shipped.</p>");
await transporter.sendMail({
from: '"Acme Orders" <[email protected]>',
to: customer.email,
subject: `Order ${order.id} shipped`,
html: template({ name: customer.name, orderId: order.id }),
text: `Hi ${customer.name}, your order ${order.id} shipped.`,
});
Step 6: Send via a Provider HTTP API for Production
SMTP is a persistent TCP connection per message: fine for tens of emails, awkward at thousands per hour. Most production teams switch to a provider HTTP API for three reasons:
- Delivery speed: API calls submit over HTTPS without the SMTP handshake overhead.
- Status granularity: webhooks fire on delivery, bounce, open, and click events with structured payloads.
- Deliverability: providers manage IP warming, bounce processing, and feedback loops automatically.
The pattern is identical across providers: one POST with an API key in the header and a JSON payload.
const fetch = require("node-fetch"); // or use the native fetch in Node 18+
async function sendViaApi(to, subject, html, text) {
const res = await fetch("https://api.your-provider.com/v1/email/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "[email protected]",
to: [to],
subject,
html,
text,
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Email API error ${res.status}: ${body}`);
}
return res.json();
}
Replace the endpoint and payload shape with your provider’s API reference. If you want templating, automation flows, and API delivery in one platform, Coldletter is built for that use case.
SMTP vs. HTTP API for Node.js
| SMTP (Nodemailer) | Provider HTTP API | |
|---|---|---|
| Setup | npm install + SMTP credentials | API key + one fetch call |
| Throughput | One connection per message by default; pooling possible | Stateless HTTP, scales horizontally |
| Delivery status | Message ID only; no event callbacks | Webhooks: delivered, bounced, opened, clicked |
| Templating | Build HTML yourself | Provider-side templates or your own |
| Deliverability tools | None built-in | Bounce handling, list hygiene, reputation monitoring |
| Best for | Internal tooling, low-volume notifications, local dev | User-facing transactional email at any scale |
Production Notes and Common Mistakes
Authenticate your domain. Without SPF, DKIM, and DMARC records, major providers route your messages to spam. According to Valimail, “authentication alone does not guarantee inbox placement, but lack of authentication is almost always an impediment to reliable inbox placement.” See How to Set Up DMARC for step-by-step DNS configuration.
Credentials belong in environment variables. Never hard-code SMTP passwords or API keys in source files. Use process.env and a .env file for local development; use a secrets manager in CI/CD and production.
Do not send marketing email over transactional SMTP. Mixing bulk campaigns with transactional sends on the same IP risks your transactional reputation. Use separate sending domains and IP pools for each.
Set a proper From and replyTo. The from address must be a domain you control and have authenticated. A replyTo pointing to a monitored inbox builds trust and catches out-of-office replies before they become spam complaints.
Build in retry logic. Transactional sends (password resets, receipts, alerts) should not be fire-and-forget. Log the messageId, watch for bounce webhooks, and retry failed sends with exponential backoff. For why emails reach spam even after all this, see Why Do My Emails Go to Spam?
If you are sending from Python instead, the same SMTP-vs-API tradeoff applies. See how to send email from Python for a parallel walkthrough using smtplib.
Frequently Asked Questions
Is Nodemailer free?
Yes. Nodemailer is open-source and published under the MIT license. There is no usage fee and no rate limit imposed by the library. You pay only for your SMTP service or email API, if those have a cost.
Can I send email in Node.js without SMTP?
Yes. Use a provider HTTP API: authenticate with an API key, POST a JSON payload to the provider’s endpoint, and inspect the HTTP response. No SMTP connection, no persistent TCP socket, no port configuration needed. Most transactional email services (Postmark, Resend, Mailgun, SendGrid) document a REST API alongside their SMTP endpoint.
How do I send email with Gmail in Node.js?
Set host: "smtp.gmail.com", port: 587, secure: false. For authentication, generate an app password in Google Account > Security > App passwords (2-Step Verification must be enabled). Use that 16-character token as auth.pass, not your regular Gmail password. Note that free Gmail accounts have a 500-message daily limit and are not suitable for production sending.
Nodemailer vs. an email API: which should I use?
Use Nodemailer over SMTP for scripts, internal tools, and low-volume notifications where you do not need delivery tracking. Use a provider HTTP API for any user-facing email (welcome messages, password resets, receipts) where you need bounce handling, retry logic, delivery webhooks, and reliable inbox placement at scale. The two are not mutually exclusive: Nodemailer also supports provider-specific transports.
How do I send HTML email in Node.js with Nodemailer?
Add an html field to your sendMail() options alongside text. The html value is an HTML string. Always include text as a plain-text fallback; clients that cannot render HTML display it instead. For complex layouts, build the HTML string with a template engine like Handlebars or Mustache before passing it to sendMail().
Why are my Node.js emails going to spam?
The most common causes are missing domain authentication (no SPF, DKIM, or DMARC records), sending from a shared IP with a poor reputation, or using a personal Gmail account for bulk sending. Start by verifying your domain’s DNS authentication records with a tool like MXToolbox, then review common spam triggers for a full diagnostic checklist.
Which transactional email service works best with Node.js?
All major services (Postmark, Resend, Mailgun, SendGrid, Brevo) offer both SMTP and an HTTP API, so any of them work with Nodemailer or a plain fetch call. The differences are in pricing tiers, deliverability tooling, template editors, and webhook granularity. See the best transactional email services comparison for a side-by-side breakdown.
I’ve spent my career building software at scale with a soft spot for email: deliverability, lifecycle campaigns, and getting messages to actually land. I started Coldletter to fix what bugged me about transactional and marketing email tools. I’m based in Vancouver.
