Wait & expect
Mail takes a second or two to arrive. These two calls block for it, so a test never polls an empty inbox — and expect turns the arrival into a verdict.
Wait for a message
POST /api/v1/inboxes/{inbox_id}/wait Scope: emails:read { "timeout": 30, "subject_contains": "password", "to_contains": "user@example.com" } Response, 200: { "matched": true, "waited_seconds": 2, "email": { "id": "e9v2h6r4ldq1a7c3", "subject": "Reset your password", "from_name": "Acme", "from_email": "noreply@acme.dev", "received_at": "2026-09-02T09:14:22.000000Z" } } Nothing in time, still 200: { "matched": false, "waited_seconds": 30, "other_messages_since": 1 }
| Field | Accepts |
|---|---|
timeout | Seconds, 1–120, default 30 |
since | ISO 8601 timestamp. Only mail received at or after it counts; default: the moment the call started |
subject_contains, from_contains, to_contains | Case-insensitive substring filters. All given filters must match |
matched: false is a result, not an error: the send may simply have failed, which is itself what the test wanted to know. other_messages_since tells the two cases apart — mail arrived but your filter did not match it, versus nothing arrived at all.
By default only mail arriving after the call started counts. Returning something that was already there would make a broken send look like a working one. When the send happens before the wait — a job that triggers the mail, then calls — pass since set to the moment the run started.
Expect: a verdict, not data
POST /api/v1/inboxes/{inbox_id}/expect Scope: emails:read (and analysis:run for deliverability_at_least) { "timeout": 30, "expect": { "to": "user@example.com", "subject_matches": "^Reset your password$", "html_contains": ["Reset password"], "has_link_matching": "^https://app\\.acme\\.dev/reset/[a-z0-9]+$", "otp_code": true, "no_unrendered_variables": true, "deliverability_at_least": 80 } } Response, 200: { "ok": true, "waited_seconds": 2, "email": { "id": "e9v2h6r4ldq1a7c3", "subject": "Reset your password", "from_email": "noreply@acme.dev", "received_at": "…" }, "extracted": { "links": ["https://app.acme.dev/reset/8f3k2q", "https://acme.dev/help"], "code": "482913", "matched_link": "https://app.acme.dev/reset/8f3k2q" }, "failures": [] } Response, 422: { "ok": false, "waited_seconds": 2, "email": { … }, "extracted": { … }, "failures": [ { "rule": "has_link_matching", "expected": "^https://app\\.acme\\.dev/reset/[a-z0-9]+$", "actual": ["http://localhost:3000/reset/8f3k2q"] }, { "rule": "no_unrendered_variables", "expected": [], "actual": ["{{ first_name }}"] } ] }
One call: it waits for the message the selectors describe, runs the checks on it, and answers 200 with ok: true or 422 with ok: false. That is the whole point — a pipeline step becomes curl --fail, and any SDK is a thin wrapper rather than a reimplementation of the assertions in five languages.
The failures list is what you read in a CI log at seven on a Friday evening, so every entry says what was wanted and what was there. extracted comes back in both cases: the reset link and the one-time code from the same call that asserted the mail arrived, so the next step of the test can follow them.
Selectors — which message
The selectors decide which message the expectation is about; the first arriving message that matches all of them is the one checked. A second, unrelated message in the inbox — a welcome mail beside the reset — does not fail the run.
| Rule | Selects a message where… |
|---|---|
to | A recipient address contains this (case-insensitive) |
from | The sender address contains this |
subject_contains | The subject contains this |
subject_matches | The subject matches this regular expression |
Checks — whether it passes
| Rule | Fails unless… |
|---|---|
text_contains | Array of strings the plain-text body must contain (falls back to the HTML stripped of tags) |
html_contains | Array of strings the HTML body must contain |
has_link_matching | A regular expression at least one link must match. The first match comes back as extracted.matched_link |
otp_code | true: one clearly identifiable one-time code must be present. It comes back as extracted.code |
no_unrendered_variables | true: no {{ name }}, {% … %}, ${…}, %name%, [[…]] or a literal undefined / NaN anywhere in subject or body |
attachment_count | Exactly this many attachments |
deliverability_at_least | Integer 0–100. Needs the analysis:run scope; the failure lists the factors that dragged the score down |
When no message matching the selectors arrives before timeout, the response is 422 with a single failure whose rule is arrival, and how many other messages arrived meanwhile. An unknown rule name is a 422 validation error, so a typo in the vocabulary cannot pass silently. Regular expressions follow PCRE and need no delimiters; escape backslashes for JSON.
The vocabulary is small on purpose. Every rule added is a rule supported forever; if a check you need is missing, tell us what you are asserting.
Capacity
A blocking call holds a server worker for its whole duration, so both calls are bounded: at most two running at once per inbox, and 120 seconds each. A third answers 429 with Retry-After: 5. Two minutes is far longer than any real send; if you find yourself waiting longer, the send is the problem. The bounds are the same ones the MCP server applies, and the reasoning is in Conventions.