How to Test Nodemailer Emails with a Real SMTP Server

Point Nodemailer at a sandbox SMTP server, send from a test, and assert on links, headers, and attachments over REST. Compared with mocks and Ethereal.

There are three ways to test code that sends with Nodemailer. Mock sendMail, and you check that your code called it. Use jsonTransport, and you check the message it built. Send through a real SMTP server that keeps the mail, and you check everything in between: the login, TLS, the MIME encoding, and what a recipient would receive.

This guide does the third, with send.smtp.dev as the server. It delivers only to accounts in your sandbox, so a test can't email a real person.

The Transport

Read the settings from the environment, so tests, staging, and production differ only in their values:

import nodemailer from 'nodemailer'

export const transport = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: Number(process.env.SMTP_PORT),
  requireTLS: true,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASSWORD,
  },
})

export function sendWelcome(user) {
  const link = `https://example.com/confirm/${user.token}`
  return transport.sendMail({
    from: '"Acme" <app@test.example.com>',
    to: user.email,
    subject: 'Welcome to Acme',
    text: `Confirm your address: ${link}`,
    html: `<p><a href="${link}">Confirm your address</a></p>`,
  })
}
SMTP_HOST=send.smtp.dev
SMTP_PORT=587
SMTP_USER=app@test.example.com
SMTP_PASSWORD=your-account-password

SMTP_USER is a sandbox account's full address, and SMTP_PASSWORD is that account's password.

Port 587 starts in plain text and upgrades with STARTTLS. requireTLS: true makes Nodemailer stop if the upgrade fails, instead of sending the password unencrypted. secure: true is for port 465, where TLS starts with the connection. On 587 it fails with wrong version number.

Send and Read It Back

The test reads the message over the REST API. A small helper, mail.js, finds an account's inbox and waits for a message to a given address:

import { Buffer } from 'node:buffer'

const API = 'https://api.smtp.dev'
const headers = { 'X-API-KEY': process.env.SMTP_DEV_API_KEY }

async function request(path) {
  const res = await fetch(new URL(path, API), { headers })
  if (!res.ok)
    throw new Error(`${path} returned ${res.status}`)
  return res
}

export const api = path => request(path).then(res => res.json())
export const download = path => request(path).then(res => res.arrayBuffer()).then(Buffer.from)

export async function inboxOf(address) {
  const { member } = await api(`/accounts?address=${encodeURIComponent(address)}`)
  const account = member.find(a => a.address === address)
  const { mailboxes } = await api(`/accounts/${account.id}`)
  return `/accounts/${account.id}/mailboxes/${mailboxes.find(m => m.path === 'INBOX').id}/messages`
}

export async function waitForMessage(inbox, to, timeout = 30_000) {
  const deadline = Date.now() + timeout
  while (Date.now() < deadline) {
    const { member } = await api(inbox)
    const found = member.find(m => m.to.some(t => t.address === to))
    if (found)
      return api(`${inbox}/${found.id}`)
    await new Promise(resolve => setTimeout(resolve, 1000))
  }
  throw new Error(`no message to ${to} after ${timeout}ms`)
}

Then the test, with Node's built-in runner:

import assert from 'node:assert'
import { before, test } from 'node:test'
import * as cheerio from 'cheerio'
import { inboxOf, waitForMessage } from './mail.js'
import { sendWelcome, transport } from './mailer.js'

let inbox
before(async () => {
  await transport.verify()
  inbox = await inboxOf('user@test.example.com')
})

test('welcome email', async () => {
  const email = `user+${crypto.randomUUID()}@test.example.com`
  const info = await sendWelcome({ email, token: 'abc123' })
  assert.deepEqual(info.rejected, [])

  const message = await waitForMessage(inbox, email)
  assert.equal(message.subject, 'Welcome to Acme')
  assert.equal(message.from.address, 'app@test.example.com')
  assert.equal(message.from.name, 'Acme')

  const link = message.text.match(/https:\/\/\S+/)[0]
  assert.equal(link, 'https://example.com/confirm/abc123')

  const $ = cheerio.load(message.html.join(''))
  assert.equal($('a:contains("Confirm your address")').attr('href'), link)
})

