Examples
Send transactional email from Express through Nodemailer and assert it with Vitest.Express + Nodemailer + InboxTap
A small Express API that sends transactional email through Nodemailer — a welcome email with a verification link, a single-use invite token, and a one-time sign-in code — with a Vitest suite that captures and asserts every email through InboxTap. It uses InboxTap's runner-native Vitest matcher adapter for concise delivery, recipient, link, and header assertions.
Prerequisites
- Node.js 20+
Setup
npm installThis example pins InboxTap to 1.3.0, the first release containing assertion matchers.
Run the tests
npm testThe tests start InboxTap and the app themselves — no other terminal needed. Each spec file boots its own InboxTap server and app on ephemeral ports, so files run in parallel without port conflicts.
Run interactively
Start InboxTap in one terminal and the app in another:
npx inboxtapnpm run devThen trigger an email and inspect what was captured:
curl -X POST http://localhost:3001/signup \
-H "content-type: application/json" \
-d '{"email":"someone@local.test"}'
curl http://localhost:8025/api/emails/latestHow it works
app (Express) → nodemailer → SMTP :1025 → InboxTap → HTTP API :8025 ← InboxTapClient (tests)
src/mailer.tsowns the single Nodemailer transport:secure: false,ignoreTLS: true, and noauth— InboxTap disables AUTH and STARTTLS.src/app.tsexposescreateApp({ mailer, baseUrl }). Injecting the mailer and base URL is what lets tests point the same app at ephemeral ports.test/helpers.tsstarts the whole stack per spec file:new InboxTapServer({ apiPort: 0, smtpPort: 0 }), anInboxTapClientwired toserver.apiUrl, and the app bound to port 0.test/setup.tsregisters InboxTap's Vitest matchers with the runner's ownexpectinstance.- Every test calls
inboxTap.createInbox()for a unique address, so tests never see each other's mail and nothing needs cleanup between runs.
Register the matchers
Import the Vitest adapter in a setup file and pass it Vitest's native expect:
import { extendInboxTapExpect } from "inboxtap/matchers/vitest";
import { expect } from "vitest";
extendInboxTapExpect(expect);The setup file is loaded through setupFiles in vitest.config.ts. The async delivery matcher must
be awaited; message matchers remain synchronous:
await expect(inbox).toHaveDeliveredOnce({ subject: /welcome/i });
const email = await inbox.waitForMessage({ subject: /welcome/i });
expect(email).toHaveRecipient(inbox.address);
expect(email).toContainLink("/verify?token=");
expect(email).not.toHaveUnsubscribeHeader();toHaveDeliveredOnce() checks the current inbox snapshot. Pass an explicit quietMs when a test
needs a short observation window for a duplicate, but do not treat that window as proof that a
later retry cannot happen.
To try this example against a local build of InboxTap instead of the published package, run
bun run build && bun pm pack at the repository root, then install the tarball here:
npm install ../../inboxtap-<version>.tgz.
Troubleshooting
waitFor…timed out — the app most likely never sent the email. Check the app logs, and confirm the transport targets the SMTP host and port that InboxTap printed on startup.- Port already in use — the test suite uses ephemeral ports and is immune, but interactive
mode defaults to 1025/8025 (InboxTap) and 3001 (app). Stop the conflicting process or set
PORT/SMTP_PORT. - Emails visible in the UI but not in tests — assert against the same inbox address the app
sent to;
createInbox()generates a fresh address per call. - A matcher type is missing — register
inboxtap/matchers/vitestin the configured setup file; the matcher adapter is intentionally not exported from the InboxTap package root.