Fix: Nodemailer Not Working — Emails Not Sending, SMTP Auth Failing, or Gmail Blocking
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Nodemailer issues — SMTP configuration, Gmail OAuth2, TLS errors, connection timeouts, email not delivered, and using with Next.js or Express.
The Problem
Nodemailer throws an SMTP authentication error:
Error: Invalid login: 535-5.7.8 Username and Password not acceptedOr the connection times out:
Error: connect ETIMEDOUT 173.194.76.108:465Or emails appear to send (no errors) but never arrive:
const info = await transporter.sendMail({ ... });
console.log('Message sent:', info.messageId); // Logged, but email never arrivesWhy This Happens
Nodemailer itself is a reliable library — most failures are actually configuration issues with the SMTP server or email provider. The SMTP protocol predates the modern serverless platforms developers run code on, and it carries assumptions (long-lived TCP connections, outbound port 25/465/587, authenticated relay) that don’t always survive a deploy to Vercel, Cloudflare Workers, or AWS Lambda.
- Gmail no longer accepts plain passwords — since May 2022, Google disabled “less secure app access.” You must use an App Password (with 2FA) or OAuth2. Microsoft 365 made the same change for Basic Auth in late 2022. If a tutorial older than 2022 says “just put your password in,” that path is closed.
- Port/TLS mismatch — port 465 requires
secure: true(SMTPS). Port 587 requiressecure: falsewithSTARTTLS. Using the wrong combination causes connection failures. Port 25 is blocked outbound by almost every cloud provider (AWS, GCP, Azure, DigitalOcean) by default to suppress spam. - Emails landing in spam — SPF, DKIM, and DMARC records are missing or incorrect. Without them, receiving mail servers mark your email as spam or silently drop it. Gmail and Yahoo both enforced DMARC alignment for bulk senders starting February 2024, so a setup that worked in 2023 may now silently bounce.
- Serverless/Edge constraints — platforms like Vercel don’t guarantee persistent SMTP connections. Use a transactional email service (Resend, SendGrid, Postmark) instead of raw SMTP in production serverless environments. Cloudflare Workers cannot speak SMTP at all because outbound TCP is not part of the runtime.
A deeper issue is that Nodemailer hides reliability problems behind a messageId. The library considers the message “sent” the moment the SMTP server accepts it for relay — not when it lands in the recipient’s inbox. So info.messageId is logged, your code thinks it succeeded, and the email actually bounced or got greylisted at the receiving end. The only way to know is to check the SMTP server’s logs (or move to an API-based provider that reports delivery status back).
Fix 1: Gmail Configuration
Option A: App Password (simplest)
- Enable 2-Step Verification on your Google account
- Go to Google Account → Security → 2-Step Verification → App passwords
- Generate an app password for “Mail”
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: '[email protected]',
pass: 'abcd efgh ijkl mnop', // 16-char App Password (no spaces needed)
},
});Option B: OAuth2 (more secure for production)
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
type: 'OAuth2',
user: '[email protected]',
clientId: process.env.GMAIL_CLIENT_ID,
clientSecret: process.env.GMAIL_CLIENT_SECRET,
refreshToken: process.env.GMAIL_REFRESH_TOKEN,
accessToken: process.env.GMAIL_ACCESS_TOKEN, // Optional — auto-refreshed
},
});
// Get OAuth2 credentials:
// 1. Create a project in Google Cloud Console
// 2. Enable the Gmail API
// 3. Create OAuth2 credentials (Desktop or Web app)
// 4. Use OAuth2 Playground to get refresh token:
// https://developers.google.com/oauthplayground
// Scope: https://mail.google.com/Pro Tip: For production apps, don’t use your personal Gmail. Use a Google Workspace account or a dedicated transactional email service. Gmail has a 500 emails/day limit and is not designed for bulk sending.
Fix 2: SMTP Port and TLS Configuration
// Port 587 — STARTTLS (recommended)
const transporter = nodemailer.createTransport({
host: 'smtp.example.com',
port: 587,
secure: false, // false = STARTTLS (upgrades after connection)
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// Port 465 — SMTPS (TLS from the start)
const transporter = nodemailer.createTransport({
host: 'smtp.example.com',
port: 465,
secure: true, // true = TLS immediately
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// Port 25 — legacy, often blocked by ISPs and cloud providers
// Avoid port 25 in modern apps
// Self-signed certificates (dev/staging only)
const transporter = nodemailer.createTransport({
host: 'localhost',
port: 1025,
secure: false,
tls: {
rejectUnauthorized: false, // Disable cert validation — dev only!
},
});// Verify the SMTP connection before sending
await transporter.verify();
console.log('SMTP server is ready to accept messages');
// Throws an error with details if connection failsFix 3: Sending Emails
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT ?? '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// Plain text email
const info = await transporter.sendMail({
from: '"My App" <[email protected]>', // Display name + address
to: '[email protected]', // Comma-separated list for multiple
subject: 'Welcome to My App',
text: 'Hello! Thanks for signing up.',
});
console.log('Message ID:', info.messageId);
console.log('Accepted:', info.accepted); // Addresses that accepted the email
console.log('Rejected:', info.rejected); // Addresses that rejected
// HTML email
await transporter.sendMail({
from: '"My App" <[email protected]>',
to: '[email protected]',
subject: 'Email Verification',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Verify your email</h2>
<p>Click the button below to verify your email address.</p>
<a
href="https://myapp.com/verify?token=${verifyToken}"
style="background: #3b82f6; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; display: inline-block;"
>
Verify Email
</a>
<p style="color: #6b7280; font-size: 14px; margin-top: 24px;">
If you didn't sign up, you can ignore this email.
</p>
</div>
`,
text: `Verify your email: https://myapp.com/verify?token=${verifyToken}`,
// Always include a text fallback for accessibility and spam score
});// Email with attachments
await transporter.sendMail({
from: '[email protected]',
to: '[email protected]',
subject: 'Your Invoice',
html: '<p>Please find your invoice attached.</p>',
attachments: [
{
filename: 'invoice.pdf',
path: '/tmp/invoice.pdf', // File path
},
{
filename: 'logo.png',
content: fs.readFileSync('./public/logo.png'), // Buffer
cid: 'logo@myapp', // Content ID for inline images
},
{
filename: 'data.csv',
content: csvString, // String content
contentType: 'text/csv',
},
],
});
// Inline image in HTML (use cid reference)
html: '<img src="cid:logo@myapp" alt="Logo" />'Fix 4: Next.js Integration
// app/api/send-email/route.ts — App Router
import { NextRequest, NextResponse } from 'next/server';
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT ?? '587'),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
export async function POST(req: NextRequest) {
try {
const { to, subject, message } = await req.json();
// Basic validation
if (!to || !subject || !message) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(to)) {
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
}
await transporter.sendMail({
from: process.env.EMAIL_FROM,
to,
subject,
text: message,
});
return NextResponse.json({ success: true });
} catch (err) {
console.error('Email send failed:', err);
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 });
}
}Warning: Nodemailer with raw SMTP can be unreliable on serverless platforms. Vercel functions have a 10–60 second execution limit and don’t maintain persistent connections well. For production Next.js apps, consider using Resend or SendGrid via their HTTP API instead of SMTP.
// Production alternative: Resend (HTTP-based, no SMTP issues)
// npm install resend
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: '[email protected]',
to: '[email protected]',
subject: 'Welcome!',
html: '<p>Welcome to My App</p>',
});Fix 5: Common Provider Configurations
// Outlook / Microsoft 365
const transporter = nodemailer.createTransport({
host: 'smtp.office365.com',
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
tls: {
ciphers: 'SSLv3',
},
});
// Amazon SES
const transporter = nodemailer.createTransport({
host: `email-smtp.${process.env.AWS_REGION}.amazonaws.com`,
port: 587,
secure: false,
auth: {
user: process.env.SES_SMTP_USERNAME, // From SES SMTP credentials (not AWS access key)
pass: process.env.SES_SMTP_PASSWORD,
},
});
// SendGrid
const transporter = nodemailer.createTransport({
host: 'smtp.sendgrid.net',
port: 587,
secure: false,
auth: {
user: 'apikey', // Literally the string "apikey"
pass: process.env.SENDGRID_API_KEY,
},
});
// Mailgun
const transporter = nodemailer.createTransport({
host: 'smtp.mailgun.org',
port: 587,
secure: false,
auth: {
user: process.env.MAILGUN_SMTP_LOGIN,
pass: process.env.MAILGUN_SMTP_PASSWORD,
},
});
// Postmark
const transporter = nodemailer.createTransport({
host: 'smtp.postmarkapp.com',
port: 587,
secure: false,
auth: {
user: process.env.POSTMARK_SERVER_TOKEN, // Same value for user and pass
pass: process.env.POSTMARK_SERVER_TOKEN,
},
});Fix 6: Local Development with Ethereal or Mailhog
// Ethereal Email — free fake SMTP for testing (no real emails sent)
const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
const info = await transporter.sendMail({
from: '[email protected]',
to: '[email protected]',
subject: 'Test',
text: 'Hello world',
});
// Open the email in the browser
console.log('Preview URL:', nodemailer.getTestMessageUrl(info));
// → https://ethereal.email/message/abc123# Mailhog — local SMTP server with web UI
# docker-compose.yml
services:
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI → http://localhost:8025// Connect to local Mailhog
const transporter = nodemailer.createTransport({
host: 'localhost',
port: 1025,
secure: false,
// No auth needed for Mailhog
});Fix 7: Nodemailer vs Resend vs SendGrid vs Postmark vs Amazon SES vs Mailgun
If you’re choosing how to send transactional email from a Node app, the protocol you pick — SMTP versus HTTP API — has bigger implications than the brand.
Nodemailer (raw SMTP) is library-first and provider-agnostic. You can point it at any SMTP server, including your own postfix instance. The cost is operational: SMTP needs a long-lived TCP connection, doesn’t survive Edge runtimes, and gives you minimal delivery telemetry. Pick Nodemailer when you control the SMTP server, or when you need to send through corporate SMTP relays that don’t expose an HTTP API.
Resend is HTTP-only and SDK-first, built specifically for modern Node/Edge runtimes. The SDK is fetch-based, so it runs on Vercel Edge, Cloudflare Workers, and Lambda without connection-pool quirks. Pricing is per-email with a generous free tier. It pairs cleanly with React Email for component-based templates. The trade-off: you’re locked into the Resend domain authentication flow.
SendGrid offers both SMTP and an HTTP SDK (@sendgrid/mail). It is older, more enterprise-oriented, and offers IP warmup, suppression lists, and event webhooks out of the box. The SMTP side has the same caveats as any SMTP (won’t run on Edge), but the HTTP SDK works anywhere fetch does. Pick SendGrid when you need both transactional and marketing email under one roof, or when your compliance team requires SOC 2 Type II from day one.
Postmark is transactional-only and built around fast inbox placement. The HTTP API is simple, the dashboard surfaces every bounce and complaint, and templates support handlebars-style variables. Postmark explicitly refuses to send marketing emails, which keeps its sending IPs reputation-clean. Pick Postmark when transactional reliability is the only thing that matters.
Amazon SES is the cheapest option at scale ($0.10 per 1,000 emails) but has the steepest setup. New accounts start in “sandbox mode” — you can only send to verified addresses until AWS approves you for production. Both SMTP and HTTP (via the AWS SDK) are supported. The SMTP credentials are different from your AWS access keys — generate them under SES Settings → SMTP credentials. Pick SES when you’re already in AWS and send millions of emails per month.
Mailgun sits between SES and SendGrid: cheaper than SendGrid, easier than SES, with both SMTP and HTTP APIs. It has strong EU data residency support (a dedicated EU endpoint at api.eu.mailgun.net). Pick Mailgun for European compliance requirements or as a SendGrid alternative.
Rule of thumb on protocol: if your code runs on a traditional Node server (Express, Fastify, long-running container), SMTP via Nodemailer is fine. If your code runs on Vercel, Netlify, Lambda, or Cloudflare Workers, use an HTTP-based provider — Resend or Postmark for transactional, SendGrid or Mailgun for hybrid use.
Still Not Working?
“Username and Password not accepted” (Gmail) — plain passwords no longer work with Gmail. Create an App Password (requires 2FA) or use OAuth2.
Connection timeout — your hosting provider may block outbound port 465 or 587. AWS EC2, Azure VMs, and Google Cloud block port 25 by default; 587 is usually allowed. Check your firewall rules. If on a serverless platform, switch to an HTTP-based email service.
Emails sent but not received — check the spam folder first. If not there, verify SPF, DKIM, and DMARC records for your domain. Use mail-tester.com to score your email and identify deliverability issues.
“self-signed certificate” error — in development, add tls: { rejectUnauthorized: false }. In production, this means your SMTP server has a real certificate issue — contact your email provider.
Works locally but not on Vercel — Vercel functions can establish new TCP connections, but long-running SMTP sessions time out. Reuse the transporter instance by creating it outside the handler function (module-level), or switch to a REST-based email API (Resend, SendGrid).
Cloudflare Workers cannot send SMTP at all — the Workers runtime does not expose raw TCP sockets to arbitrary hosts. Nodemailer will throw Module not found: net at build time. Use an HTTP API (Resend, SendGrid, Postmark) or proxy through a separate Node service.
Gmail/Yahoo bouncing bulk mail after Feb 2024 — both providers now require DMARC alignment, one-click unsubscribe headers (List-Unsubscribe: <mailto:...> and List-Unsubscribe-Post), and spam rates below 0.3%. Add the list headers in the sendMail() options or move to a provider that injects them automatically.
OAuth2 refresh token expired — Google refresh tokens issued to apps in “Testing” mode in Google Cloud expire after 7 days. Publish the OAuth consent screen to “In production” status to get long-lived refresh tokens. This is the most common reason Gmail OAuth2 works initially and then silently breaks a week later.
For related email sending, see Fix: React Email and Resend Not Working, Fix: Next.js API Route Not Working, Fix: Vercel Edge Function Not Working, and Fix: Express CORS Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Puppeteer Not Working — Navigation Timeout, Chrome Not Found, and Target Closed
How to fix Puppeteer errors — navigation timeout exceeded, failed to launch browser process in Docker and CI, element not found, page crashed Target closed, memory leaks from unclosed pages, and waiting for dynamic content.
Fix: Meilisearch Not Working — Search Returns No Results, Index Not Found, or API Key Errors
How to fix Meilisearch issues — index setup, document indexing, search configuration, filtering, facets, typo tolerance, and self-hosted deployment.
Fix: Temporal Not Working — Workflows Not Starting, Activities Failing, or Worker Not Connecting
How to fix Temporal issues — worker setup, workflow and activity errors, schedule configuration, versioning, and self-hosted Temporal Server deployment.