Mailosaur

@crvouga/mockingbird-service-mailosaur

Stateful mock of the Mailosaur email/SMS testing API (messages search, get, delete, long-poll) with an HTTP ingest so other mocks can drop mail in (Mockingbird service contract).

Work in progress. Usable, but incomplete: operations, response shapes and options can still change between releases. Pin an exact version. Check the README's "Deliberately not modelled" notes before relying on it.
Work in progressCommunication Runs in the browser8 of 8 operationsIn-process fetchNode servermockingbird-mailosaur
$npm install -D @crvouga/mockingbird-service-mailosaur

Playground

Requests go to the real mock running in this tab. State persists across requests, and the journal shows what it received. A green dot marks operations whose sample succeeds as-is; others need ids from earlier responses.

Loads the real @crvouga/mockingbird-service-mailosaur into this tab on first send
https://mailosaur.com

`Authorization: Basic base64(<api key>:)` (the SDK sends the key as the username).

Send a request to see the mock's response.

Operations

8 of 8 operations in the vendored contract are mocked. Hand-authored from mailosaur@11.1.0 (esm/operations/messages.js, esm/models/*.js) and apps/backend/src/modules/dev-tools/lib/mailosaur-client.ts.

MethodPathOperationStatus
POST/api/messages/searchSearchMessagesMessage summaries matching the criteria, newest first. Answers at once; when nothing matches, `x-ms-delay` tells the SDK how long to wait…
GET/api/messages/awaitAwaitMessageByQueryThe same long-poll with the criteria as query parameters.
POST/api/messages/awaitAwaitMessageServer-side long-poll: hold the request until a message matching the criteria arrives (answering with the full message within millisecond…
GET/api/messagesListMessages
POST/api/messagesCreateMessage`messages.create`: a new message in the server's inbox (the vendor would also send it to a verified address when `send` is true; the mock…
DELETE/api/messagesDeleteAllMessages
GET/api/messages/{id}GetMessage
DELETE/api/messages/{id}DeleteMessage

Documentation

The package README, the same file that ships in the npm tarball.Edit on GitHub

Stateful mock of the Mailosaur email/SMS testing API for test suites, plus an HTTP ingest so anything that "sends" mail (the Resend mock's --forward-to-inbox, the Twilio mock, Cognito hooks, a test) drops it into one inbox. The unmodified mailosaur SDK reads it: messages.get returns within ~20 ms of a message arriving instead of long-polling Mailosaur for up to 120 s, and html.codes / text.codes / html.links are parsed the way Mailosaur parses them.

  • Operation coverage: SUPPORT.md
  • The vendor publishes no OpenAPI spec: openapi.yaml is hand-authored from mailosaur@11.1.0 (the requests it sends and the fields its models read) and our consumer's client.

Install

Shell
npm install -D @crvouga/mockingbird-service-mailosaur

ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with npx mockingbird-mailosaur serve, createServer from ./server (Node), or createRuntime with any Fetch server.

Usage

Pointing the mailosaur SDK at it

The SDK only speaks HTTPS (https.request, whatever the base URL's scheme) and drops the base URL's port (it passes only the hostname and path, so it always connects to 443). So a MAILOSAUR_BASE_URL alone (G-M1) only works if the mock listens on 443. The mock's secure port therefore also acts as an HTTP CONNECT proxy that tunnels every target into the mock (never to the network). The SDK honours HTTPS_PROXY, reading it once, when a client is constructed:

Shell
npx mockingbird-mailosaur serve --port 8793 --tls-port 8794 --tls-cert-out /tmp/mailosaur-mock.pem
# in the process that constructs the SDK client:
HTTPS_PROXY=http://127.0.0.1:8794 NODE_EXTRA_CA_CERTS=/tmp/mailosaur-mock.pem

With that, new MailosaurClient(apiKey) keeps its default https://mailosaur.com/ and every call lands in the mock. The generated certificate names localhost, 127.0.0.1 and mailosaur.com. Set HTTPS_PROXY only around the SDK construction if the rest of the process must not see it, since other HTTP clients (axios) also read it and would be tunnelled into the mock too. If the mock can bind 443, --tls-port 443 and new MailosaurClient(key, "https://127.0.0.1/") work without the proxy.

JavaScript
import { createServer } from "@crvouga/mockingbird-service-mailosaur/server"
import MailosaurClient from "mailosaur"

const inbox = await createServer({ tls: true })
// …trust inbox.cert (NODE_EXTRA_CA_CERTS, or tls.setDefaultCACertificates in a test)…
process.env.HTTPS_PROXY = inbox.proxyUrl
const mailosaur = new MailosaurClient("any-key")
delete process.env.HTTPS_PROXY

// Something "sends" the signup email:
await fetch(`${inbox.url}/__admin/ingest`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    to: "member-app-x1@abcd1234.mailosaur.net",
    subject: "Your verification code",
    text: "Your verification code is 604218. ",
  }),
})
const message = await mailosaur.messages.get("abcd1234", { sentTo: "member-app-x1@abcd1234.mailosaur.net" })
message.text?.codes?.[0]?.value // "604218"

The inbox can also be read without the SDK, through the admin routes:

TypeScript
import { createServer } from "@crvouga/mockingbird-service-mailosaur/server"

const inbox = await createServer()
const response = await fetch(`${inbox.url}/__admin/ingest`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ to: "member-app-x1@abcd1234.mailosaur.net", text: "Your verification code is 604218. " }),
})
const { id } = (await response.json()) as { id: string }
const { codes } = (await (await fetch(`${inbox.url}/__admin/outbox/${id}/links`)).json()) as { codes: string[] }
// codes[0] === "604218"
await inbox.close()

Routes

Route Behaviour
POST /api/messages/search?server=&page=&itemsPerPage=&receivedAfter=&dir= Body {sentTo?, sentFrom?, subject?, body?, match?: "ALL"|"ANY"}. sentTo / sentFrom match an address exactly (any of to/cc/bcc, case-insensitive); subject / body are case-insensitive contains. {items: [summary]}, newest first (dir=Ascending flips). Answers at once, with x-ms-delay: 20 (the SDK's poll interval while nothing matches), which is how messages.get returns ~20 ms after arrival.
POST /api/messages/await?server=&receivedAfter=&timeout= Server-side long-poll: the full message the moment one matches, or 404 {type: "search_timeout"} after timeout ms (default 10000, at most 300000). GET takes the criteria as query parameters.
GET /api/messages?server= messages.list: summaries, newest first.
POST /api/messages?server= messages.create: stores {to, subject, text?, html?, from?, cc?} in the server.
DELETE /api/messages?server= messages.deleteAll: 204.
GET /api/messages/{id} messages.getById: the full message (from[], to[], cc[], bcc[], received, subject, html{body, links[{href,text}], codes[{value}], images[]}, text{body, links, codes}, attachments[], metadata, server), or 404.
DELETE /api/messages/{id} messages.del: 204, or 404.

Auth is Authorization: Basic base64(<api key>:) (what the SDK sends); any key works, none is a 401 authentication_error. A 400 names the field the way the SDK's error parser expects ({errors: [{field, detail: [{description}]}]}).

Servers are implicit. A message's server is, in order: the ingest's server, the id in a <server>.mailosaur.net recipient, or * (visible from every server id). receivedAfter keeps messages received at or after the instant (on the mock clock).

Codes and links. codes[] lists every distinct standalone run of 4–8 digits in the readable text (HTML without head, styles, scripts, tags; entities decoded), ignoring digits inside URLs. Our consumer keeps the first 6-digit one. html.links is every <a href> with its text; text.links every URL in the text body.

Admin (beyond the standard contract)

Route Effect
POST /__admin/ingest {to, from?, cc?, bcc?, subject?, html?, text?, server?, type?: "Email"|"SMS", headers?, attachments?: [{filename, content (base64), contentType}]} → 201 with the parsed message. Addresses may be "Name <a@b.co>", bare emails, phone numbers (SMS) or arrays of them. Resend's POST /emails body is accepted as is. Wakes every waiting search at once.
GET /__admin/outbox?to=&since=&server=&limit= Every stored message ({id, to, createdAt, server, message}), oldest first. GET /__admin/outbox/:id returns one.
GET /__admin/outbox/:id/links {id, links: [href…], codes: [value…]}.
GET / PUT /__admin/settings {pollDelaysMs: [20]}: the x-ms-delay sent while a search matches nothing.

Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}; GET /__admin/faults/presets): auth_failed (401, SDK authentication_error), rate_limited (429 on search, SDK api_error), server_error (500), search_never_matches (searches find nothing, so messages.get ends in search_timeout), slow_search (2 s latency).

Namespaces

The SDK cannot add headers, so a namespace can be chosen by API key: PUT /__admin/credentials {"credentials": {"<MAILOSAUR_API_KEY>": "<namespace>"}}. Also x-mockingbird-namespace or a /ns/<name> prefix for raw HTTP callers. Ingest into a namespace with x-mockingbird-namespace (the Resend mock forwards with its own namespace name).

Deliberately not modelled

  • Real delivery: there is no SMTP listener. Mail arrives only through the ingest route, messages.create, or another mock's --forward-to-inbox.
  • Servers, usage, devices (TOTP), previews, spam/deliverability analysis, forward and reply, and file downloads (attachment urls are placeholders; ingest keeps only attachment metadata).
  • Mailosaur's exact code detector is not published. The mock's rule (standalone 4–8 digit runs, not inside URLs) reproduces it for our templates (Cognito's "Your verification code is {####}.").
  • Server ids are not validated against an account; any 8-character id is an (empty) inbox.

API

Export Kind Description
MailosaurAPI class The in-process mock: fetch(request), reset(), ingest(input), messages(), state. Options: sqlite, now, namespace, settings.
createRuntime function The mock with the full service contract (health, admin, namespaces, credentials, presets, ingest). Options: settings, clock, seed, adminKey, onLog, sqlite.
MAILOSAUR_PRESETS object Every named fault preset.
MAILOSAUR_NAMESPACE string The service name, "mailosaur".
ANY_SERVER string "*": the server of mail ingested without one (visible from every server id).
DEFAULT_AWAIT_TIMEOUT_MS, MAX_AWAIT_TIMEOUT_MS numbers The await long-poll's default and maximum timeout.
DEFAULT_SETTINGS object {pollDelaysMs: [20]}.
matchesCriteria function Whether a message matches {sentTo, sentFrom, subject, body, match}.
findCodes, htmlContent, textContent, parseAddresses functions Mailosaur's parsing: codes, {body, links, codes, images} content, Name <email> / phone addresses.
document, operationIds, supportedOperationIds values The vendored OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server) Node Serve over node:http, plus tls: true for the HTTPS + CONNECT door (tlsUrl, proxyUrl, cert); the serve CLI target (--tls-port, --tls-cert, --tls-key, --tls-cert-out, --poll-delay); port 8793.
selfSignedCertificate, CERTIFICATE_HOSTS (./server) Node Generate the in-memory certificate the door presents, and the hosts it names.

Part of mockingbird.

to navigate to open