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.yamlis hand-authored frommailosaur@11.1.0(the requests it sends and the fields its models read) and our consumer's client.
Install
npm install -D @crvouga/mockingbird-service-mailosaurESM 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:
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.pemWith 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.
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:
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.