@crvouga/mockingbird-service-mailosaur 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog — @crvouga/mockingbird-service-mailosaur
2
+
3
+ ## 0.1.0 (2026-09-22)
4
+
5
+ Initial release.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # @crvouga/mockingbird-service-mailosaur
2
+
3
+ Stateful mock of the **Mailosaur** email/SMS testing API for test suites, plus an HTTP ingest so
4
+ anything that "sends" mail (the Resend mock's `--forward-to-inbox`, the Twilio mock, Cognito
5
+ hooks, a test) drops it into one inbox. The unmodified `mailosaur` SDK reads it: `messages.get`
6
+ returns within ~20 ms of a message arriving instead of long-polling Mailosaur for up to 120 s,
7
+ and `html.codes` / `text.codes` / `html.links` are parsed the way Mailosaur parses them.
8
+
9
+ - Operation coverage: [SUPPORT.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/mailosaur/SUPPORT.md)
10
+ - The vendor publishes no OpenAPI spec: `openapi.yaml` is hand-authored from `mailosaur@11.1.0`
11
+ (the requests it sends and the fields its models read) and our consumer's client.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -D @crvouga/mockingbird-service-mailosaur
17
+ ```
18
+
19
+ ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with
20
+ `npx mockingbird-mailosaur serve`, `createServer` from `./server` (Node), or `createRuntime` with
21
+ any Fetch server.
22
+
23
+ ## Usage
24
+
25
+ ### Pointing the `mailosaur` SDK at it
26
+
27
+ The SDK only speaks HTTPS (`https.request`, whatever the base URL's scheme) and **drops the base
28
+ URL's port** (it passes only the hostname and path, so it always connects to 443). So a
29
+ `MAILOSAUR_BASE_URL` alone (G-M1) only works if the mock listens on 443. The mock's secure port
30
+ therefore also acts as an HTTP `CONNECT` proxy that tunnels **every** target into the mock (never
31
+ to the network). The SDK honours `HTTPS_PROXY`, reading it once, when a client is constructed:
32
+
33
+ ```bash
34
+ npx mockingbird-mailosaur serve --port 8793 --tls-port 8794 --tls-cert-out /tmp/mailosaur-mock.pem
35
+ # in the process that constructs the SDK client:
36
+ HTTPS_PROXY=http://127.0.0.1:8794 NODE_EXTRA_CA_CERTS=/tmp/mailosaur-mock.pem
37
+ ```
38
+
39
+ With that, `new MailosaurClient(apiKey)` keeps its default `https://mailosaur.com/` and every
40
+ call lands in the mock. The generated certificate names `localhost`, `127.0.0.1` and
41
+ `mailosaur.com`. Set `HTTPS_PROXY` only around the SDK construction if the rest of the process
42
+ must not see it, since other HTTP clients (axios) also read it and would be tunnelled into the
43
+ mock too. If the mock can bind 443, `--tls-port 443` and `new MailosaurClient(key,
44
+ "https://127.0.0.1/")` work without the proxy.
45
+
46
+ ```js
47
+ import { createServer } from "@crvouga/mockingbird-service-mailosaur/server"
48
+ import MailosaurClient from "mailosaur"
49
+
50
+ const inbox = await createServer({ tls: true })
51
+ // …trust inbox.cert (NODE_EXTRA_CA_CERTS, or tls.setDefaultCACertificates in a test)…
52
+ process.env.HTTPS_PROXY = inbox.proxyUrl
53
+ const mailosaur = new MailosaurClient("any-key")
54
+ delete process.env.HTTPS_PROXY
55
+
56
+ // Something "sends" the signup email:
57
+ await fetch(`${inbox.url}/__admin/ingest`, {
58
+ method: "POST",
59
+ headers: { "content-type": "application/json" },
60
+ body: JSON.stringify({
61
+ to: "member-app-x1@abcd1234.mailosaur.net",
62
+ subject: "Your verification code",
63
+ text: "Your verification code is 604218. ",
64
+ }),
65
+ })
66
+ const message = await mailosaur.messages.get("abcd1234", { sentTo: "member-app-x1@abcd1234.mailosaur.net" })
67
+ message.text?.codes?.[0]?.value // "604218"
68
+ ```
69
+
70
+ The inbox can also be read without the SDK, through the admin routes:
71
+
72
+ ```ts
73
+ import { createServer } from "@crvouga/mockingbird-service-mailosaur/server"
74
+
75
+ const inbox = await createServer()
76
+ const response = await fetch(`${inbox.url}/__admin/ingest`, {
77
+ method: "POST",
78
+ headers: { "content-type": "application/json" },
79
+ body: JSON.stringify({ to: "member-app-x1@abcd1234.mailosaur.net", text: "Your verification code is 604218. " }),
80
+ })
81
+ const { id } = (await response.json()) as { id: string }
82
+ const { codes } = (await (await fetch(`${inbox.url}/__admin/outbox/${id}/links`)).json()) as { codes: string[] }
83
+ // codes[0] === "604218"
84
+ await inbox.close()
85
+ ```
86
+
87
+ ### Routes
88
+
89
+ | Route | Behaviour |
90
+ | --- | --- |
91
+ | `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. |
92
+ | `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. |
93
+ | `GET /api/messages?server=` | `messages.list`: summaries, newest first. |
94
+ | `POST /api/messages?server=` | `messages.create`: stores `{to, subject, text?, html?, from?, cc?}` in the server. |
95
+ | `DELETE /api/messages?server=` | `messages.deleteAll`: 204. |
96
+ | `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. |
97
+ | `DELETE /api/messages/{id}` | `messages.del`: 204, or 404. |
98
+
99
+ Auth is `Authorization: Basic base64(<api key>:)` (what the SDK sends); any key works, none is a
100
+ 401 `authentication_error`. A 400 names the field the way the SDK's error parser expects
101
+ (`{errors: [{field, detail: [{description}]}]}`).
102
+
103
+ **Servers** are implicit. A message's server is, in order: the ingest's `server`, the id in a
104
+ `<server>.mailosaur.net` recipient, or `*` (visible from every server id). `receivedAfter` keeps
105
+ messages received at or after the instant (on the mock clock).
106
+
107
+ **Codes and links.** `codes[]` lists every distinct standalone run of 4–8 digits in the readable
108
+ text (HTML without head, styles, scripts, tags; entities decoded), ignoring digits inside URLs.
109
+ Our consumer keeps the first 6-digit one. `html.links` is every `<a href>` with its text;
110
+ `text.links` every URL in the text body.
111
+
112
+ ### Admin (beyond the standard contract)
113
+
114
+ | Route | Effect |
115
+ | --- | --- |
116
+ | `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. |
117
+ | `GET /__admin/outbox?to=&since=&server=&limit=` | Every stored message (`{id, to, createdAt, server, message}`), oldest first. `GET /__admin/outbox/:id` returns one. |
118
+ | `GET /__admin/outbox/:id/links` | `{id, links: [href…], codes: [value…]}`. |
119
+ | `GET` / `PUT /__admin/settings` | `{pollDelaysMs: [20]}`: the `x-ms-delay` sent while a search matches nothing. |
120
+
121
+ Fault presets (`POST /__admin/faults {"preset": "<name>", "count"?: n}`; `GET /__admin/faults/presets`):
122
+ `auth_failed` (401, SDK `authentication_error`), `rate_limited` (429 on search, SDK `api_error`),
123
+ `server_error` (500), `search_never_matches` (searches find nothing, so `messages.get` ends in
124
+ `search_timeout`), `slow_search` (2 s latency).
125
+
126
+ ### Namespaces
127
+
128
+ The SDK cannot add headers, so a namespace can be chosen by API key:
129
+ `PUT /__admin/credentials {"credentials": {"<MAILOSAUR_API_KEY>": "<namespace>"}}`. Also
130
+ `x-mockingbird-namespace` or a `/ns/<name>` prefix for raw HTTP callers. Ingest into a namespace
131
+ with `x-mockingbird-namespace` (the Resend mock forwards with its own namespace name).
132
+
133
+ ### Deliberately not modelled
134
+
135
+ - Real delivery: there is no SMTP listener. Mail arrives only through the ingest route,
136
+ `messages.create`, or another mock's `--forward-to-inbox`.
137
+ - Servers, usage, devices (TOTP), previews, spam/deliverability analysis, forward and reply,
138
+ and file downloads (attachment `url`s are placeholders; ingest keeps only attachment metadata).
139
+ - Mailosaur's exact code detector is not published. The mock's rule (standalone 4–8 digit runs,
140
+ not inside URLs) reproduces it for our templates (Cognito's "Your verification code is
141
+ {####}.").
142
+ - Server ids are not validated against an account; any 8-character id is an (empty) inbox.
143
+
144
+ ## API
145
+
146
+ | Export | Kind | Description |
147
+ | --- | --- | --- |
148
+ | `MailosaurAPI` | class | The in-process mock: `fetch(request)`, `reset()`, `ingest(input)`, `messages()`, `state`. Options: `sqlite`, `now`, `namespace`, `settings`. |
149
+ | `createRuntime` | function | The mock with the full service contract (health, admin, namespaces, credentials, presets, ingest). Options: `settings`, `clock`, `seed`, `adminKey`, `onLog`, `sqlite`. |
150
+ | `MAILOSAUR_PRESETS` | object | Every named fault preset. |
151
+ | `MAILOSAUR_NAMESPACE` | string | The service name, `"mailosaur"`. |
152
+ | `ANY_SERVER` | string | `"*"`: the server of mail ingested without one (visible from every server id). |
153
+ | `DEFAULT_AWAIT_TIMEOUT_MS`, `MAX_AWAIT_TIMEOUT_MS` | numbers | The `await` long-poll's default and maximum `timeout`. |
154
+ | `DEFAULT_SETTINGS` | object | `{pollDelaysMs: [20]}`. |
155
+ | `matchesCriteria` | function | Whether a message matches `{sentTo, sentFrom, subject, body, match}`. |
156
+ | `findCodes`, `htmlContent`, `textContent`, `parseAddresses` | functions | Mailosaur's parsing: codes, `{body, links, codes, images}` content, `Name <email>` / phone addresses. |
157
+ | `document`, `operationIds`, `supportedOperationIds` | values | The vendored OpenAPI contract and its operation ids. |
158
+ | `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. |
159
+ | `selfSignedCertificate`, `CERTIFICATE_HOSTS` (`./server`) | Node | Generate the in-memory certificate the door presents, and the hosts it names. |
160
+
161
+ Part of [mockingbird](https://github.com/crvouga/mockingbird).