A contact form that sends through Gmail with Nodemailer usually fails in one of a handful of ways, and the error text often points in the wrong direction. We reproduced each failure against the real smtp.gmail.com on 9 October 2026, with Nodemailer 10.0.16 on Node and a made-up Gmail address, so every attempt failed before a message could be accepted. Then we ran the same code on Cloudflare Workers.
What we learned in short:
-
Missing credentials for "PLAIN"never reached Google. Nodemailer raises it itself when the password is empty, which almost always means an environment variable is not set where the code runs. - Gmail gives the same 535 for a wrong password and for an account that does not exist. The error cannot tell you which one you have.
-
securemust match the port. The wrong combination fails either instantly with an OpenSSL message or after a long silent wait. -
On Cloudflare Workers none of Gmail's ports worked, even though the same code reached Gmail from
wrangler devon a laptop.
Every error at a glance
| Setup | err.code |
err.command |
Message | Time to fail |
|---|---|---|---|---|
Wrong password (port 465 or 587, or service: "gmail") |
EAUTH |
AUTH PLAIN |
Invalid login: 535-5.7.8 Username and Password not accepted. For more information, go to 535 5.7.8 https://support.google.com/mail/?p=BadCredentials … |
0.4–0.6 s |
Password empty or undefined
|
EAUTH |
API |
Missing credentials for "PLAIN" |
0.4 s |
No auth at all |
EENVELOPE |
MAIL FROM |
Mail command failed: 530-5.7.0 Authentication Required. … |
0.6 s |
secure: true on port 587 |
ESOCKET |
CONN |
…SSL routines:tls_validate_record_header:wrong version number… |
0.1 s |
secure: false on port 465 |
ETIMEDOUT |
CONN |
Greeting never received |
as long as greetingTimeout
|
Host typo smtp.gmial.com
|
ESOCKET |
CONN |
connect ECONNREFUSED 51.79.68.169:465 |
1.3 s |
| Normal password on an account with 2-Step Verification | EAUTH |
AUTH PLAIN |
534-5.7.9 Application-specific password required (from user reports; we could not reproduce it without a real account) |
Log err.code, err.command and err.responseCode next to the message. Together they say which step failed: API means Nodemailer stopped before talking to anyone, CONN means the connection or TLS failed, AUTH PLAIN means Google rejected the login, and MAIL FROM means Google rejected the message itself.
535-5.7.8 “Username and Password not accepted”
This is the one everybody hits. We got it on port 465 with secure: true, on port 587 with secure: false, and with the service: "gmail" shortcut. All three are correct transport settings, and all three reached the login step.
Two things worth knowing:
-
It does not tell you whether the account exists. Our address was made up and Gmail still said "Username and Password not accepted", exactly as it does for a real account with a wrong password. So check the
uservalue too: it must be the full address, such asyou@gmail.com, and must be the same account the app password was created in. -
Your normal Google password is the wrong password. Gmail no longer accepts the account password from apps like Nodemailer. You need an app password:
- Turn on 2-Step Verification for the account.
- Create an app password at myaccount.google.com/apppasswords.
- Put the 16 characters it shows into
auth.pass.
Google's help page says app passwords work only with 2-Step Verification on. It also lists three cases where the option is missing: 2-Step Verification set up only with security keys, a work or school account, or Advanced Protection. In those cases use OAuth2 (Nodemailer supports it) or a sending service instead.
If the app password still fails, check how it reached your code. Google shows it in groups of four with spaces; paste it without them to rule that out. A stray quote or a trailing newline in a .env file breaks it too. (We could not test which of these Gmail tolerates: with a made-up account every login fails the same way.)
“Missing credentials for "PLAIN"”: Gmail was never asked
Error: Missing credentials for "PLAIN"
code: 'EAUTH', command: 'API'
command: 'API' is the giveaway: Nodemailer threw this before it sent anything to Google. We got it by passing pass: undefined, which is what process.env.GMAIL_APP_PASSWORD is when the variable does not exist in that environment.
Typical reasons:
- The variable is in your local
.env, but not in the hosting dashboard (Vercel, Netlify, Supabase secrets). - It is set for Preview but not for Production, or the other way round.
- It was added after the last deploy, and the running deployment does not have it yet. Redeploy.
- The code that sends mail runs in the browser, where server variables do not exist. Sending mail must happen on the server: anything in browser code, including an app password, is visible to every visitor.
530-5.7.0 “Authentication Required”
Leave out auth entirely and Gmail lets you connect, then refuses the message at MAIL FROM with 530-5.7.0 Authentication Required. The error code is EENVELOPE, which sounds like a problem with your addresses, but it is the same missing login. Usually auth was spread from an object that turned out empty.
secure and port: two very different failures
Gmail listens on 465 for TLS from the first byte and on 587 for plain text upgraded with STARTTLS. Nodemailer's secure: true means the first; secure: false means the second.
-
secure: trueon 587 fails at once with an OpenSSL error:SSL routines:tls_validate_record_header:wrong version number. Nothing in it says "port", which is why this one sends people searching for OpenSSL bugs. -
secure: falseon 465 fails slowly. The server waits for a TLS handshake, the client waits for a plain-text greeting, and nobody speaks. We setgreetingTimeoutto 10 seconds and gotGreeting never receivedafter 10.06 seconds. With Nodemailer's default timeouts the wait is longer, which on a serverless platform can mean the function hits its own time limit first and you see a platform timeout instead of an SMTP error.
The two working combinations:
// Either: TLS from the start
{ host: "smtp.gmail.com", port: 465, secure: true, auth: { user, pass } }
// Or: STARTTLS
{ host: "smtp.gmail.com", port: 587, secure: false, auth: { user, pass } }
A typo in the host goes to someone else's server
We tried smtp.gmial.com. It resolved, to an address at the hosting company OVH, not to Google, and the connection was refused on ports 25, 465 and 587. So today you get ECONNREFUSED. But a typo domain that resolves can start accepting connections at any time, and then your Gmail address and app password go to whoever runs it. If you see ECONNREFUSED with an IP address you do not recognise, check the spelling of host before anything else, and rotate the app password if a typo was ever deployed.
Serverless: it works locally, then fails after deploy
Cloudflare Workers
We deployed a Worker (with nodejs_compat) that tries the same Gmail login on each port, and called it twice. Both runs gave the same answers:
| Port | Result on Cloudflare Workers |
|---|---|
| 465 |
ESOCKET: proxy request failed, cannot connect to the specified address
|
| 587 |
ESOCKET: TLS Handshake Failed.
|
| 25 |
ESOCKET: Connections to port 25 are prohibited
|
None of them reached the login step. The same Worker run locally with wrangler dev behaved differently: port 587 and port 25 reached Gmail and got the normal 535. So a Worker can pass every local test and still fail on every port once deployed.
Supabase Edge Functions (Lovable, Bolt)
Supabase's limits page says: "Outgoing connections to ports 25 and 587 are not allowed." That leaves 465 for Gmail. We did not run Nodemailer in an Edge Function for this article, so treat this as the documented rule rather than a tested result.
Vercel and Netlify functions
We did not test these. If you see a function timeout with no SMTP error, check the secure and port pair above first, because the slow failure is long enough to hit a function limit.
A transport that tells you what went wrong
import nodemailer from "nodemailer";
const user = process.env.GMAIL_USER;
const pass = process.env.GMAIL_APP_PASSWORD;
if (!user || !pass) {
// Fail with a clear message instead of Nodemailer's 'Missing credentials for "PLAIN"'.
throw new Error("GMAIL_USER or GMAIL_APP_PASSWORD is not set in this environment");
}
const transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 465,
secure: true, // must be true for 465 and false for 587
auth: { user, pass },
connectionTimeout: 10_000,
greetingTimeout: 10_000, // fail fast instead of hitting the platform's time limit
});
export async function sendContactMessage({ name, email, message }) {
try {
await transporter.sendMail({
from: user, // Gmail sends from the account you logged in with
replyTo: email, // so Reply goes to the visitor
to: user,
subject: `New message from ${name}`,
text: message,
});
} catch (err) {
// These three fields identify the failing step; the message alone often misleads.
console.error("mail failed", { code: err.code, command: err.command, responseCode: err.responseCode, message: err.message });
throw err;
}
}
Checklist
-
command: 'API'with "Missing credentials": the password variable is not set where the code runs. - 535-5.7.8: use an app password, not your Google password, and the full address as
user. - 534-5.7.9: same fix; the account has 2-Step Verification and you sent the normal password.
- "wrong version number": you set
secure: trueon port 587. - "Greeting never received" or a function timeout: you set
secure: falseon port 465. -
ECONNREFUSEDto an unfamiliar IP: check the spelling ofhost. - Works locally, fails deployed on Cloudflare Workers or Supabase: the platform blocks the port. Use an HTTP email API or a form backend instead of SMTP.
How we tested
Node with Nodemailer 10.0.16, one connection per case to smtp.gmail.com, a made-up address (formgong.repro.nobody.4417@gmail.com) and made-up passwords, so Gmail rejected every attempt and nothing was sent. The Cloudflare Workers results come from a Worker deployed for the test and removed afterwards; each port was tried twice. Google's app password rules are quoted from Google's help page, the Supabase rule from Supabase's Edge Function limits, and the 534-5.7.9 text from user reports such as WP Mail SMTP's write-up.
We build Formgong, a form backend: your form posts to it over HTTPS and it sends the notification, so there is no SMTP login, app password or blocked port to deal with. If your form fails in the browser before it ever reaches the server, our “Failed to fetch” write-up covers that side.
Top comments (0)