@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).
Maintainers
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.
Type-safe • Zero dependencies • ESM + CJS
📑 Contents
- 🎯 What it does
- ✅ Capability matrix
- 📦 Installation
- 🚀 Quick start
- 🔐 Authentication
- 📂 Namespaces
- 📄 Pagination
- 📎 Attachments
- 📡 Real-time
- 🧪 Testing helpers
- ⚠️ Error handling
- 🔁 Rate limiting and retries
- 🖥️ Runtime support
- 🔬 Verifying against the real API
- 🧭 API behaviour notes
- 🔒 Security
- 🤝 Contributing
- 📄 Licence
🎯 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
fetchonly, 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/checkoutandPOST /user/portalare the two routes deliberately left out of an email SDK.
📦 Installation
npm install @matthieuduga/smtp-devNothing 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 mailboxA 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,perPageandlimitare ignored by the API and are not offered byListOptions. viewcarries only@idandprevious.nextis present on page 1 and absent on the last page, sopage.nextPageisnullthere — do not use it to detect the end; an emptyitemsarray does.page.totalItemsisnumber | null. It was present onGET /user/plansand absent elsewhere;nullis 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 withmessages.search(one request per 30 messages) or withwaitForMessage.
✅ Every collection namespace answers
list,listAllandlistPages, so the three shapes above are available ondomains,accounts,mailboxes,messagesandtokens, 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 socketThree things about this hub that will cost you a silent failure if you assume otherwise:
- Only the
topicquery parameter works.match,matchURITemplateandmatchURLPatternall answer400 Missing "topic" parameter.topicis the default ofSubscribeOptions.topicParameter; the other spellings remain accepted for a future stock hub. - The topic must be the template
/accounts/<accountId>{+path}. Without the{+path}operator the hub answers200and then stays silent for ever — no event, no error. The SDK always sends the template; there is no option that can drop it, andSubscribeOptionspublishes notopicand no mailbox narrowing, because an option that filtered nothing would read as a filtered stream. - The frames carry only
idanddata. No SSEevent:name, notype:field — soevent.typeis discriminated ondata["@type"], andevent.eventis 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— samebuildMatcher— so a wait and a search cannot disagree. A malformedsubject,fromorhasAttachmentsis aTypeErrorbefore the first request. - A slow round is not a failed wait: a poll that hits its own deadline, a network failure
and an exhausted
5xxall count as a round that produced nothing, and the wait ends asMessageWaitTimeoutError. signalaborts withSmtpDevAbortError;timeoutMsexpires withMessageWaitTimeoutError. 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 /accountsthat carries it. signalreaches 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
AggregateErrorcarries 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
POSTand the response still failed.deleteAccountsByPrefixis 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
TypeErrorbefore 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. domainnarrows the sweep;signalreaches 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):
429and any5xx;SmtpDevNetworkErrorandSmtpDevTimeoutError.
Never retried, and why:
- Non-idempotent verbs (
POST) — a replayedPOSTcan 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:
timeoutMscovers 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 nativefetch,AbortSignal.anyandReadableStreamand ships no polyfill. Developed and verified on Node.js 22. - No CI matrix is configured for this package;
npm run checkis the quality gate.
Runtime notes:
- Node.js
>=20.10: nativefetch,AbortSignal.any(used when available, with a fallback),ReadableStream,structuredClone-free object handling. - ESM and CJS both ship, with per-condition
.d.tsfiles (attwverifies both). - Browser runtimes: the CRUD namespaces work — they are plain
fetchand no Node built-in is imported at module scope. Two caveats: pass an explicitfetchif the runtime'sHeaders/AbortSignal.anydiffer, and setallowInsecureTransport: trueonly for a local stub. The filesystem variants (downloadEmlToFile,downloadAttachmentToFile) and the{ path }attachment input usenode:fs/promisesand are Node-only;{ path }and the…ToFilemethods 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.
attwruns as part ofnpm run check, so a.d.tsthat 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
Sentcopy and an emptyINBOX; 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+jsonthey answer{ member, view }, and withAccept: application/jsonthey degrade to a bare array with no pagination metadata. The SDK requestsapplication/ld+jsoneverywhere and overrides it toapplication/jsonforrealtime.getToken(), which accepts nothing else (406). fromis required bymessages.send— omitting it answers400 … "$from".SendMessageInput.fromis therefore required and validated locally, so the mistake costs no request.expiresAtis computed by the server —messages.updateaccepts the field and answers200, but the stored deadline comes back as now plus the plan's retention (7 days on the free plan). ReadMessage.expiresAtfrom the answer; never report a submitted value back as applied.movetakes a mailbox id and mints a new message id —{"mailbox": "<id>"}answers200, and the id you passed then answers404in both mailboxes. Use the returned message for anything that follows.- A system mailbox cannot be deleted —
mailboxes.deleteanswers400on one of the five folders created with the account, and the mailbox survives. CheckisSystemfirst. - The Mercure hub only accepts
topic, and only with the{+path}template — thematch,matchURITemplateandmatchURLPatternspellings all answer400 Missing "topic" parameter, and atopicwithout{+path}answers200and then delivers nothing, silently and for ever. Frames carry onlyidanddata, so the event kind comes fromdata["@type"]. - The page size is fixed at 30 —
itemsPerPage,perPageandlimitare ignored;view.nextis absent on the last page;totalItemsis published by some collections (/user/plans) and not by the others, which is whyPage.totalItemsis nullable. - A sent message lands in the sender's
Sentmailbox, a few seconds after the204— not in the recipient'sINBOX. It carriesthreadId,downloadUrl,sourceUrlandexpiresAt, and omitsautoDeleteEnabledandverifications.htmlis an array, andflagsholds IMAP flags such as["\\Seen","\\Flagged"]. Mailboxis thinner on some routes — the list and create answers carrytotalMessages/totalUnreadMessages, the rename answer omits both, andMailbox.accountis a relative IRI string (/accounts/<id>), which is why both are optional on the type.PlanandUserSubscriptioncarry fewer fields than their Hydra classes declare — nostripePriceId,domains,accounts,retention,quota,statusorcurrentPeriodEnd. The effective caps are insubscription.limits.attachments[].sizeis 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
