---
title: "How to Test Nodemailer Emails with a Real SMTP Server"
description: "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."
canonical_url: "https://smtp.dev/docs/guides/nodemailer/"
---

> Index of every page on this site: [llms.txt](https://smtp.dev/llms.txt)

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:

```js
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>`,
  })
}
```

```dotenv
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](https://smtp.dev/docs/api/). A small helper, `mail.js`, finds an account's inbox and waits for a message to a given address:

```js
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:

```js
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](https://smtp.dev/docs/features/account/#plus-sign-aliasing): 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

```js
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](https://smtp.dev/docs/guides/email-size-limits/) after encoding.

## Headers

The message JSON has `from`, `to`, `cc`, `replyTo`, `subject`, and `date` as fields. For any other header, fetch the [raw source](https://smtp.dev/docs/api/#get-a-message-source) and parse it:

```js
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:

<table>
<thead>
  <tr>
    <th>
      <code>
        code
      </code>
    </th>
    
    <th>
      Means
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        EAUTH
      </code>
    </td>
    
    <td>
      The server refused the login. A wrong password, or a username that isn't the full address
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ESOCKET
      </code>
    </td>
    
    <td>
      The connection failed. With <code>
        wrong version number
      </code>
      
      , it's <code>
        secure: true
      </code>
      
       on a STARTTLS port
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ETLS
      </code>
    </td>
    
    <td>
      STARTTLS was required and didn't complete
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ETIMEDOUT
      </code>
    </td>
    
    <td>
      Nothing answered. The network probably blocks the port
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        EENVELOPE
      </code>
    </td>
    
    <td>
      The server refused the sender or every recipient
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        EMESSAGE
      </code>
    </td>
    
    <td>
      The server refused the message itself. <code>
        responseCode
      </code>
      
       552 means it's over the size limit
    </td>
  </tr>
</tbody>
</table>

## Mocks, jsonTransport, and Ethereal

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      Mock <code>
        sendMail
      </code>
    </th>
    
    <th>
      <code>
        jsonTransport
      </code>
    </th>
    
    <th>
      Ethereal
    </th>
    
    <th>
      Sandbox
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Runs your SMTP settings
    </td>
    
    <td>
      No
    </td>
    
    <td>
      No
    </td>
    
    <td>
      With Ethereal's host
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      You assert on
    </td>
    
    <td>
      The arguments your code sent
    </td>
    
    <td>
      The message as JSON
    </td>
    
    <td>
      A preview page, or IMAP
    </td>
    
    <td>
      JSON over REST, IMAP, POP3
    </td>
  </tr>
  
  <tr>
    <td>
      Needs a network
    </td>
    
    <td>
      No
    </td>
    
    <td>
      No
    </td>
    
    <td>
      Yes
    </td>
    
    <td>
      Yes
    </td>
  </tr>
  
  <tr>
    <td>
      Mail from other senders
    </td>
    
    <td>
      No
    </td>
    
    <td>
      No
    </td>
    
    <td>
      Not on public accounts
    </td>
    
    <td>
      Yes, over MX
    </td>
  </tr>
  
  <tr>
    <td>
      Messages kept
    </td>
    
    <td>
      Not stored
    </td>
    
    <td>
      Not stored
    </td>
    
    <td>
      A few hours
    </td>
    
    <td>
      7, 30, or 90 days by plan
    </td>
  </tr>
</tbody>
</table>

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.

<note>

Ethereal's retention, preview links, and inbound rules are from its own FAQ, checked November 11, 2026.

</note>

## 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](https://smtp.dev/docs/setup/domain/). The provider delivers it like any other email, and the same assertions apply. The message also arrives with [SPF, DKIM, and DMARC results](https://smtp.dev/docs/guides/spf-dkim-dmarc/).

## Next

- The [CI guide](https://smtp.dev/docs/guides/ci/) runs these tests on every push, with the two secrets they need.
- [Playwright](https://smtp.dev/docs/guides/e2e/) and [Cypress](https://smtp.dev/docs/guides/cypress/) follow the link from the email in a browser test.
- [OTP codes and magic links](https://smtp.dev/docs/guides/otp-magic-links/) covers resend, reuse, and expiry.

## Sitemap

See the full [sitemap](https://smtp.dev/sitemap.md) for all pages.
