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.

ImportMain exportLifecycle
inboxtap/fixturesstartInboxTapFixture()Explicit start and idempotent close()
inboxtap/fixtures/bunsetupInboxTap()File setup and teardown through Bun hooks
inboxtap/fixtures/vitestextendInboxTap()File-scoped server, test-scoped inbox
inboxtap/fixtures/playwrightextendInboxTap()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:

ImportMain exportsBehavior
inboxtap/matchersinboxTapMatchers, createInboxTapMatchers()Peer-free matcher implementations and public observation types
inboxtap/matchers/bunextendInboxTapExpect(expect)Extends Bun's injected expect in place
inboxtap/matchers/vitestextendInboxTapExpect(expect)Extends Vitest's injected expect in place
inboxtap/matchers/playwrightextendInboxTapExpect(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 });
MatcherSemantics
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.

MethodBehavior
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.

MethodOptions and boundsResult
failNextInteger code from 400 through 599; optional CR/LF-free message defaults to Injected SMTP failure; optional to and timesReturns the configured SMTP failure instead of capturing the message
delayNextInteger durationMs from 0 through 60000; optional to and timesHolds the transaction before completing delivery
disconnectNextNon-negative safe integer afterBytes; optional to and timesCloses the SMTP connection after the threshold is observed
pauseNextOptional to; integer timeoutMs from 0 through 60000, default 60000; no times optionReturns one independently controlled SmtpPauseGate
resetNo optionsAborts 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:

StateMeaning
pendingThe rule is queued and has not reached a matching DATA command
pausedA matching SMTP transaction is being held
releasedrelease() canceled a pending rule or resumed a paused transaction
expiredThe absolute timeout elapsed; an active transaction receives SMTP 451 and is not captured
abortedreset() 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

MethodResolves toPurpose
messages()CapturedEmail[]List messages for this address
clear()numberDelete messages for this address
waitForMessage(options)CapturedEmailAwait the full matching message
waitForLink(options)stringAwait the first link, optionally containing text
waitForCode(options)stringAwait a code matching a pattern; defaults to six digits
waitForMatch(options)stringAwait 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.