Sending Email from Node.js with Nodemailer: SMTP Services, HTML Templates, Attachments and Pooling
Key takeaways
Nodemailer is the most popular email sending library for Node.js. It supports SMTP, attachments, HTML emails, and works with all major email services.
Introduction
Nodemailer is a module for Node.js applications to send emails. It’s easy to use, supports all major email providers, and is production-ready.
Sending an email sounds trivial until you actually try to do it from a raw socket. The SMTP protocol dates back to 1982, and while the core command exchange (HELO, MAIL FROM, RCPT TO, DATA) is simple text, the surrounding machinery — encrypted handshakes, authentication mechanisms, MIME multipart encoding for attachments, character set handling for non-ASCII subject lines — is not something you want to hand-roll in application code. A single malformed header or an incorrectly base64-encoded attachment can cause a message to silently disappear into a spam folder, or get rejected outright by a receiving mail server. Nodemailer exists to absorb that complexity: it manages socket-level SMTP negotiation, TLS upgrade, connection pooling, and MIME construction so your application code can stay focused on what to send rather than how the bytes get onto the wire.
That said, Nodemailer only solves the transport-layer problem. It does not make your email arrive in the inbox, and it does not make your sending domain trustworthy to Gmail, Outlook, or corporate spam filters. That second problem — deliverability — is arguably harder than the code, and this guide spends real time on it further down, because it’s the part most tutorials skip and the part that actually determines whether your password-reset emails get read.
Why Nodemailer?
// Before: Manual SMTP is complex
// Dealing with sockets, authentication, headers...
// With Nodemailer: Simple and clean
const transporter = nodemailer.createTransport({ /* config */ });
await transporter.sendMail({ from, to, subject, text });
The createTransport() call above hides a surprising amount of work. Under the hood, Nodemailer opens a TCP socket to the SMTP host, performs an EHLO handshake to discover what the server supports (STARTTLS, authentication mechanisms, maximum message size), negotiates TLS if required, authenticates with the credentials you provided, and only then accepts your message for delivery. sendMail() then serializes your to/from/subject/text/html/attachments object into a properly formed MIME message — this includes choosing a multipart boundary, base64-encoding binary attachments, and setting the correct Content-Type headers for each part. Getting any of this wrong by hand is a common source of “email shows up with broken formatting” bugs, which is exactly the class of bug Nodemailer is designed to eliminate.
Installation
npm install nodemailer
Basic Email
const nodemailer = require('nodemailer');
// Create transporter
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: '[email protected]',
pass: 'your-app-password', // Use app password, not account password
},
});
// Send email
const mailOptions = {
from: '[email protected]',
to: '[email protected]',
subject: 'Hello from Nodemailer',
text: 'This is a plain text email.',
};
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
console.error(error);
} else {
console.log('Email sent:', info.response);
}
});
// Or with async/await
async function sendEmail() {
try {
const info = await transporter.sendMail(mailOptions);
console.log('Email sent:', info.messageId);
} catch (error) {
console.error('Error sending email:', error);
}
}
A detail that trips up almost every developer the first time: the pass field above is not your normal Gmail account password. Since 2022, Google requires 2-Step Verification to be enabled on the account, after which you generate a 16-character App Password specifically for this purpose (myaccount.google.com/apppasswords). This exists because Google’s own login flow expects interactive OAuth or 2FA challenges that a headless SMTP client can never satisfy — an app password is a scoped credential that bypasses that requirement while limiting the blast radius if it leaks. If you use your real account password, authentication will fail with an Invalid login error regardless of whether the password is otherwise correct.
It’s also worth understanding why the callback-style and async/await versions both exist in the example above. sendMail() supports both a Node-style (error, info) callback and a Promise-returning form; the Promise form is what you want in any modern codebase, since it composes with try/catch, Promise.all() for concurrent sends, and async route handlers in frameworks like Express. The callback form is really only kept around for backward compatibility with older code.
Email Services
Gmail
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: '[email protected]',
pass: 'your-app-password', // Generate at myaccount.google.com/apppasswords
},
});
Outlook/Hotmail
const transporter = nodemailer.createTransport({
service: 'hotmail',
auth: {
user: '[email protected]',
pass: 'your-password',
},
});
Custom SMTP
const transporter = nodemailer.createTransport({
host: 'smtp.example.com',
port: 587,
secure: false, // true for 465, false for other ports
auth: {
user: 'username',
pass: 'password',
},
});
Why the port matters more than it looks
The port/secure combination above is the single most common source of “it hangs and eventually times out” bug reports for Nodemailer, and it’s worth understanding the three ports involved instead of copy-pasting a value and hoping:
- Port 25 is the original SMTP port, used almost exclusively for server-to-server relay today. Most residential ISPs and a large share of cloud providers (AWS, GCP, Azure, DigitalOcean) block outbound traffic on port 25 by default to cut down on spam bots — so if you try to connect on 25 from an EC2 instance without first requesting a block removal, you’ll get connection timeouts that have nothing to do with your code.
- Port 465 is “SMTPS” — TLS is established immediately on connect, before any SMTP commands are exchanged. This is what
secure: truemeans in Nodemailer: the whole session, from the first byte, runs inside a TLS tunnel. - Port 587 is the modern submission port. The connection starts in plaintext, the client sends
EHLO, and then issues aSTARTTLScommand to upgrade the existing plaintext connection to TLS mid-stream. This is whatsecure: falseplus a compliant server actually means — despite the name,secure: falsedoes not mean “unencrypted.” Nodemailer still auto-upgrades via STARTTLS on port 587 as long as the server advertises support for it.
The bug pattern to watch for: setting secure: true while pointing at port 587 (or vice versa) causes the TLS handshake to start at the wrong point in the conversation, and most servers respond by hanging until the client times out rather than returning a clean error. If a transporter connection is timing out for no obvious reason, mismatched port/secure settings are the first thing to check — the fix is almost always port: 587, secure: false (STARTTLS) for outbound submission, reserving port: 465, secure: true for providers that specifically require implicit TLS.
AWS SES
const transporter = nodemailer.createTransport({
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
secure: false,
auth: {
user: 'YOUR_SMTP_USERNAME',
pass: 'YOUR_SMTP_PASSWORD',
},
});
Deliverability: the part the code examples don’t show you
This is the section most Nodemailer tutorials skip, and it’s the reason a syntactically perfect email can still never reach the inbox. Getting sendMail() to resolve without throwing only proves that your SMTP server accepted the message for delivery — it says nothing about whether the receiving server (Gmail, Outlook, a corporate spam gateway) decided to deliver it, quarantine it as spam, or silently drop it.
Modern mail providers authenticate incoming mail against three DNS-based mechanisms, and a domain missing any of them will see a meaningful share of its mail routed to spam or rejected outright:
- SPF (Sender Policy Framework) — a DNS
TXTrecord on your sending domain that lists which mail servers are authorized to send mail claiming to be from that domain. If your app sends viasmtp.example.combut your domain’s SPF record doesn’t list that server, receiving servers treat the message as potentially spoofed. - DKIM (DomainKeys Identified Mail) — the sending server cryptographically signs each outgoing message with a private key; the public key is published in DNS. This lets the receiver verify the message wasn’t altered in transit and genuinely originated from a server holding the private key — something raw SMTP with only a username/password can’t prove on its own.
- DMARC (Domain-based Message Authentication, Reporting and Conformance) — a policy record that tells receiving servers what to do when SPF or DKIM checks fail (quarantine, reject, or do nothing), and where to send failure reports. Without a DMARC record, receivers fall back to their own heuristics, which tend to be conservative.
This is precisely why sending transactional email through a personal Gmail account’s raw SMTP credentials — the pattern shown earlier in this guide — works fine for a side project but breaks down at any real volume: Gmail’s own sending infrastructure has excellent reputation, but your use of it as a relay doesn’t inherit SPF/DKIM alignment with your own application domain, and Gmail rate-limits and throttles accounts that show automated sending patterns. Beyond a few dozen emails a day, you risk the account itself getting flagged.
This is the practical reason to use a dedicated Email Service Provider (ESP) like AWS SES, SendGrid, Postmark, or Resend instead of raw SMTP credentials for anything user-facing. These services:
- Manage SPF/DKIM/DMARC alignment for you (or make it a guided DNS setup step) once you verify your sending domain.
- Maintain dedicated or shared IP pools with sending reputations they actively monitor and protect.
- Provide bounce and complaint webhooks, so your application can detect and stop emailing addresses that hard-bounce or mark you as spam — continuing to send to those addresses is itself a reputation hit.
- Offer real send-volume headroom (SES scales to millions of emails/day; a personal Gmail account does not).
The important nuance is that Nodemailer and an ESP are not competing choices — as the code above shows, AWS SES exposes an SMTP endpoint, so you keep using nodemailer.createTransport() exactly as you would for any custom SMTP host, just pointed at SES’s endpoint with SES-issued SMTP credentials. Nodemailer is the transport client; the ESP is the reputation and deliverability layer underneath it. For a low-volume internal tool or a personal project, Gmail SMTP is a reasonable shortcut. For anything sending password resets, receipts, or notifications to real users, route through an ESP from day one — retrofitting deliverability after users start reporting “I never got the email” is far more painful than configuring it upfront.
HTML Emails
const mailOptions = {
from: '[email protected]',
to: '[email protected]',
subject: 'HTML Email Example',
html: `
<h1>Welcome to Our Service!</h1>
<p>Thank you for signing up.</p>
<p>Click the button below to verify your email:</p>
<a href="https://example.com/verify?token=abc123"
style="display: inline-block; padding: 10px 20px;
background: #007bff; color: white;
text-decoration: none; border-radius: 5px;">
Verify Email
</a>
`,
};
await transporter.sendMail(mailOptions);
Attachments
const mailOptions = {
from: '[email protected]',
to: '[email protected]',
subject: 'Email with Attachments',
text: 'Please see attached files.',
attachments: [
// File path
{
filename: 'document.pdf',
path: './files/document.pdf',
},
// Buffer
{
filename: 'data.txt',
content: Buffer.from('This is file content'),
},
// Stream
{
filename: 'report.csv',
content: fs.createReadStream('./files/report.csv'),
},
// URL
{
filename: 'logo.png',
path: 'https://example.com/logo.png',
},
],
};
await transporter.sendMail(mailOptions);
Embedded Images
const mailOptions = {
from: '[email protected]',
to: '[email protected]',
subject: 'Email with Embedded Image',
html: `
<h1>Check out our logo!</h1>
<img src="cid:logo" alt="Logo" width="200">
`,
attachments: [
{
filename: 'logo.png',
path: './images/logo.png',
cid: 'logo', // Same as in <img src="cid:logo">
},
],
};
await transporter.sendMail(mailOptions);
Email Templates
Handlebars Template
npm install handlebars
const handlebars = require('handlebars');
const fs = require('fs').promises;
async function sendTemplatedEmail(to, data) {
// Read template
const templateSource = await fs.readFile('./templates/welcome.hbs', 'utf8');
// Compile template
const template = handlebars.compile(templateSource);
// Generate HTML
const html = template(data);
// Send email
await transporter.sendMail({
from: '[email protected]',
to,
subject: 'Welcome!',
html,
});
}
// Usage
await sendTemplatedEmail('[email protected]', {
name: 'Alice',
verifyUrl: 'https://example.com/verify?token=abc123',
});
Template (welcome.hbs):
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; }
.button {
display: inline-block;
padding: 10px 20px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 5px;
}
</style>
</head>
<body>
<h1>Welcome, {{name}}!</h1>
<p>Thank you for signing up.</p>
<p>Please verify your email:</p>
<a href="{{verifyUrl}}" class="button">Verify Email</a>
</body>
</html>
Multiple Recipients
// Multiple recipients (comma-separated)
const mailOptions = {
from: '[email protected]',
to: '[email protected], [email protected], [email protected]',
subject: 'Multiple Recipients',
text: 'This email goes to multiple people.',
};
// CC and BCC
const mailOptions = {
from: '[email protected]',
to: '[email protected]',
cc: '[email protected]',
bcc: '[email protected]',
subject: 'CC and BCC Example',
text: 'CC sees copy@, BCC doesn\'t see others.',
};
Sending Bulk Emails and Connection Pooling
async function sendBulkEmails(recipients) {
for (const recipient of recipients) {
try {
await transporter.sendMail({
from: '[email protected]',
to: recipient.email,
subject: 'Personalized Email',
html: `<p>Hello ${recipient.name}!</p>`,
});
console.log(`Email sent to ${recipient.email}`);
// Rate limiting (Gmail: 500 per day, 100 per hour)
await new Promise(resolve => setTimeout(resolve, 1000)); // 1 second delay
} catch (error) {
console.error(`Failed to send to ${recipient.email}:`, error);
}
}
}
const recipients = [
{ email: '[email protected]', name: 'Alice' },
{ email: '[email protected]', name: 'Bob' },
{ email: '[email protected]', name: 'Charlie' },
];
await sendBulkEmails(recipients);
The loop above works, but notice what it’s actually doing under the hood: by default, a Nodemailer transporter created with createTransport() opens a new TCP connection and re-authenticates for every single sendMail() call, then tears the connection down. For three emails that’s irrelevant overhead. For a newsletter going out to ten thousand subscribers, that’s ten thousand TCP handshakes, ten thousand TLS negotiations, and ten thousand SMTP AUTH round-trips — most of the wall-clock time is spent on connection setup, not actual message transfer.
Nodemailer’s built-in pool: true option solves this by keeping a small set of persistent SMTP connections open and reusing them across many sendMail() calls:
const transporter = nodemailer.createTransport({
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
secure: false,
auth: {
user: process.env.SES_SMTP_USER,
pass: process.env.SES_SMTP_PASS,
},
pool: true, // Reuse connections instead of one per send
maxConnections: 5, // Concurrent SMTP connections
maxMessages: 100, // Recycle a connection after N messages
rateDelta: 1000, // Rate limiting window (ms)
rateLimit: 5, // Max messages per rateDelta window
});
// sendMail calls now share pooled connections automatically
for (const recipient of recipients) {
await transporter.sendMail({
from: '[email protected]',
to: recipient.email,
subject: 'Personalized Email',
html: `<p>Hello ${recipient.name}!</p>`,
});
}
A few things worth understanding about these options rather than treating them as magic numbers: maxConnections caps how many sockets are open to the SMTP server simultaneously — set this too high and you risk tripping the provider’s own concurrent-connection limits (SES, for instance, enforces an account-level connection cap). maxMessages forces a connection to close and reopen after a number of sends, which avoids issues where some SMTP servers silently drop long-lived idle-ish connections. rateLimit/rateDelta throttles the pool itself, which is a more reliable way to respect a provider’s sending-rate ceiling than the manual setTimeout delay in the loop above — a fixed per-message delay doesn’t account for concurrent connections each sending in parallel, so the effective throughput with maxConnections: 5 and a naive delay could be five times higher than you intended.
For genuinely large sends (tens of thousands of recipients), pooling alone isn’t enough — that’s the point where you want a real background job queue (see the Queue for Bulk Emails pattern below) so that a crashed process doesn’t lose track of who has and hasn’t received their email, and so retries don’t re-send to people who already got the message.
Express Integration
const express = require('express');
const nodemailer = require('nodemailer');
const app = express();
app.use(express.json());
const transporter = nodemailer.createTransport({ /* config */ });
// Contact form
app.post('/api/contact', async (req, res) => {
try {
const { name, email, message } = req.body;
// Validate input
if (!name || !email || !message) {
return res.status(400).json({ error: 'All fields required' });
}
// Send email
await transporter.sendMail({
from: process.env.EMAIL_FROM,
to: process.env.EMAIL_TO,
replyTo: email,
subject: `Contact Form: ${name}`,
text: message,
html: `
<h3>New Contact Form Submission</h3>
<p><strong>From:</strong> ${name} (${email})</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
res.json({ message: 'Email sent successfully' });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to send email' });
}
});
app.listen(3000);
Verification Emails
const crypto = require('crypto');
async function sendVerificationEmail(user) {
// Generate verification token
const token = crypto.randomBytes(32).toString('hex');
// Save token to database
await db.users.update(user.id, {
verificationToken: token,
verificationExpiry: Date.now() + 3600000, // 1 hour
});
// Send email
const verifyUrl = `${process.env.APP_URL}/verify?token=${token}`;
await transporter.sendMail({
from: '[email protected]',
to: user.email,
subject: 'Verify Your Email',
html: `
<h1>Email Verification</h1>
<p>Hi ${user.name},</p>
<p>Please verify your email by clicking the link below:</p>
<a href="${verifyUrl}">Verify Email</a>
<p>This link expires in 1 hour.</p>
`,
});
}
Password Reset
async function sendPasswordResetEmail(user) {
// Generate reset token
const token = crypto.randomBytes(32).toString('hex');
const hash = await bcrypt.hash(token, 10);
// Save hashed token
await db.users.update(user.id, {
resetToken: hash,
resetExpiry: Date.now() + 3600000,
});
// Send email
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;
await transporter.sendMail({
from: '[email protected]',
to: user.email,
subject: 'Password Reset Request',
html: `
<h1>Password Reset</h1>
<p>Hi ${user.name},</p>
<p>You requested a password reset. Click the link below:</p>
<a href="${resetUrl}">Reset Password</a>
<p>This link expires in 1 hour.</p>
<p>If you didn't request this, ignore this email.</p>
`,
});
}
Testing
// Use Ethereal for testing (fake SMTP)
const nodemailer = require('nodemailer');
async function createTestTransporter() {
// Generate test account
const testAccount = await nodemailer.createTestAccount();
// Create transporter
const transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
return transporter;
}
// Usage
const transporter = await createTestTransporter();
const info = await transporter.sendMail({
from: '[email protected]',
to: '[email protected]',
subject: 'Test Email',
text: 'This is a test email.',
});
// Preview URL
console.log('Preview URL:', nodemailer.getTestMessageUrl(info));
// https://ethereal.email/message/xxx...
Send failures, queues and abuse of email endpoints
Classify send failures instead of catching them all
sendMail rejects for very different reasons, and a single catch that logs and returns { success: false } treats them all the same. Production systems generally need to distinguish why the send failed, because the correct response is different for each category:
- Connection/timeout errors (
ECONNREFUSED,ETIMEDOUT,ESOCKET) mean the SMTP server was unreachable — often transient (a network blip, the provider briefly rate-limiting your IP). These are safe to retry with backoff. - Authentication errors (SMTP code
535, Nodemailer’sEAUTH) mean your credentials are wrong or an app password was revoked. Retrying immediately will not help — this should page a human or trigger an alert, not silently retry forever. - Recipient errors (SMTP
550,551— “mailbox unavailable”) mean the address doesn’t exist or the receiving server rejected it outright. Retrying is pointless and can hurt your sender reputation if you retry a hard bounce repeatedly; the address should instead be flagged in your database so you stop emailing it. - Throttling errors (SMTP
421,454, or a provider-specific “daily quota exceeded”) mean you’re sending faster than the provider allows. This calls for backing off and slowing down — not an infinite retry loop that hammers the server harder.
A more production-shaped version distinguishes these cases and decides whether to retry, alert, or mark the recipient as invalid:
async function sendEmailWithClassification(options) {
try {
const info = await transporter.sendMail(options);
return { success: true, messageId: info.messageId };
} catch (error) {
const code = error.responseCode; // SMTP status code, when available
if (error.code === 'EAUTH') {
// Credentials are bad — alert immediately, retrying won't fix it
await alertOncall('Nodemailer auth failure', error);
return { success: false, retryable: false, reason: 'auth' };
}
if (code >= 500 && code < 600) {
// Permanent failure (bad address, policy rejection) — do not retry
return { success: false, retryable: false, reason: 'rejected' };
}
if (code >= 400 && code < 500) {
// Temporary failure (throttling, mailbox full) — safe to retry later
return { success: false, retryable: true, reason: 'temporary' };
}
// Network-level failure — safe to retry with backoff
return { success: false, retryable: true, reason: 'network' };
}
}
This kind of classification is exactly what a queue with retry semantics (below) is built to consume: retryable failures go back on the queue with exponential backoff, non-retryable ones get logged and surfaced without wasting further send attempts.
Queue bulk sends
const Bull = require('bull');
const emailQueue = new Bull('email');
// Add to queue
emailQueue.add({ to: '[email protected]', subject: '...', html: '...' });
// Process queue
emailQueue.process(async (job) => {
await transporter.sendMail(job.data);
});
A queue moves sending out of the request, so an HTTP handler no longer waits on a slow SMTP server, and failed jobs can be retried using the classification above. Keep the job payload small (recipient, template name, variables) and render the HTML in the worker; a fully rendered HTML body with inline images stored per job adds up quickly in Redis. Because a job can run more than once after a worker crash, record which message was sent (for example by a per-recipient idempotency key) before treating a retry as safe.
Rate-limit the endpoints that send mail
const rateLimit = require('express-rate-limit');
app.set('trust proxy', 1); // when running behind one reverse proxy or load balancer
const emailLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 emails per window
message: 'Too many emails sent, try again later',
});
app.post('/api/contact', emailLimiter, async (req, res) => {
// Send email
});
Any public endpoint that triggers an email (contact forms, password reset, sign-up confirmation) can be used to make your server send mail on someone else’s behalf, which burns your sending reputation. Two details decide whether the limiter actually works. Behind a reverse proxy, Express sees the proxy’s IP for every request unless trust proxy is set, so all users share one bucket; set it to the number of proxies you really have, not true, or clients can spoof X-Forwarded-For to get a fresh bucket. And never take the recipient address from the request body for a contact form: send to your own fixed inbox and put the visitor’s address in replyTo, so the form cannot be turned into an open relay.