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

@matthieuduga/smtp-dev

v0.1.0

Published

Type-safe, dependency-free Node.js SDK for the smtp.dev email testing API (domains, accounts, mailboxes, messages, tokens and Mercure realtime).

Readme

📨 @matthieuduga/smtp-dev

The type-safe, dependency-free TypeScript SDK for the smtp.dev email testing API.

It creates throwaway sandbox accounts, sends real email through them, reads and downloads the resulting messages, and watches the account over Mercure SSE. Every operation it exposes is exercised by a real integration test against https://api.smtp.dev, and the behaviours of that API that are easy to get wrong are documented up front rather than left for you to discover.

Node TypeScript License Runtime dependencies Modules Tests API

Type-safe • Zero dependencies • ESM + CJS


📑 Contents


🎯 What it does

Type-safe, dependency-free TypeScript SDK for the smtp.dev email testing API. It creates throwaway sandbox accounts, sends real email through them, reads and downloads the resulting messages, and watches the account over Mercure SSE — so a test can assert on the mail its own code triggered instead of mocking a transport. Every operation it exposes is exercised by a real integration test against https://api.smtp.dev, and the behaviours of that API that are easy to get wrong are documented up front rather than left for you to discover.

  • Native fetch only, zero runtime dependencies.
  • Strict TypeScript, bundled .d.ts, dual ESM + CJS build.
  • All 31 documented API operations, each unit-tested and each exercised by a real integration test against the live API.
  • Testing helpers that create, wait for and clean up the account a test needs.

This package is written for end-to-end suites that assert on the mail their own code triggered: a checkout test that needs the receipt it just sent, a signup test that waits for the verification code, a CI job that must leave no sandbox account behind. It suits CI, where the point is to assert on a real message rather than on a mocked transport, and internal tooling, where a throwaway inbox is cheaper and safer than a real one. It is not a production email client: the accounts are sandboxes, and the API keeps them for a retention window rather than for ever.

💡 A test suite should not need a mocked transport. The SDK exists so the assertion reads a message the API really delivered instead of a fixture that only proves your own stub works.


✅ Capability matrix

Seven namespaces, one per resource family of the API, and the operation count of each:

| Namespace | Operations | What it does | | ------------------ | :--------: | ---------------------------------------------------------------------------------- | | client.domains | 5 | List, create, inspect, activate or deactivate and delete the domains your key owns | | client.accounts | 5 | List, create, inspect, update (password, activation) and delete sandbox accounts | | client.mailboxes | 5 | List, create, inspect, rename and delete the folders inside an account | | client.messages | 9 | Send mail, then list, read, flag, move, delete, search and download a mailbox | | client.tokens | 4 | List, create (one-time secret), inspect and revoke the API tokens of your key | | client.realtime | 1 | Mint a Mercure hub token and subscribe to account events over SSE | | client.user | 2 | Read the available plans and the current subscription, limits included | | Total | 31 | Every documented operation, unit-tested and integration-tested |

What that buys you, and where the API itself is the limit:

| Capability | Available | | --------------------------------------------------------- | :-------: | | Create and delete accounts | ✅ | | Send mail | ✅ | | Read, search and filter messages | ✅ | | Download .eml and attachments (bytes, file, stream) | ✅ | | Live events over SSE | ✅ | | API token management — the credentials under your own key | ✅ | | Plan and quota information | ✅ | | Server-side search or filtering of a mailbox | ❌ | | Renaming or updating a token, or reading its secret back | ❌ | | Billing: POST /user/checkout and POST /user/portal | ❌ | | CRUD over the Mercure hub itself | ❌ |

⚠️ The four ❌ rows are the API, not the SDK. Filtering is client-side only, a token has no update route and never returns its secret twice, billing is out of scope, and the Mercure hub is a stream rather than a resource — POST /user/checkout and POST /user/portal are the two routes deliberately left out of an email SDK.


📦 Installation

npm install @matthieuduga/smtp-dev

Nothing else to install: the SDK has no runtime dependency, and Node.js >=20.10 already carries the fetch it uses.


🚀 Quick start

Under five minutes: copy your key in, run the script, read the email it sent. The script creates two temporary accounts, sends a message from one to the other, and reads the copy back.

