@Mail Domain Check

Nodemailer | Payload CMS | Next.js | Mailgun

Nodemailer 535 Authentication Failed in a Payload / Next.js Build

EAUTH, SMTP 535, and command AUTH PLAIN mean the client reached an SMTP server and that server rejected authentication. Treat that as a credential, endpoint, or secret-transport failure first. SPF, DKIM, DMARC, and inbox placement happen later.

What the log proves

code: EAUTH
Nodemailer classified the failure as authentication. Do not begin by changing SPF or DMARC.
responseCode: 535
The SMTP server issued a permanent authentication-failure reply for this attempt.
command: AUTH PLAIN
DNS, TCP, the SMTP greeting, and any required TLS negotiation progressed far enough to attempt an auth mechanism.
Error verifying Nodemailer transport
With Payload's Nodemailer adapter, this can be the adapter's startup verification, not a customer message send.

A failed verify() does not prove that the application attempted a real message. A successful verify() proves DNS, connection, TLS, and authentication only; it does not prove that a sender address will be accepted or that a recipient will receive the message.

Why it appears during next build

Payload CMS loads payload.config while Next.js builds application routes. In @payloadcms/email-nodemailer 3.86.0, constructing the adapter calls Nodemailer's verify() unless skipVerify is true. The adapter catches and logs a verification error, so the log line alone does not prove that the build failed.

This creates two separate problems:

  1. The SMTP credentials or endpoint may be wrong.
  2. A deterministic static build now depends on a live external SMTP service.

Fix both. Do not hide invalid credentials merely to make the build green.

Check the rejected authentication boundary

  1. Credential type: for Mailgun, use a domain SMTP credential. A Mailgun API key or Control Panel password is not the SMTP password.
  2. Exact login: use the full SMTP username shown for the sending domain, commonly postmaster@mailer.example.com. Do not substitute the visible From address.
  3. Region: use smtp.mailgun.org for a US-region domain and smtp.eu.mailgun.org for an EU-region domain. Mailgun credentials are region-bound.
  4. Port mode: use port 587 or 2525 with STARTTLS and secure: false; use port 465 with implicit TLS and secure: true.
  5. Secret transport: confirm that the CI runner and deployed container receive the same bytes. A generated dotenv line can truncate or reinterpret passwords containing #, whitespace, quotes, backslashes, dollar signs, or a newline.

Never print the password. Compare only the presence flag, byte length, and a short SHA-256 fingerprint calculated independently in CI and in the runtime container. Remove those diagnostics after the mismatch is resolved.

Remove SMTP from the build, then add a runtime gate

The adapter supports skipVerify. Use it to prevent configuration import from opening a live SMTP connection, then run an explicit verification command against the deployed runtime environment before declaring the release healthy.

const transport = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: Number(process.env.SMTP_PORT),
  secure: Number(process.env.SMTP_PORT) === 465,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
})

export default nodemailerAdapter({
  defaultFromAddress: "mailer@example.com",
  defaultFromName: "Example",
  transport,
  skipVerify: true,
})

For the release gate, construct the same transport from the runtime environment and call await transport.verify() once. Fail the release or readiness check on EAUTH; log only the error class, SMTP code, response code, command, host label, port, and credential fingerprint. Do not expose this check as a public endpoint with detailed errors.

await nodemailer.createTransport(...) is unnecessary because createTransport() is synchronous. Removing the await improves clarity but does not fix a 535 response.

Prove a real send separately

  1. Pass the runtime verify() gate with the intended host, port, user, and secret fingerprint.
  2. Send one consented test message using the same application path and visible From identity as production.
  3. Record the provider message ID and SMTP submission result without storing the recipient in analytics.
  4. Check provider events for accepted, delivered, deferred, bounced, rejected, suppressed, or complained.
  5. Record recipient-server acceptance and inbox visibility as separate evidence.

If authentication succeeds but the sender is rejected, work on the verified sending domain or From identity. If submission succeeds but delivery fails, then investigate authentication alignment, reputation, suppression, receiver replies, and inbox placement.

A practical reading of the public case

The public build log in sooke-radio/sookelive issue 13 contains EAUTH, 535 Authentication failed, AUTH PLAIN, and Payload's transport-verification message. The repository config uses Mailgun SMTP and loads the mail adapter from payload.config. That is enough to prioritize the authentication and build-import boundaries above; it is not enough to identify which secret byte, Mailgun region, or account state is wrong.

Primary references