Reference
Client SDK
Reference for the client SDK, runner fixtures, assertion matchers, redacted reports, SMTP faults, and captured messages.Import the test client from inboxtap/client. It communicates with the local HTTP API and creates
unique recipient addresses in the test process.
Create a client
import { InboxTapClient } from "inboxtap/client";
const inboxTap = new InboxTapClient({
baseUrl: "http://localhost:8025",
});baseUrl defaults to http://localhost:8025. An optional domain skips the initial health request
when the test already knows the configured recipient domain.
Create an inbox
const inbox = await inboxTap.createInbox({ alias: "password-reset" });
console.log(inbox.address);The alias is normalized to lowercase letters, digits, and hyphens, then a random 12-character suffix
is added. The default alias is test.
Fixture entry points
Runner fixtures are published behind isolated subpaths so the root server and client SDK do not load optional test dependencies.
| Import | Main export | Lifecycle |
|---|---|---|
inboxtap/fixtures | startInboxTapFixture() | Explicit start and idempotent close() |
inboxtap/fixtures/bun | setupInboxTap() | File setup and teardown through Bun hooks |
inboxtap/fixtures/vitest | extendInboxTap() | File-scoped server, test-scoped inbox |
inboxtap/fixtures/playwright | extendInboxTap() | Worker-scoped server, test-scoped inbox |
startInboxTapFixture() defaults both ports to 0 and returns server, client, a verified
Nodemailer transport, smtp connection details, createInbox(), and close(). Every adapter
uses the same fixture object. Install Nodemailer 9, plus Vitest 4.1 or Playwright 1.61 when using
those runner-specific subpaths. The smtp value contains
{ host, port, secure: false, ignoreTLS: true } for configuring the application under test.
Create a new inbox for every test. A fixture may share the capture server and transport at its documented scope, but a suite-global inbox would allow parallel tests to read the same recipient.
Matcher entry points
Assertion matchers are published separately from the server, client, and fixtures:
| Import | Main exports | Behavior |
|---|---|---|
inboxtap/matchers | inboxTapMatchers, createInboxTapMatchers() | Peer-free matcher implementations and public observation types |
inboxtap/matchers/bun | extendInboxTapExpect(expect) | Extends Bun's injected expect in place |
inboxtap/matchers/vitest | extendInboxTapExpect(expect) | Extends Vitest's injected expect in place |
inboxtap/matchers/playwright | extendInboxTapExpect(expect) | Returns a new typed Playwright expect |
Importing inboxtap, inboxtap/client, or inboxtap/matchers does not require Nodemailer,
Vitest, or Playwright. Only a runner-specific adapter needs that runner's optional peer.
import { expect } from "vitest";
import { extendInboxTapExpect } from "inboxtap/matchers/vitest";
extendInboxTapExpect(expect);
await expect(inbox).toHaveDeliveredOnce({
subject: /password reset/i,
quietMs: 100,
});
const email = await inbox.waitForMessage({ subject: /password reset/i });
expect(email).toHaveRecipient(inbox.address);
expect(email).toContainLink("/reset-password");
expect(email).toHaveUnsubscribeHeader({ oneClick: true });| Matcher | Semantics |
|---|---|
toHaveDeliveredOnce({ subject?, quietMs? }) | Takes an immediate inbox snapshot and requires exactly one matching message. A subject string is a case-insensitive substring; a regular expression is tested without changing its lastIndex. quietMs is an integer from 0 through 60000 and defaults to 0. |
toHaveRecipient(address) | Matches the SMTP envelope recipients exactly and case-insensitively; it does not use the parsed display-address field. |
toContainLink(stringOrRegex) | Checks extracted HTTP(S) links. A string is a substring, while a cloned regular expression leaves caller state unchanged. |
toHaveUnsubscribeHeader({ oneClick? }) | Parses only the raw top-level RFC headers, including case-varied names and folded values. By default it requires a non-empty List-Unsubscribe; oneClick: true also requires an angle-bracketed HTTPS target and an exact, case-insensitive List-Unsubscribe-Post: List-Unsubscribe=One-Click. |
toHaveDeliveredOnce() does not poll for an initial message. When its first snapshot is valid,
quietMs observes only that following interval; it is not proof that a later retry cannot happen.
The one-click matcher checks RFC 8058 header shape only. It does not verify DKIM signatures,
signature coverage, or the behavior of the unsubscribe endpoint.
Matcher results deliberately omit actual, expected, message bodies, recipient and link values,
token-bearing patterns, and raw headers. Failures expose only safe counts and boolean states.
For report collection, pass a synchronous recorder to the pure factory:
import {
createInboxTapMatchers,
type InboxTapMatcherObservation,
} from "inboxtap/matchers";
const observations: InboxTapMatcherObservation[] = [];
const matchers = createInboxTapMatchers({
recorder: {
recordMatcherObservation(observation) {
observations.push(observation);
},
},
});Each observation is schema-versioned and records the matcher name, negation and pass state,
optional captured-message ID, and content-safe counts or booleans. It never contains the expected
recipient, subject, link pattern, header value, or captured body. Pass matchers to a compatible
runner's expect.extend(); the runner-specific adapters call the same factory.
Report entry point
Import InboxTapReport from the peer-free inboxtap/reports subpath. The collector implements
InboxTapMatcherRecorder, so pass it directly to a runner adapter or to
createInboxTapMatchers({ recorder }). Add captured messages and application-level assertions
explicitly:
import { test as base } from "vitest";
import { extendInboxTap } from "inboxtap/fixtures/vitest";
import { extendInboxTapExpect } from "inboxtap/matchers/vitest";
import { InboxTapReport } from "inboxtap/reports";
const test = extendInboxTap(base);
test("writes redacted evidence", async ({ expect, inboxTap, inbox }) => {
const report = new InboxTapReport({ title: "Signup email" });
extendInboxTapExpect(expect, { recorder: report });
try {
await inboxTap.transport.sendMail({
from: "app@local.test",
to: inbox.address,
subject: "Verify your account",
text: "Open https://app.local.test/verify/id-example?next=private",
});
await expect(inbox).toHaveDeliveredOnce({ subject: /verify/i });
const email = await inbox.waitForMessage({ subject: /verify/i });
report.addAssertion({
name: "verification email exposes one link",
passed: email.links.length === 1,
messageId: email.id,
});
} finally {
for (const email of await inbox.messages()) report.addMessage(email);
await report.write("artifacts/signup-email.json");
await report.write("artifacts/signup-email.html");
}
});Writing from finally preserves the latest evidence when a matcher or application assertion
fails.
| Method | Behavior |
|---|---|
addMessage(message) | Adds one CapturedEmail, deduplicated by its ID, and returns the collector for chaining. |
addAssertion({ name, passed, message?, messageId?, details? }) | Adds an application assertion and returns the collector. Optional strings and structured details pass through the same bounded redaction pipeline. |
recordMatcherObservation(observation) | Implements InboxTapMatcherRecorder; runner adapters call it for each completed matcher. |
render({ format: "json" | "html" }) | Returns the deterministic artifact as a string. |
write(filePath, { format? }) | Writes the artifact asynchronously, inferring the format from the extension when omitted. |
render({ format }) returns deterministic JSON or self-contained static HTML for the same ordered
inputs. JSON includes a schema version. write(filePath, { format? }) infers the format from a
.json or .html extension when it is omitted and creates missing parent directories. Neither
operation calls the InboxTap server.
The version 1 JSON document exposes title, protection, summary, messages, assertions, and
truncation. Truncation records omitted message and assertion counts, shortened-field counts,
omitted UTF-8 bytes, and the artifact byte limit. utf8BytesOmittedExact is true when
utf8BytesOmitted is exact. It is false when a bounded scan stops early, in which case the byte
count is a measured lower bound; HTML labels the two cases exactly and at least.
Recorder scope follows the expect object that was extended. The example uses Vitest's
test-bound expect, so each report receives only that test's matcher observations. Do not attach
different per-test collectors to one shared Bun or Vitest expect while tests run concurrently.
Use explicit addMessage() and addAssertion() calls in that case, or deliberately collect one
suite-scoped report. Playwright's adapter returns a new extended expect, which can be scoped to
one report.
The default redaction pipeline:
- excludes raw RFC source;
- assigns each email address a consistent pseudonym within the report;
- removes URL credentials and redacts every query value, fragments, secret-like path values,
Authorization,Cookie,Proxy-Authorization,Set-Cookie,X-API-Key,X-Auth-Token, and token-like values in text and HTML; and - escapes captured HTML instead of rendering it and never executes captured scripts or loads captured or remote images, styles, or tracking pixels.
Use redaction.patterns and redaction.additionalSensitiveHeaders for project-specific secrets.
If a custom pattern overlaps a URL, InboxTap replaces the whole URL so regex-driven mutation cannot
expose adjacent query or fragment values.
Collection is bounded to 100 messages and 1,000 assertions, and each rendered artifact is capped at
10 MiB. When a limit is reached, the output records explicit truncation markers instead of growing
without bound.
Redaction is best-effort and cannot guarantee detection of arbitrary personal or secret data.
Review an artifact before sharing it. includeRaw: true retains a best-effort-redacted copy of the
raw RFC source and should be used only when the extra diagnostic value justifies the materially
higher disclosure risk.
SMTP fault controller
Every programmatic server exposes a SmtpFaultController as server.faults; fixture users access
it through inboxTap.server.faults. The public controller, options, gate, and state types are
exported from inboxtap. No enable flag is required.
interface SmtpFaultController {
failNext(options: SmtpFailNextOptions): void;
delayNext(options: SmtpDelayNextOptions): void;
disconnectNext(options: SmtpDisconnectNextOptions): void;
pauseNext(options?: SmtpPauseNextOptions): SmtpPauseGate;
reset(): void;
}Register a rule before triggering the application. In registration order, a rule binds to the next
matching SMTP transaction that reaches DATA, and only one rule applies to each transaction. An
omitted to matches every recipient; otherwise matching is case-insensitive across the SMTP
envelope. If any recipient matches in a multi-recipient transaction, the fault applies to the
whole transaction. Both IPv4 and IPv6 SMTP listeners consume the same controller queue.
| Method | Options and bounds | Result |
|---|---|---|
failNext | Integer code from 400 through 599; optional CR/LF-free message defaults to Injected SMTP failure; optional to and times | Returns the configured SMTP failure instead of capturing the message |
delayNext | Integer durationMs from 0 through 60000; optional to and times | Holds the transaction before completing delivery |
disconnectNext | Non-negative safe integer afterBytes; optional to and times | Closes the SMTP connection after the threshold is observed |
pauseNext | Optional to; integer timeoutMs from 0 through 60000, default 60000; no times option | Returns one independently controlled SmtpPauseGate |
reset | No options | Aborts queued and active waits and clears their timers |
times must be an integer from 1 through 100 and defaults to 1. Expanded rule applications
share a cap of 100, counting queued and active applications. A supplied to must be a non-empty
string; surrounding whitespace is ignored. Disconnect thresholds are observed at stream-chunk
boundaries rather than byte-exactly. InboxTap closes at the end of DATA when a message is shorter
than afterBytes, so the injected transaction still fails. Failed and disconnected deliveries
never enter the store. Delayed and paused deliveries appear only after SMTP delivery completes
successfully.
SmtpPauseGate.state is a read-only SmtpPauseState:
| State | Meaning |
|---|---|
pending | The rule is queued and has not reached a matching DATA command |
paused | A matching SMTP transaction is being held |
released | release() canceled a pending rule or resumed a paused transaction |
expired | The absolute timeout elapsed; an active transaction receives SMTP 451 and is not captured |
aborted | reset() or server shutdown canceled the gate |
The timeout starts when pauseNext() is called, so it bounds both queue time and active pause time.
The gate becomes paused after the message body passes parsing and size validation and enters the
hold. waitUntilPaused() then resolves and remains resolved after that gate later terminates. It
rejects if the gate reaches a terminal state before pausing. release() is idempotent and may be
called while pending; doing so removes the queued rule so a later transaction is unaffected.
const gate = server.faults.pauseNext({
to: inbox.address,
timeoutMs: 10_000,
});
const delivery = triggerEmail(inbox.address);
await gate.waitUntilPaused();
expect(gate.state).toBe("paused");
gate.release();
await delivery;reset() does not clear captured messages. Server shutdown provides the same bounded gate cleanup:
queued or active gates become aborted, and timers are cleared. Fault controls are programmatic
only—there are no fault HTTP routes, CLI flags, history, or statistics.
TestInbox methods
| Method | Resolves to | Purpose |
|---|---|---|
messages() | CapturedEmail[] | List messages for this address |
clear() | number | Delete messages for this address |
waitForMessage(options) | CapturedEmail | Await the full matching message |
waitForLink(options) | string | Await the first link, optionally containing text |
waitForCode(options) | string | Await a code matching a pattern; defaults to six digits |
waitForMatch(options) | string | Await a required string or regular-expression match |
Wait options accept subject, timeoutMs, and afterId. subject can be a case-insensitive string
substring or a regular expression. Extraction waits default to 10 seconds.
const code = await inbox.waitForCode({
pattern: /\b\d{4}\b/,
subject: /sign in/i,
timeoutMs: 20_000,
});Low-level client methods
InboxTapClient also exposes health, listEmails, latestEmail, waitForEmail, clearEmails,
and the generic request method. Prefer TestInbox inside parallel tests because it applies the
unique recipient filter automatically.
CapturedEmail
interface CapturedEmail {
id: string;
receivedAt: string;
envelope: { from: string | null; to: string[] };
from: string;
to: string[];
subject: string;
headers: Record<string, string>;
text: string;
html: string;
links: string[];
codes: string[];
raw: string;
}links and codes are arrays. InboxTap does not assign semantic names such as magic or
verification to extracted values.
Errors
Failed requests and extraction timeouts throw InboxTapError. Its status property contains the
HTTP-style status, including 408 for a timeout and 404 for a missing message.