One honest caveat first, because it changes what the runnable example does. On the account this package was verified against, inbound delivery does not happen: a message sent from one sandbox account never reaches the recipient's INBOX — not after 60 s, and with no bounce. The only copy that exists is the sender's, in its own Sent mailbox a few seconds after the 204. Nothing in the SDK decides where a message is filed; this is a property of that API key. The example below therefore reads the sender's Sent mailbox, which is exactly where the API puts the message, and prints a one-line note about the recipient's copy rather than pretending to fetch it. On a key that does deliver, the same script reads INBOX instead — the only change is the mailbox path.

// quickstart.ts — run with: npx tsx quickstart.ts
import { SmtpDevClient } from '@matthieuduga/smtp-dev';

const client = new SmtpDevClient({ apiKey: process.env.SMTP_DEV_API_KEY ?? '' });

// Two throwaway accounts. `withTemporaryAccount` deletes what it created in a finally
// block, whether the callback returns or throws — so nothing is left behind here.
const sender = await client.helpers.withTemporaryAccount(
  { domain: 'mlbox.space', localPartPrefix: 'quickstart-from' },
  async (account) => {
    const recipient = await client.helpers.withTemporaryAccount(
      { domain: 'mlbox.space', localPartPrefix: 'quickstart-to' },
      async (peer) => peer.address,
    );

    // `from` is mandatory: the API answers `400 $from` without it, even though the
    // published documentation claims the server derives the sender itself.
    await client.messages.send(account.id, {
      from: { address: account.address },
      to: [{ address: recipient }],
      subject: 'Hello from the SDK',
      text: 'This message travelled through smtp.dev.',
    });

    // The only copy that exists is the sender's, in its own `Sent` mailbox.
    const message = await client.waitForMessage(account.id, {
      mailbox: 'Sent',
      subject: 'Hello from the SDK',
      timeoutMs: 30_000,
    });

    console.log('from   :', message.from.address);
    console.log('to     :', message.to.map((r) => r.address).join(', '));
    console.log('subject:', message.subject);
    console.log('body   :', message.text.trim());
    console.log(
      "note   : this copy is in the sender's Sent mailbox; the recipient's",
      'INBOX copy is not observable on this API key.',
    );

    return account.address;
  },
);

client.close();
console.log(`done — ${sender} and its peer are deleted`);

Run it:

export SMTP_DEV_API_KEY=your-key-here
npx tsx quickstart.ts

🔐 Authentication

The API key is sent as the X-API-KEY header on every request. It is required, trimmed, and refused locally when empty.

import { SmtpDevClient } from '@matthieuduga/smtp-dev';

const client = new SmtpDevClient({ apiKey: process.env.SMTP_DEV_API_KEY ?? '' });

