How to Test Nodemailer Emails with a Real SMTP Server
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 withEAUTH, not thirty seconds later as a missing email.- One recipient per test.
user+<uuid>@test.example.comis a plus alias: it delivers touser@test.example.comand stays in the message'sto. Tests running in parallel each find their own message. Plus aliases work on every plan. - Both parts.
textis the plain text part, andhtmlis an array of HTML parts. cheerio parses the HTML and returnshrefdecoded, so&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:
sendMailrejects, and the error lists them inrejected. - Some refused:
sendMailresolves. The message goes to the rest, and the refused addresses are ininfo.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:
code | Means |
|---|---|
EAUTH | The server refused the login. A wrong password, or a username that isn't the full address |
ESOCKET | The connection failed. With wrong version number, it's secure: true on a STARTTLS port |
ETLS | STARTTLS was required and didn't complete |
ETIMEDOUT | Nothing answered. The network probably blocks the port |
EENVELOPE | The server refused the sender or every recipient |
EMESSAGE | The server refused the message itself. responseCode 552 means it's over the size limit |
Mocks, jsonTransport, and Ethereal
Mock sendMail | jsonTransport | Ethereal | Sandbox | |
|---|---|---|---|---|
| Runs your SMTP settings | No | No | With Ethereal's host | Yes |
| You assert on | The arguments your code sent | The message as JSON | A preview page, or IMAP | JSON over REST, IMAP, POP3 |
| Needs a network | No | No | Yes | Yes |
| Mail from other senders | No | No | Not on public accounts | Yes, over MX |
| Messages kept | Not stored | Not stored | A few hours | 7, 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.
jsonTransportfits template snapshots with no network.sendMailresolves with the message as a JSON string ininfo.message.- Ethereal fits a quick look at one message.
nodemailer.createTestAccount()returns SMTP credentials with no signup, andnodemailer.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.
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
- The CI guide runs these tests on every push, with the two secrets they need.
- Playwright and Cypress follow the link from the email in a browser test.
- OTP codes and magic links covers resend, reuse, and expiry.
IMAP & POP3
IMAP works on the server's copy of a mailbox, POP3 downloads it. Ports, the commands to type into openssl, and curl, Python, and Node scripts for both.
Mailtrap
Mailtrap's Email Sandbox and smtp.dev side by side on quotas, retention, and inbound addresses. When to stay on Mailtrap, and how to move a test suite.