npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

inboxtap

v1.4.1

Published

A local email capture server and TypeScript SDK for testing verification links, magic links, OTPs, invitations, and password-reset emails.

Readme

InboxTap

InboxTap is a local email capture server and TypeScript SDK for testing verification links, magic links, OTPs, invitations, and password-reset emails. Point your application at its local SMTP address, then await the link, code, or custom match directly from your automated test—without Docker or an external mail service.

bunx inboxtap

The published CLI also runs with Node 20 or later:

npx inboxtap

It captures all local SMTP recipients in a bounded in-memory store. No message is delivered externally.

Read the complete guides and API reference at inboxtap.dev/docs.

Quick start

Start the server:

bunx inboxtap

During local development of this repository, use:

bun install
bun run start

The defaults are:

SMTP: localhost:1025
API:  http://localhost:8025

Configure the application under test:

SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE=false

Options let you run multiple projects side by side:

inboxtap --smtp-port 2025 --api-port 9025 --domain mail.local.test

Test-suite SDK

Install the package in a Bun or Node 20+ test project, then import the client subpath:

import { InboxTapClient } from "inboxtap/client";

const inboxTap = new InboxTapClient();
const inbox = await inboxTap.createInbox({ alias: "signup" });

await page.getByLabel("Email").fill(inbox.address);
await page.getByRole("button", { name: "Create account" }).click();

const verificationUrl = await inbox.waitForLink({
  subject: /verify your email/i,
  contains: "/verify",
});
await page.goto(verificationUrl);

The inbox address is generated in the client, so parallel tests can safely isolate their messages without pre-registering recipients on the server.

const code = await inbox.waitForCode({ subject: /security code/i });
const key = await inbox.waitForMatch({ pattern: /api_key=([A-Za-z0-9_-]+)/ });
const messages = await inbox.messages();
await inbox.clear();

waitForLink, waitForCode, and waitForMatch resolve to the extracted string. waitForMessage resolves to the complete captured email.

Runner-native fixtures

InboxTap provides isolated fixture subpaths for Bun test, Vitest, and Playwright. The shared fixture starts both listeners on dynamic ports by default, creates and verifies a plain Nodemailer transport, cleans up partial startup failures, and exposes an idempotent close().

Install Nodemailer 9 with InboxTap, plus the runner adapter your project uses:

bun add --dev inboxtap nodemailer vitest

Extend a Vitest base test to share one InboxTap server per file while creating a fresh inbox for every test:

import { expect, test as base } from "vitest";
import { extendInboxTap } from "inboxtap/fixtures/vitest";

const test = extendInboxTap(base);

test("captures an account email", async ({ inboxTap, inbox }) => {
  await inboxTap.transport.sendMail({
    from: "[email protected]",
    to: inbox.address,
    subject: "Verify your account",
    text: "Open https://app.local.test/verify?id=example",
  });

  const message = await inbox.waitForMessage({ subject: /verify your account/i });
  expect(message.envelope.to).toContain(inbox.address);
});

Use startInboxTapFixture() from inboxtap/fixtures for an explicit lifecycle, setupInboxTap() from inboxtap/fixtures/bun, or extendInboxTap() from inboxtap/fixtures/playwright. Playwright applications that need the dynamically selected SMTP port must start as a dependent worker fixture; an already-running webServer cannot consume a port selected later by a test fixture.

Assertion matchers

Matcher implementations and runner adapters use isolated subpaths. Inject the runner's expect into its adapter; Bun and Vitest extend that object in place, while Playwright returns a new typed expect:

import { expect, test as base } from "vitest";
import { extendInboxTap } from "inboxtap/fixtures/vitest";
import { extendInboxTapExpect } from "inboxtap/matchers/vitest";

extendInboxTapExpect(expect);
const test = extendInboxTap(base);

test("delivers one verification email", async ({ inboxTap, inbox }) => {
  await inboxTap.transport.sendMail({
    from: "[email protected]",
    to: inbox.address,
    subject: "Verify your account",
    text: "Open https://app.local.test/verify?id=example",
  });

  await expect(inbox).toHaveDeliveredOnce({
    subject: /verify your account/i,
    quietMs: 100,
  });

  const email = await inbox.waitForMessage({ subject: /verify your account/i });
  expect(email).toHaveRecipient(inbox.address);
  expect(email).toContainLink("/verify");
});

toHaveDeliveredOnce() checks the messages that already exist; it does not wait for the first delivery. An optional quietMs observes only the interval after a valid one-message snapshot and cannot prove that no later retry will arrive. Recipient matching uses the SMTP envelope, link strings are substrings, and regular expressions are tested without changing their lastIndex.

toHaveUnsubscribeHeader({ oneClick: true }) reads the raw, unfolded RFC headers and requires an HTTPS List-Unsubscribe target plus List-Unsubscribe-Post: List-Unsubscribe=One-Click. It checks header shape, not DKIM validity or the remote endpoint.

