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 }
Fields accepted when waiting for a message
FieldAccepts
timeoutSeconds, 1–120, default 30
sinceISO 8601 timestamp. Only mail received at or after it counts; default: the moment the call started
subject_contains, from_contains, to_containsCase-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.

Selector rules: which message is picked
RuleSelects a message where…
toA recipient address contains this (case-insensitive)
fromThe sender address contains this
subject_containsThe subject contains this
subject_matchesThe subject matches this regular expression

Checks — whether it passes

Check rules: what makes the expectation pass
RuleFails unless…
text_containsArray of strings the plain-text body must contain (falls back to the HTML stripped of tags)
html_containsArray of strings the HTML body must contain
has_link_matchingA regular expression at least one link must match. The first match comes back as extracted.matched_link
otp_codetrue: one clearly identifiable one-time code must be present. It comes back as extracted.code
no_unrendered_variablestrue: no {{ name }}, {% … %}, ${…}, %name%, [[…]] or a literal undefined / NaN anywhere in subject or body
attachment_countExactly this many attachments
deliverability_at_leastInteger 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.