Run it with node --test, on Node 20 or newer, which Nodemailer 10 requires. Three things in it are worth copying:

  • transport.verify() connects and logs in without sending. A wrong password fails there with EAUTH, not thirty seconds later as a missing email.
  • One recipient per test. user+<uuid>@test.example.com is a plus alias: it delivers to user@test.example.com and stays in the message's to. Tests running in parallel each find their own message. Plus aliases work on every plan.
  • Both parts. text is the plain text part, and html is an array of HTML parts. cheerio parses the HTML and returns href decoded, so &amp; in the markup compares equal to & in the text link.

Attachments

test('invoice email carries the PDF', async () => {
  const to = `user+${crypto.randomUUID()}@test.example.com`
  const pdf = await readFile('fixtures/invoice.pdf')

  await transport.sendMail({
    from: 'app@test.example.com',
    to,
    subject: 'Your invoice',
    text: 'Attached.',
    attachments: [
      { filename: 'invoice.pdf', content: pdf },
    ],
  })

  const message = await waitForMessage(inbox, to)
  const [file] = message.attachments
  assert.equal(file.filename, 'invoice.pdf')
  assert.equal(file.contentType, 'application/pdf')
  assert.deepEqual(await download(file.downloadUrl), pdf)
})

readFile comes from node:fs/promises, and download from the helper. Comparing the downloaded bytes with the fixture proves the file survived base64 encoding, which a check on the filename alone doesn't. Attachments count toward the 20 MB message limit after encoding.

Headers

The message JSON has from, to, cc, replyTo, subject, and date as fields. For any other header, fetch the raw source and parse it:

import { simpleParser } from 'mailparser'

const { raw } = await api(`${inbox}/${message.id}/source`)
const { headers } = await simpleParser(raw)

assert.equal(headers.get('x-order-id'), 'order-42')
assert.equal(headers.get('list').unsubscribe.url, 'https://example.com/unsubscribe?u=1')

That reads a message sent with headers: { 'X-Order-Id': 'order-42' } and list: { unsubscribe: 'https://example.com/unsubscribe?u=1' }. Use a parser instead of a regex over raw: Nodemailer writes that header as X-Order-ID, and long headers are folded across lines.

Refused Recipients

send.smtp.dev refuses any recipient that isn't an account in your sandbox. What Nodemailer does next depends on the message:

  • Every recipient refused: sendMail rejects, and the error lists them in rejected.
  • Some refused: sendMail resolves. The message goes to the rest, and the refused addresses are in info.rejected.

The second case is silent unless you look, which is why the test above asserts that info.rejected is empty. Seed test users with addresses on your sandbox domain, or plus aliases of one account.

Error Codes

Nodemailer sets code on the errors it throws, and responseCode when the server replied:

codeMeans
EAUTHThe server refused the login. A wrong password, or a username that isn't the full address
ESOCKETThe connection failed. With wrong version number, it's secure: true on a STARTTLS port
ETLSSTARTTLS was required and didn't complete
ETIMEDOUTNothing answered. The network probably blocks the port
EENVELOPEThe server refused the sender or every recipient
EMESSAGEThe server refused the message itself. responseCode 552 means it's over the size limit

Mocks, jsonTransport, and Ethereal

Mock sendMailjsonTransportEtherealSandbox
Runs your SMTP settingsNoNoWith Ethereal's hostYes
You assert onThe arguments your code sentThe message as JSONA preview page, or IMAPJSON over REST, IMAP, POP3
Needs a networkNoNoYesYes
Mail from other sendersNoNoNot on public accountsYes, over MX
Messages keptNot storedNot storedA few hours7, 30, or 90 days by plan

Each has a job:

  • A mock fits unit tests of the code around the mailer: was it called, and for which user.
  • jsonTransport fits template snapshots with no network. sendMail resolves with the message as a JSON string in info.message.
  • Ethereal fits a quick look at one message. nodemailer.createTestAccount() returns SMTP credentials with no signup, and nodemailer.getTestMessageUrl(info) returns a preview link. Messages are deleted after a few hours, and the preview link opens without a login.
  • The sandbox fits when a test has to read the message back, when CI and the team need the same inbox, or when the email comes from somewhere other than your code.
Ethereal's retention, preview links, and inbound rules are from its own FAQ, checked November 11, 2026.

When Production Doesn't Use SMTP

If production sends through a provider's HTTP API or Nodemailer's SES transport, a second transport for tests would cover code that never runs in production. Keep the production transport and send to an address on a domain whose MX points at the sandbox. The provider delivers it like any other email, and the same assertions apply. The message also arrives with SPF, DKIM, and DMARC results.

Next