Other client options: baseUrl (default https://api.smtp.dev), mercureHubUrl (default https://mercure.smtp.dev/.well-known/mercure), timeoutMs (default 30 000, covering retries), retry, fetch, userAgent and allowInsecureTransport.

Keeping other secrets out of errors

The API key is redacted from every message the SDK produces. Account passwords, Mercure topic secrets and any other credential you send yourself are not known to the client, so register them:

const client = new SmtpDevClient({
  apiKey: process.env.SMTP_DEV_API_KEY ?? '',
  // Redacted from every error message, request URL and body this client produces.
  additionalSecrets: [process.env.ALICE_PASSWORD ?? ''],
});

The API does echo a submitted password back in a duplicate-address validation payload, so this is not hypothetical. Entries shorter than a few characters are ignored — a one-character secret would shred every message — and withTemporaryAccount registers its generated password per request instead, for the same reason.


📂 Namespaces

client.domains, client.accounts, client.mailboxes, client.messages, client.tokens, client.realtime and client.user. Each is readonly, so a namespace can be passed around instead of the whole client.

domains

const page = await client.domains.list({ domain: 'mlbox' });
console.log(page.items.map((d) => `${d.domain} (${String(d.isActive)})`));

const created = await client.domains.create({ domain: 'mail.example.test' });
await client.domains.setActive(created.id, true);
await client.domains.delete(created.id);

create needs DNS records for the name (an MX and an SPF entry pointing at mx.smtp.dev) before the domain receives anything; see the integration-suite gate.

accounts

import type { CreateAccountInput } from '@matthieuduga/smtp-dev';

const input: CreateAccountInput = {
  address: '[email protected]',
  password: 'a-strong-password',
  isActive: true,
};
const account = await client.accounts.create(input);
console.log(
  account.id,
  account.address,
  account.mailboxes.map((m) => m.path),
);

await client.accounts.update(account.id, { password: 'a-rotated-password' });
await client.accounts.delete(account.id);

An account is created with five system mailboxes (INBOX, Sent, Trash, Drafts, Junk). create without a password answers 422; a duplicate address answers 422 with a violations entry for address.

mailboxes

const created = await client.mailboxes.create(account.id, { path: 'Receipts/2026' });
await client.mailboxes.rename(account.id, created.id, 'Receipts/2027');
console.log(await client.mailboxes.get(account.id, created.id));

await client.mailboxes.delete(account.id, created.id); // 204 on a non-system mailbox

A system mailbox answers 400 on delete and survives — check isSystem when the request matters. list accepts a path filter; listAll and listPages walk every page.

messages

The shortest way to send, and the shortest way to read the copy back:

await client.messages.send(account.id, {
  from: { address: '[email protected]' },
  to: [{ address: '[email protected]' }],
  subject: 'Receipt',
  text: 'Thanks!',
});

const inbox = await client.helpers.resolveMailbox(account.id, 'INBOX');
const page = await client.messages.list(account.id, inbox.id, { order: 'desc' });
const [newest] = page.items;
if (!newest) throw new Error('the mailbox is empty');
const message = await client.messages.get(account.id, inbox.id, newest.id);
console.log(message.subject, message.from.address);

Every field a message can carry, plus flagging and deletion:

import type { SendMessageInput } from '@matthieuduga/smtp-dev';

// Send. `from` is mandatory; at least one of text/html/attachments is required.
const body: SendMessageInput = {
  from: { address: '[email protected]', name: 'Example' },
  to: [{ address: '[email protected]' }],
  cc: [{ address: '[email protected]' }],
  subject: 'Receipt',
  text: 'Thanks!',
  html: '<p>Thanks!</p>',
  headers: [{ name: 'X-Order-Id', value: '42' }],
};
await client.messages.send(account.id, body); // 204, no content

// Read back. Mailbox ids are resolved by path — see the testing helpers.
const inbox = await client.helpers.resolveMailbox(account.id, 'INBOX');
const page = await client.messages.list(account.id, inbox.id, { order: 'desc' });
const message = await client.messages.get(account.id, inbox.id, page.items[0].id);

await client.messages.update(account.id, inbox.id, message.id, {
  isRead: true,
  isFlagged: true,
});
await client.messages.delete(account.id, inbox.id, message.id);

messages.search filters client-side over every page — the API exposes no server-side search:

for await (const found of client.messages.search(account.id, inbox.id, {
  subject: /verification/i,
  from: '[email protected]',
  hasAttachments: false,
  since: new Date('2026-10-01T00:00:00Z'),
})) {
  console.log(found.date, found.subject);
}

tokens

import type { CreateTokenInput } from '@matthieuduga/smtp-dev';

const input: CreateTokenInput = { name: 'regression', description: 'E2E suite' };
const created = await client.tokens.create(input);

// `created.token` is returned ONCE. There is no route that reads it back.
console.log(created.id, created.token);
await client.tokens.delete(created.id);

tokens.list and tokens.get never carry a secret, which is why create resolves to CreatedToken and get to Token. There is no update: the API exposes no PATCH or PUT on a token.

realtime

See Real-time below.

user

const plans = await client.user.plans();
console.log(plans.map((p) => `${p.name} ${String(p.price)}`));

const subscription = await client.user.subscription();
console.log(subscription.plan, subscription.limits);

subscription.plan is a string on the live API, not an embedded object; the real caps are in subscription.limits. status, currentPeriodEnd and id are absent from the payload and therefore optional on the type.


📄 Pagination

Collections answer Hydra documents (Accept: application/ld+json) with {member, view}. Three shapes per collection, all normalised into Page<T>:

// One page.
const page = await client.messages.list(accountId, inboxId, { order: 'desc', page: 2 });
console.log(page.items.length, page.page, page.nextPage, page.previousPage);

// Every item, crossing page boundaries.
for await (const message of client.messages.listAll(accountId, inboxId)) {
  console.log(message.subject);
}

// Every page, so a long walk can be checkpointed.
for await (const p of client.messages.listPages(accountId, inboxId)) {
  console.log(`page ${String(p.page)}: ${String(p.items.length)} items`);
}
  • The page size is fixed at 30, server-side. itemsPerPage, perPage and limit are ignored by the API and are not offered by ListOptions.
  • view carries only @id and previous. next is present on page 1 and absent on the last page, so page.nextPage is null there — do not use it to detect the end; an empty items array does.
  • page.totalItems is number | null. It was present on GET /user/plans and absent elsewhere; null is a real answer, not a gap. It is the collection's count, not this page's.
  • There is no server-side search and no server-side filter beyond order[createdAt]. Filter with messages.search (one request per 30 messages) or with waitForMessage.

✅ Every collection namespace answers list, listAll and listPages, so the three shapes above are available on domains, accounts, mailboxes, messages and tokens, not only on messages.


📎 Attachments

Send one attachment and read the message back as bytes:

// ── send ──
await client.messages.send(account.id, {
  from: { address: '[email protected]' },
  to: [{ address: '[email protected]' }],
  subject: 'Invoice',
  text: 'See attached.',
  attachments: [
    {
      filename: 'hello.txt',
      contentType: 'text/plain',
      content: new TextEncoder().encode('hello'),
    },
  ],
});

// ── download ──
const eml = await client.messages.downloadEml(accountId, inboxId, messageId);
console.log(eml.contentType, eml.body.byteLength, eml.contentDisposition);

Send from bytes, from a base64 string, or from a file path:

await client.messages.send(account.id, {
  from: { address: '[email protected]' },
  to: [{ address: '[email protected]' }],
  subject: 'Invoice',
  text: 'See attached.',
  attachments: [
    // Bytes — base64-encoded by the SDK.
    {
      filename: 'hello.txt',
      contentType: 'text/plain',
      content: new TextEncoder().encode('hello'),
    },
    // An already-base64 string — sent verbatim, after a local validation.
    { filename: 'data.json', contentType: 'application/json', content: btoa('{"ok":true}') },
    // A path — read from disk *before* the request is built, so a bad path sends nothing.
    { filename: 'report.pdf', contentType: 'application/pdf', content: { path: './report.pdf' } },
  ],
});

Download to bytes, to a file, or as a stream:

import { writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';

// Bytes, with the MIME type and Content-Disposition the API sent.
const eml = await client.messages.downloadEml(accountId, inboxId, messageId);
console.log(eml.contentType, eml.body.byteLength, eml.contentDisposition);
await writeFile('message.eml', eml.body);

// Straight to disk — the bytes are written verbatim.
await client.messages.downloadEmlToFile(accountId, inboxId, messageId, 'message.eml');

// A stream, to avoid holding a large attachment in memory. The body is a web
// ReadableStream; `Readable.fromWeb` adapts it for node:stream consumers.
const attachment = await client.messages.downloadAttachmentStream(
  accountId,
  inboxId,
  messageId,
  attachmentId,
);
await pipeline(
  Readable.fromWeb(attachment.stream as Parameters<typeof Readable.fromWeb>[0]),
  createWriteStream('out.bin'),
);

attachments[].size reported by the API is the size of the stored MIME part, not of what you uploaded — a 1024 B payload came back advertising 1402 B. The downloaded bytes are identical to the uploaded ones.


📡 Real-time

realtime.subscribe(accountId) yields typed events from the Mercure SSE hub and reconnects on its own.

const controller = new AbortController();

for await (const event of client.realtime.subscribe(accountId, {
  signal: controller.signal,
  reconnectDelayMs: 1_000,
  maxReconnectDelayMs: 30_000,
})) {
  if (event.type === 'message') {
    console.log('new message:', event.data.subject);
  } else if (event.type === 'mailbox') {
    console.log('mailbox:', event.data.path);
  } else {
    // An event the SDK could not type is yielded, never thrown.
    console.log('untyped payload:', event.data);
  }
}

client.close(); // also releases every open subscription and its socket

Three things about this hub that will cost you a silent failure if you assume otherwise:

  1. Only the topic query parameter works. match, matchURITemplate and matchURLPattern all answer 400 Missing "topic" parameter. topic is the default of SubscribeOptions.topicParameter; the other spellings remain accepted for a future stock hub.
  2. The topic must be the template /accounts/<accountId>{+path}. Without the {+path} operator the hub answers 200 and then stays silent for ever — no event, no error. The SDK always sends the template; there is no option that can drop it, and SubscribeOptions publishes no topic and no mailbox narrowing, because an option that filtered nothing would read as a filtered stream.
  3. The frames carry only id and data. No SSE event: name, no type: field — so event.type is discriminated on data["@type"], and event.event is absent on every frame. Each stream opens with a : heartbeat comment.

realtime.getToken() exposes the Mercure JWT and its locally decoded expiresAt (the signature is not verified, and a token whose exp cannot be read is never reused). subscribe fetches its own token; you rarely need this.


🧪 Testing helpers

client.helpers sequences routes the namespaces already expose — it adds no new API operation — and saves the sequencing every mail test repeats. client.waitForMessage is a shortcut for the most common of them.

waitForMessage

import { MessageWaitTimeoutError, SmtpDevClient } from '@matthieuduga/smtp-dev';

const client = new SmtpDevClient({ apiKey: process.env.SMTP_DEV_API_KEY ?? '' });

// Trigger the thing you want an email for…
await signUp({ address: '[email protected]' });

try {
  const message = await client.waitForMessage(accountId, {
    subject: /verification code/i,
    from: '[email protected]',
    timeoutMs: 15_000,
  });
  console.log(message.text);
} catch (error) {
  if (error instanceof MessageWaitTimeoutError) {
    // The message names the account, the mailbox and the criteria, so a mistyped
    // subject is obvious without a debugger.
    console.warn(error.timeoutMs, error.message);
  }
  throw error;
}

Safety guarantees:

  • No mailbox id is ever assumed. The default is the documented path 'INBOX', resolved through the API; so is 'Sent' or 'Receipts/2026'. Ids are server-assigned and change when an account is recreated.
  • searchFromNow: true (the default) costs one extra listing and ignores what was already in the mailbox, so the message you just triggered is the one you get.
  • The matching rules are the ones of messages.search — same buildMatcher — so a wait and a search cannot disagree. A malformed subject, from or hasAttachments is a TypeError before the first request.
  • A slow round is not a failed wait: a poll that hits its own deadline, a network failure and an exhausted 5xx all count as a round that produced nothing, and the wait ends as MessageWaitTimeoutError.
  • signal aborts with SmtpDevAbortError; timeoutMs expires with MessageWaitTimeoutError. Either way the poll loop stops, the socket is released and every timer and listener is cleared.

Request cost: poll ≈ one request per pollIntervalMs (about 30 for the default 30 s / 1 s); sse costs 2 (mailbox resolution + GET /mercure/token) and then none; the default hybrid costs at least 4 (mailbox resolution, baseline, one poll, one token). Only the first page — the newest 30 — is ever read. DEFAULT_WAIT_TIMEOUT_MS and DEFAULT_POLL_INTERVAL_MS are exported so a CI budget can be derived from the same numbers.

withTemporaryAccount

const sent = await client.helpers.withTemporaryAccount(
  { domain: 'example.test', localPartPrefix: 'checkout-test' },
  async (account) => {
    await client.messages.send(account.id, {
      // `from` is mandatory: the API answers 400 without it.
      from: { address: account.address },
      to: [{ address: '[email protected]' }],
      subject: 'Receipt',
      text: 'Thanks!',
    });
    const inbox = await client.helpers.resolveMailbox(account.id, 'INBOX');
    return client.messages.list(account.id, inbox.id, { order: 'desc' });
  },
);
// The account — and every message it held — is gone, whether the callback returned
// or threw.
console.log(sent.items.map((message) => message.subject));

Safety guarantees:

  • The address is random (<prefix>-<12 random base64url characters>@<domain>), so two parallel runs never collide and a leaked account is identifiable by prefix.
  • The password defaults to a random 24-character base64url string, generated inside the helper and registered as a per-request secret of the POST /accounts that carries it.
  • signal reaches both calls, the creation and the cleanup delete — the delete is the only irreversible operation here.
  • The delete always happens. Callback throws and delete succeeds → the original error is rethrown untouched. Both fail → an AggregateError carries the two. Callback succeeds and delete fails → the delete error propagates alone.
  • A failed creation issues no delete, but cannot promise nothing was created: the server may have accepted the POST and the response still failed. deleteAccountsByPrefix is the recovery.

ensureAccount

const { account, created } = await client.helpers.ensureAccount({
  address: '[email protected]',
  isActive: true,
});
console.log(
  created ? 'created' : 'reused',
  account.mailboxes.map((m) => m.path),
);

Safety guarantees: the address filter is a partial match, so the result is narrowed to an exact case-insensitive address — asking for [email protected] can never match [email protected], nor create a second account next to one that matched partially. The narrowing walks every page. isActive: true reactivates an inactive account with one PATCH; an existing password is never touched, because it is write-only. This is a read followed by a write, so it is not atomic: give each parallel run its own domain or localPartPrefix.

deleteAccountsByPrefix

// See the blast radius first — the sweep, the matching and the report are identical,
// only the DELETEs are skipped.
const preview = await client.helpers.deleteAccountsByPrefix('nsd-tmp', {
  domain: 'example.test',
  dryRun: true,
});
console.log(`would delete ${preview.deleted.length} of ${preview.inspected}`);

await client.helpers.deleteAccountsByPrefix('nsd-tmp', { domain: 'example.test' });

Safety guarantees — this is irreversible, and deleting an account deletes its mailboxes and every message in them:

  • A prefix shorter than 4 characters is refused with a TypeError before any request.
  • Only accounts whose local part starts with the prefix are deleted; an address that merely contains it ([email protected]) is inspected and left alone.
  • domain narrows the sweep; signal reaches the listing and every delete.
  • A sweep that fails after destroying accounts attaches them to the error as AccountSweepError#deleted — the one fact the caller cannot get anywhere else.

resolveMailbox

// A bare string is always a PATH. An identifier requires the object form, so the intent
// is explicit at the call site and can never be mis-resolved.
const inbox = await client.helpers.resolveMailbox(accountId, 'INBOX');
const nested = await client.helpers.resolveMailbox(accountId, { path: 'Receipts/2026' });
const same = await client.helpers.resolveMailbox(accountId, { id: inbox.id });

Safety guarantees: a path is matched exactly but case-insensitively ('inbox' → INBOX; 'INB' is refused with SmtpDevNotFoundError), a spelling the API's exact-match path filter does not return is retried once without the filter, and a malformed id is a TypeError without spending a request.


⚠️ Error handling

Every failure is an SmtpDevError — including parse failures, timeouts and aborts — so one catch separates SDK failures from programming mistakes (TypeError).

import {
  SmtpDevAuthenticationError, // 401
  SmtpDevMethodNotAllowedError, // 405
  SmtpDevNetworkError, // fetch failed
  SmtpDevNotAcceptableError, // 406
  SmtpDevNotFoundError, // 404
  SmtpDevRateLimitError, // 429
  SmtpDevResponseError, // unparsable body, unexpected Content-Type, unmapped status
  SmtpDevTimeoutError, // deadline exceeded
  SmtpDevValidationError, // 400 / 422
  SmtpDevAbortError, // caller signal
  isSmtpDevError,
  type SmtpDevError,
} from '@matthieuduga/smtp-dev';

try {
  await client.messages.get(accountId, inboxId, 'missing');
} catch (error) {
  if (error instanceof SmtpDevNotFoundError) {
    // status, code, statusText, body (sanitised), request {method, url}
  }
  if (error instanceof SmtpDevValidationError) {
    console.error(error.status, error.violations); // [{propertyPath, message}, …]
  }
  if (error instanceof SmtpDevRateLimitError) {
    console.error(error.limit, error.remaining, error.retryAfterMs);
  }
  if (isSmtpDevError(error)) {
    // Safe to log and to serialise: never a body, never a URL with credentials.
    console.error(JSON.stringify(error));
  }
}

error.toJSON() returns {name, message, status, code, request} — the same safe snapshot JSON.stringify(error) uses. Error bodies are heterogeneous upstream (404 is application/problem+json, 401 is application/json {message}); the SDK normalises them and puts the sanitised text in message, the structured one in body.

AccountSweepError extends SmtpDevError and adds deleted: readonly string[] and inspected: number, so a partially failed sweep can name what it destroyed.

| Narrow with | Raised when | | ------------------------------ | ------------------------------------------------------------------------------------------------- | | SmtpDevNotFoundError | 404 — a stale id, a deleted mailbox or message. | | SmtpDevValidationError | 400/422 — field-level rejection; read violations. | | SmtpDevAuthenticationError | 401 — the key is missing, malformed or revoked. Never retried. | | SmtpDevNotAcceptableError | 406 — the route does not accept the Accept header sent. | | SmtpDevMethodNotAllowedError | 405 — the verb does not exist on that route. | | SmtpDevRateLimitError | 429 — the request budget is exhausted; carries limit/remaining. | | SmtpDevResponseError | An unparsable body, an unexpected Content-Type, or an unmapped status. | | SmtpDevTimeoutError | The request (retries included) exceeded timeoutMs. | | SmtpDevNetworkError | fetch itself failed — DNS, TLS, socket. | | SmtpDevAbortError | The caller's signal aborted the call. | | MessageWaitTimeoutError | A waitForMessage deadline expired; carries timeoutMs, accountId, mailboxPath, settings. | | AccountSweepError | deleteAccountsByPrefix failed after deleting; carries deleted, inspected. | | TypeError | A programming error — blank id, malformed input. Never retried, no request spent. |


🔁 Rate limiting and retries

The API returns ratelimit-limit: 8192 per window, the window is not sliding, and ratelimit-reset is always 0 — so the server gives no usable wait hint and the SDK's own backoff is what you wait on.

Defaults: 2 retries, 250 ms base delay doubling, 5 s ceiling, full jitter.

Retried — only when the verb is idempotent (GET, PUT, DELETE):

  • 429 and any 5xx;
  • SmtpDevNetworkError and SmtpDevTimeoutError.

Never retried, and why:

  • Non-idempotent verbs (POST) — a replayed POST can create a duplicate domain or, worse, send the same email twice. A caller who wants that must opt in explicitly.
  • 401, 404, 406, 405, 400/422 — the answer will be identical next time.
  • SmtpDevAbortError — the caller asked for it to stop.
  • A deadline exceeded: timeoutMs covers retries, so a retry that cannot finish in the remaining budget is pointless.
const client = new SmtpDevClient({
  apiKey: process.env.SMTP_DEV_API_KEY ?? '',
  timeoutMs: 10_000,
  retry: { maxRetries: 4, baseDelayMs: 100, maxDelayMs: 2_000, jitter: true },
});

🖥️ Runtime support

Requirements:

  • Node.js >=20.10 — the SDK uses the native fetch, AbortSignal.any and ReadableStream and ships no polyfill. Developed and verified on Node.js 22.
  • No CI matrix is configured for this package; npm run check is the quality gate.

Runtime notes:

  • Node.js >=20.10: native fetch, AbortSignal.any (used when available, with a fallback), ReadableStream, structuredClone-free object handling.
  • ESM and CJS both ship, with per-condition .d.ts files (attw verifies both).
  • Browser runtimes: the CRUD namespaces work — they are plain fetch and no Node built-in is imported at module scope. Two caveats: pass an explicit fetch if the runtime's Headers/AbortSignal.any differ, and set allowInsecureTransport: true only for a local stub. The filesystem variants (downloadEmlToFile, downloadAttachmentToFile) and the { path } attachment input use node:fs/promises and are Node-only; { path } and the …ToFile methods throw there. Use the bytes or the stream form in a browser.
  • sideEffects: false, and the internal transport, Hydra helpers, SSE parser and validators are deliberately not exported: they may change without a major version bump.

💡 The two build conditions are not decoration. attw runs as part of npm run check, so a .d.ts that resolves to the wrong module shape fails the gate before it reaches a consumer.


🔬 Verifying against the real API

The integration suite under test/integration/ exercises every route against https://api.smtp.dev and records what the server actually answered. It is opt-in, never part of npm run check, and only ever mutates resources it created itself.

cp .env.example .env      # then fill in SMTP_DEV_API_KEY
npm run test:integration

# Remove the leftovers of every past run: accounts, tokens and domains, in its own
# process, on its own config so it can never sweep a concurrent run.
npm run test:integration:cleanup

| Variable | Effect when absent | | ------------------------------- | --------------------------------------------------------------------------------------------- | | SMTP_DEV_API_KEY | The whole suite skips with one message and exit code 0. | | SMTP_DEV_TEST_DOMAIN | The first active domain of the key is discovered; set but unknown or inactive → hard failure. | | SMTP_DEV_ENABLE_DOMAIN_TESTS | Domain create/update/delete stays off; those three routes are reported as skipped. | | SMTP_DEV_ENABLE_INBOUND_TESTS | The delivery-dependent tests stay off; their routes are reported as skipped. |

Every run prints a markdown table — one row per route, with the SDK method, the unit test that pins it, the result and the observation — and writes the same thing to test-results/integration-report.json, so every run produces its own coverage record. A row is recorded after the assertions it summarises and is downgraded to failed if the file that recorded it had any failed test, so a green cell always means a green module.

Guardrails. Everything the suite creates carries the run prefix nsd-it-<8 hex>, and the cleanup registry refuses to register any resource whose identifier does not carry it; cleanup is reverse-ordered (message before mailbox before account, token last), never throws, and accumulates failures. The standalone sweep covers the three families a killed run can strand — accounts (which take their mailboxes and messages with them), the POST /tokens credentials and the opt-in nsd-it-*.invalid domains — and deletes only those whose local part, name or domain starts with nsd-it-, re-asserting the predicate on the very resource before the delete. It does not reach anything created without the prefix, nor a Mercure JWT (a lifetime, not a listing). No key, password, JWT, one-time token secret, subject or body is ever printed.

The inbound-delivery caveat

On the key this suite was last run against, a message sent from one sandbox account to another never reached the recipient's INBOX; only the sender's own Sent copy existed, a few seconds after the 204 of messages.send. The suite therefore reads and writes the messages an account sends to itself, and validates realtime with a mailbox mutation instead of a message event. The three checks that genuinely need a delivery (waitForMessage on a received message, the Message Mercure event, the account-to- account scenario) skip with an explicit reason and are reported as skipped — set SMTP_DEV_ENABLE_INBOUND_TESTS=1 to run them against a key that delivers.

⚠️ Inbound delivery depends on the key, not on the SDK. A key that does not deliver produces a Sent copy and an empty INBOX; that is the behaviour the quick start and the integration suite both read around.


🧭 API behaviour notes

Points of the SMTP.DEV API that are easy to get wrong from the route reference alone. The SDK encodes each of them in the type, in the local validation or in the TSDoc of the method it applies to, so none of them can cost you a request or a silent bug.

  • Content negotiation — collections are Hydra: with Accept: application/ld+json they answer { member, view }, and with Accept: application/json they degrade to a bare array with no pagination metadata. The SDK requests application/ld+json everywhere and overrides it to application/json for realtime.getToken(), which accepts nothing else (406).
  • from is required by messages.send — omitting it answers 400 … "$from". SendMessageInput.from is therefore required and validated locally, so the mistake costs no request.
  • expiresAt is computed by the server — messages.update accepts the field and answers 200, but the stored deadline comes back as now plus the plan's retention (7 days on the free plan). Read Message.expiresAt from the answer; never report a submitted value back as applied.
  • move takes a mailbox id and mints a new message id — {"mailbox": "<id>"} answers 200, and the id you passed then answers 404 in both mailboxes. Use the returned message for anything that follows.
  • A system mailbox cannot be deleted — mailboxes.delete answers 400 on one of the five folders created with the account, and the mailbox survives. Check isSystem first.
  • The Mercure hub only accepts topic, and only with the {+path} template — the match, matchURITemplate and matchURLPattern spellings all answer 400 Missing "topic" parameter, and a topic without {+path} answers 200 and then delivers nothing, silently and for ever. Frames carry only id and data, so the event kind comes from data["@type"].
  • The page size is fixed at 30 — itemsPerPage, perPage and limit are ignored; view.next is absent on the last page; totalItems is published by some collections (/user/plans) and not by the others, which is why Page.totalItems is nullable.
  • A sent message lands in the sender's Sent mailbox, a few seconds after the 204 — not in the recipient's INBOX. It carries threadId, downloadUrl, sourceUrl and expiresAt, and omits autoDeleteEnabled and verifications. html is an array, and flags holds IMAP flags such as ["\\Seen","\\Flagged"].
  • Mailbox is thinner on some routes — the list and create answers carry totalMessages/totalUnreadMessages, the rename answer omits both, and Mailbox.account is a relative IRI string (/accounts/<id>), which is why both are optional on the type.
  • Plan and UserSubscription carry fewer fields than their Hydra classes declare — no stripePriceId, domains, accounts, retention, quota, status or currentPeriodEnd. The effective caps are in subscription.limits.
  • attachments[].size is the stored MIME part, not your upload (1402 B advertised for a 1024 B payload); the downloaded bytes are identical to what you uploaded.
  • A sent message and a received one are not the same — the attachment of a message becomes downloadable a moment after the message appears, and a moved message becomes readable through its destination a moment after the 200.

🔒 Security

Report a vulnerability privately through GitHub's advisory form rather than in an issue; see SECURITY.md for what the package handles and what is out of scope.

The SDK's own posture is the redaction described under authentication: the API key never appears in an error message, a request URL or a serialised error, and a credential you register through additionalSecrets is treated the same way.


🤝 Contributing

Issues and pull requests are welcome. Read CONTRIBUTING.md first, run npm run check before opening a pull request, and describe exactly what you tested.


📄 Licence

MIT © 2026 Matthieu DUGA