Use inboxTapMatchers or createInboxTapMatchers({ recorder }) from the peer-free inboxtap/matchers subpath when integrating another compatible expect implementation. Matcher diagnostics report only safe counts and states; they do not echo message bodies, recipient values, links, tokens, or raw headers. The recorder receives the same content-safe, structured observations for later report collection. Importing inboxtap, inboxtap/client, or inboxtap/matchers does not load Nodemailer, Vitest, or Playwright.

Redacted test reports

Build shareable CI evidence with the client-side inboxtap/reports subpath. InboxTapReport accepts matcher observations, captured messages, and explicit application assertions, then produces deterministic, versioned JSON or a self-contained static HTML report:

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: "[email protected]",
      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.

write() infers JSON or HTML from the file extension unless format is provided, and creates missing parent directories. By default, reports exclude raw RFC source, consistently pseudonymize email addresses, and redact URL credentials, every query value, fragments, secret-like path values, common authentication and cookie headers, and token-like values in text and HTML. Add project-specific redaction.patterns or redaction.additionalSensitiveHeaders when needed. If a custom pattern overlaps a URL, InboxTap replaces the whole URL so regex-driven mutation cannot expose adjacent query or fragment values.

Recorder scope follows the extended expect. The example uses Vitest's test-bound instance for per-test observations. Do not attach different collectors to one shared Bun or Vitest expect while tests run concurrently; record messages and application assertions explicitly instead, or intentionally build one suite-level report.

Reports accept at most 100 messages and 1,000 assertions, and each rendered artifact is capped at 10 MiB; bounded output includes explicit truncation markers. JSON exposes utf8BytesOmittedExact; when bounded accounting cannot inspect an entire omitted value, the byte count is a measured lower bound and HTML labels it as at least. HTML output escapes captured markup and does not execute captured scripts or load captured or remote images, styles, or tracking pixels.

Redaction is best-effort, not a guarantee that arbitrary personal or secret data will be detected. Review artifacts before sharing them. Setting includeRaw: true retains a best-effort-redacted copy of the raw RFC source and carries materially higher disclosure risk.

SMTP fault injection

Every programmatic server exposes server.faults for deterministic delivery-level failure tests; no enable flag is required. Register a rule before triggering the application: the next matching SMTP transaction to reach DATA consumes it.

inboxTap.server.faults.failNext({
  code: 451,
  message: "Temporary local failure",
  to: inbox.address,
});

await expect(triggerEmail(inbox.address)).rejects.toThrow();
expect(await inbox.messages()).toHaveLength(0);

Use delayNext({ durationMs, to?, times? }), disconnectNext({ afterBytes, to?, times? }), or pauseNext({ to?, timeoutMs? }) to exercise timeouts, interrupted sends, and concurrency. A pause gate exposes state, waitUntilPaused(), and an idempotent release():

const gate = inboxTap.server.faults.pauseNext({ to: inbox.address });
const delivery = triggerEmail(inbox.address);

await gate.waitUntilPaused();
gate.release();
await delivery;

Recipient filters match the SMTP envelope case-insensitively; any matching recipient faults the whole transaction. Only one rule applies to a transaction. Failed and disconnected deliveries are not captured, and paused or delayed deliveries appear only after successful completion. Fault controls are programmatic only—there are no HTTP routes or CLI flags.

HTTP API

All endpoints return JSON. Query values are URL encoded.

| Method | Path | Description | | --- | --- | --- | | GET | /health | Service status, SMTP/API ports, and recipient domain | | GET | /api/emails | List messages; accepts to, subject, afterId, and limit | | GET | /api/emails/latest | Newest matching message | | GET | /api/emails/wait | Long-poll for a message; accepts filters plus timeoutMs up to 60,000 | | GET | /api/emails/:id | Retrieve one captured message | | DELETE | /api/emails | Clear all messages, or only a recipient with ?to=… |

For example:

curl "http://localhost:8025/api/emails/latest?to=signup%40local.test"

Captured emails include the SMTP envelope, headers, decoded text/HTML, discovered HTTP(S) links, 4–8 digit codes, and raw RFC 822 source.

Examples

Runnable end-to-end projects live in examples/:

Each example is standalone: install and test it from its own directory. Guided walkthroughs are on inboxtap.dev/docs.

Safety and scope

InboxTap binds only the loopback addresses (127.0.0.1 and ::1, so localhost works out of the box) by default and intentionally disables SMTP authentication and STARTTLS. Keep it local; it is not an outbound relay or production mail server.

InboxTap 1.x is in-memory only. It does not include persistence, a dashboard, attachments, webhooks, Docker, or configurable extraction files.

Support

InboxTap is free and open source. If it saves you time, you can support its ongoing maintenance. Your support helps fund documentation, bug fixes, and new releases.

Contributing

bun install
bun run verify

Lefthook installs pre-commit checks for formatting, linting, type checking, and Bun tests. Pre-push runs the complete verification suite, including the tsup build and Node distribution smoke test.

Read docs/OVERVIEW.md for the project goals and design invariants, then CONTRIBUTING.md and STYLE_GUIDE.md for coding and commit conventions. Maintainers can use RELEASING.md for the verified npm release flow.

License

MIT