fabricatedemail HTTP API
Disposable email addresses for automated tests. You register an address, your system under test sends mail to it, and you poll this API for the message — typically about a second after SMTP delivery. No inbound network access is needed on your side, so this works from CI runners without tunnels or relays.
This document is the whole contract. Everything a consuming project needs is here; nothing else in the repository is required reading.
What you need from the operator before you start:
| Base URL | https://api.fabricatedemail.com |
| Mail domain | fabricatedemail.com — addresses are local-part@fabricatedemail.com, not @api.fabricatedemail.com |
| API key | 64 hex characters, issued once by the operator (see Admin) |
Stability policy. Additive changes ship at any time: new endpoints, new
optional fields, new status codes on existing endpoints. Write clients that
ignore fields they do not recognise and that branch on the status codes they
know. A change that removes or renames a field, changes what an existing
status means, or otherwise breaks a working client gets a new path prefix —
/v2/ — and the prefix it replaces keeps working for six months.
Subscription and key lists are unpaginated (bounded by the caps below); a
message list returns at most 50 messages per response — page with the
after cursor (see Messages).
Quick start
const BASE = "https://api.fabricatedemail.com";
const KEY = process.env.TEMPMAIL_KEY!;
const h = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
// 1. A per-run random address on the mail domain: 8 random bytes = 64 bits.
// (Not sliced from a UUID — its fixed version nibble would cut this to 60.)
const rand = Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join("");
const address = `myapp-${rand}@fabricatedemail.com`;
// 2. Register it. 201 means the mailbox is live *now* — mail sent after this
// response returns is guaranteed to be visible to the poll below.
const created = await fetch(`${BASE}/subscriptions`, {
method: "POST",
headers: h,
body: JSON.stringify({ address, ttlSeconds: 900 }),
}).then((r) => r.json());
// 3. Trigger whatever sends the mail.
await signUp(address);
// 4. Long-poll for it: returns as soon as mail lands, or after 30 s empty.
const { messages } = await fetch(
`${BASE}/subscriptions/${created.id}/messages?wait=30`,
{ headers: h },
).then((r) => r.json());
const code = /\b(\d{6})\b/.exec(messages[0].text ?? "")?.[1];
// 5. Optional — free the address early. It expires on its own TTL anyway.
await fetch(`${BASE}/subscriptions/${created.id}`, { method: "DELETE", headers: h });
Copy-paste recipes for the common assertions are in Assertion recipes.
Authentication
Every endpoint except GET /health requires:
Authorization: Bearer <api-key>
The scheme is matched case-insensitively. A missing header, a malformed
credential, an unknown key, and a revoked key are all
401 {"error": "unauthorized"} and indistinguishable from outside.
A key sees only its own subscriptions and messages. Another key's subscription
id is a 404 to you, not a 403; the one thing 403 means is that the key's
account is suspended.
Keys are issued by the operator with the admin endpoints and are shown exactly once at creation — only a SHA-256 hash is stored, so a lost key cannot be recovered, only replaced. Store yours as a CI secret.
General contract
- Errors. Every error response has body
{"error": "<message>"}. The message is human-facing and may change; branch on the status code, never on the string. - Check order. Every request is decided in this order: authentication
(
401) → account state (403) → rate limit (429) → request validation (400) → existence/ownership (404) → cross-key conflict (409) → address cap (429). The first three are the account's and run on every key route; the rest are the route's own. A request that is wrong in two ways gets the earlier status: an unauthenticated request with a malformed body is401, and?after=abcon a subscription that does not exist is400. - Unknown routes and methods are
404 {"error": "Not found"}, including after successful authentication. - Timestamps — every timestamp the API returns is exactly the format of
JavaScript
Date.prototype.toISOString(): millisecond precision, trailingZ, e.g.2026-08-10T12:00:00.000Z. - Request bodies must be JSON objects. A malformed body, a JSON array, or
a bare JSON string is
400. Unknown fields are ignored. - Consistency. Register first, then trigger the mail. A
201fromPOST /subscriptionsmeans the mailbox is committed and visible to the mail handler, and a stored message is visible to your very next poll — there is no replication lag to design around.
Status codes
| Status | When |
|---|---|
200 |
Successful read |
201 |
Subscription or key created |
204 |
Deleted (no body) |
400 |
Malformed address, a reserved address local part, ttlSeconds out of range or wrong type, non-integer after, negative or non-integer wait, body that is not a JSON object |
401 |
Missing, malformed, unknown or revoked key |
403 |
The key's account is suspended. Every key of the account sees it, on every endpoint |
404 |
Unknown route; unknown, expired, deleted, replaced or foreign-owned subscription; absent message id |
409 |
The address has a live subscription owned by another key |
429 (address cap) |
The account is at its live-address cap, or at the technical bound. No Retry-After — waiting frees nothing |
429 (rate limit) |
The key passed 600 requests in a minute. Carries Retry-After: 60 |
500 |
Server-side failure; logged by the service with the real cause. Retry |
Addresses
You compose the address; the service does not generate it.
- Grammar.
local-part@fabricatedemail.com, where the local part is 1–64 characters of[a-z0-9._+-]. Exactly one@. The domain must be the service mail domain exactly — subdomains such asx@sub.fabricatedemail.comare rejected. - Case. Addresses are compared and stored lowercased.
Foo@fabricatedemail.comis registered and returned asfoo@fabricatedemail.com, and mail addressed to either reaches the same mailbox. - Reserved local parts. These are the service's own and cannot be
registered:
abuse,postmaster,security,support,admin,noreply,no-reply,hostmaster,webmaster,info,billing,legal,dmarc. Mail to the ones that must receive is forwarded to a real inbox. Asking for one is400 {"error": "address local part is reserved"}. - Anything else is
400: a missing@, spaces, characters outside the grammar, an empty or 65-character local part, another domain.
Randomness is mandatory
Use a per-run random address of at least 64 bits of entropy — for example 16 hex characters from 8 random bytes:
myapp-4f2a9c81d3e6b7a0@fabricatedemail.com. (16 characters sliced from a UUID's hex are not 64 bits — the fixed version nibble leaves 60.)
Two reasons, both of which will bite you otherwise:
- Isolation. Registering an address you already hold replaces the old subscription and deletes its messages (see Re-registration). Two jobs sharing a deterministic address destroy each other's subscriptions and can read each other's mail.
- Security. The address is the only thing an outside sender needs in order to place content into your mailbox. Randomness is the boundary.
A single, permanently registered address for interactive or manual use is fine; anything that can run twice concurrently needs a fresh random address per run.
Subscriptions
A subscription is a claim on an address: "I will read mail sent here." It lives for its TTL, or until you delete it if it is permanent.
POST /subscriptions
Register an address.
POST /subscriptions
Authorization: Bearer <api-key>
Content-Type: application/json
{"address": "myapp-4f2a9c81d3e6b7a0@fabricatedemail.com", "ttlSeconds": 900}
201
{
"id": "3f7c1c0e-1e5b-4b3f-9a2a-2f6f6d4b1c77",
"address": "myapp-4f2a9c81d3e6b7a0@fabricatedemail.com",
"createdAt": "2026-08-10T12:00:00.000Z",
"expiresAt": "2026-08-10T12:15:00.000Z"
}
ttlSeconds:
| Value | Meaning |
|---|---|
| omitted | Default TTL, 1 hour |
| integer 60 – 86400 | That many seconds (60 s to 24 h) |
null (explicit JSON null) |
Permanent — never expires, expiresAt is null, lives until DELETE |
| anything else — a non-integer, a numeric string, a boolean, an out-of-range integer | 400 |
Failure modes:
| Status | Cause |
|---|---|
400 |
Address outside the grammar or off the service domain; a reserved local part; address missing or not a string; ttlSeconds invalid; body not a JSON object |
403 |
The account is suspended |
409 |
Another key holds a live subscription for this address — including a permanent one, which stays live until its owner deletes it |
429 (address cap) |
The account already holds as many live addresses as its tier allows, or 10 000, whichever is lower. No Retry-After: waiting frees nothing, and the fix is to delete an address or move up a tier |
429 (rate limit) |
The key passed 600 requests in a minute. Retry-After: 60 |
An expired foreign subscription never causes a 409: the registration
clears expired rows for the address as part of the write, so an address whose
previous holder let it lapse is immediately reusable.
If two clients register the same free address at the same instant, exactly one
wins; the other gets 409.
Re-registration replaces, and is terminal for the old id
Registering an address your own key already holds — live or expired —
deletes the old subscription and its stored messages and returns 201 with a
new id and a new expiry. Consequences you must design around:
- A
404on a subscription id you were using means expired, deleted, or replaced. These are indistinguishable, and the handle is dead for good. Get a new one by registering again. - Overlapping jobs on the same deterministic address destroy each other's mailboxes. Use per-run random addresses.
- A reused deterministic address can receive late mail triggered by an earlier run, so "exactly one email" assertions on reused addresses are unsound.
Retrying a create
Retrying POST /subscriptions after a client-side timeout is safe, with one
rare exception: if the original request commits after your retry did, the
retry's subscription is the one that gets replaced, and its id turns 404.
The remedy is part of this contract: if a poll on a just-created
subscription returns 404, re-create the subscription once and continue with
the new id.
Permanent mailboxes
{"address": "...", "ttlSeconds": null} registers a permanent subscription:
expiresAtisnullin this and every later response for it.- It never expires and is never garbage-collected.
DELETEis the only thing that ends it. - It still counts towards the account's live-address cap, for as long as it exists.
- Another key registering the same address still gets
409. - Its messages still expire on the normal 24-hour retention. A permanent mailbox holds at most the last day of mail; it does not accumulate.
Use one for a long-lived shared inbox (a manual QA address, a staging notification sink). Do not use one as the address for parallel test runs — the replacement semantics above still apply.
GET /subscriptions
Lists the calling key's live subscriptions, oldest first. Expired subscriptions never appear, whether or not the hourly cleanup has collected them yet.
200
{
"subscriptions": [
{
"id": "3f7c1c0e-1e5b-4b3f-9a2a-2f6f6d4b1c77",
"address": "myapp-4f2a9c81d3e6b7a0@fabricatedemail.com",
"createdAt": "2026-08-10T12:00:00.000Z",
"expiresAt": "2026-08-10T12:15:00.000Z"
}
]
}
Unpaginated: the account's live-address cap bounds it, and the technical bound
of 10 000 live addresses bounds it whatever the cap says. expiresAt is null
for permanent subscriptions.
DELETE /subscriptions/{sid}
Ends a subscription early and deletes its stored messages in the same transaction.
204on success, with no body.404for an unknown, expired, already-deleted, or foreign-owned id.- This is the only way a permanent subscription ever ends.
Deleting is optional hygiene — a TTL'd subscription disappears on its own — but it frees the address for immediate reuse by another key.
Messages
GET /subscriptions/{sid}/messages?after=<id>&wait=<seconds>
Returns the stored messages for the subscription, oldest first.
200
{"messages": [ /* message objects, see below */ ]}
| Parameter | Type | Default | Behavior |
|---|---|---|---|
after |
integer | 0 |
Return only messages whose id is greater than this. Non-integer (including 1.5, 1e3, +1, an empty value): 400 |
wait |
integer seconds | 0 |
Long-poll. 0 returns immediately. Values above 30 are clamped to 30, not rejected. Negative or non-integer: 400 |
- At most 50 messages per response, oldest first. A response holding
exactly 50 may have more behind it: advance
afterto the lastidyou received and request again — the same carry-over every consumer loop below already does. (An OTP wait never notices the bound; it exists so a mailbox spammed toward the 1,000-message cap stays servable.) afterand the single-message path parameter are always the integeridfield — never the RFC 5322messageId. Ids start at 1, so an omittedafterreturns everything currently stored.idis monotonically increasing but not contiguous within a mailbox: it is assigned service-wide, so your mailbox may see ids 7, 12, 13. Never compute the next cursor — always carry over theidof the last message you handled.- With
wait, the service re-checks about once per second and returns as soon as at least one message matches. On timeout it returns200 {"messages": []}— an empty list is a normal, non-error result. - The key and the subscription are validated once, at request start. A wait
can therefore outlive the subscription's expiry, deletion or replacement; the
call then times out with an empty list, and the next request returns
404. 404for an unknown, expired, deleted, replaced or foreign-ownedsid. Parameter validation happens first, so a badafteron a nonexistent subscription is400.
The poll loop
let after = 0;
for (;;) {
const r = await fetch(
`${BASE}/subscriptions/${sid}/messages?after=${after}&wait=30`,
{ headers: { Authorization: `Bearer ${KEY}` } },
);
if (r.status === 404) break; // expired, deleted or replaced — terminal
const { messages } = await r.json();
for (const m of messages) { handle(m); after = m.id; }
}
A well-behaved 30-second loop costs about two requests per minute. Do not poll
in a tight loop with wait=0: the request quota is shared account-wide.
GET /subscriptions/{sid}/messages/{id}
Returns one message object, not wrapped in a list. {id} is the integer
message id.
404if the message does not exist, belongs to a different subscription, or the path segment is not an integer.404if the parent subscription is missing or expired — the parent's liveness governs, not the message's own retention.- A message can therefore disappear from a live mailbox once its 24-hour retention has passed and the hourly cleanup has collected it.
GET /subscriptions/{sid}/messages/{id}/extract
Returns the one-time codes and the links found in one message, so you do not have to write the regex yourself.
200
{
"codes": ["482913"],
"links": ["https://example.com/verify?t=8f14e45fceea167a"]
}
Authentication and every 404 are the single-message read's above: the same
key, the same parent-liveness rule, the same 404 for a path segment that is
not an integer.
The rules are heuristic and deliberately simple. They are written out here so you can predict them, and they can change — a change that finds more is additive under the stability policy. Assert on the code or the link you expected; never assert that a list is empty.
codesare runs of 4 to 8 digits that sit within 40 characters of one of the whole wordscode,OTP,PIN,verification,verifyorpasscode, matched case-insensitively, on either side of the digits. In order of appearance, without repeats. They are read from thetextbody, or from thehtmlbody with its tags stripped when there is no text body or it is empty.linksare everyhttp://andhttps://URL in thetextandhtmlbodies, text first, in order of appearance and without repeats. Trailing sentence punctuation is not part of the URL, and&in an HTML link is read back as&.
A message with neither answers {"codes": [], "links": []}: an empty result is
200, never 404. A headers-only row has
no bodies to read and always answers two empty lists.
Message shape
{
"id": 42,
"messageId": "<abc@mail.example.com>",
"from": "noreply@example.com",
"to": "myapp-4f2a9c81d3e6b7a0@fabricatedemail.com",
"subject": "Verify your account",
"text": "Your code is 123456",
"html": "<p>Your code is <b>123456</b></p>",
"headers": [["from", "Example <noreply@example.com>"], ["subject", "Verify your account"]],
"attachments": [{"filename": "invoice.pdf", "mimeType": "application/pdf", "size": 31337}],
"truncated": false,
"rawSize": 4096,
"receivedAt": "2026-08-10T12:00:00.000Z"
}
| Field | Type | Notes |
|---|---|---|
id |
integer | Service-assigned, monotonic. The after cursor and the single-message path parameter |
messageId |
string | null | The RFC 5322 Message-ID header, null when the sender did not set one. Display and debug data — never an API parameter |
from |
string | null | The parsed From: header address. On headers-only rows (see below) it is the SMTP envelope sender instead; the two can legitimately differ |
to |
string | Always the SMTP envelope recipient (RCPT TO) — the address that actually routed here, so a spoofed To: header cannot affect it. May differ from the to entry in headers |
subject |
string | null | |
text |
string | null | Plain-text body, capped at 256 KiB. null on headers-only rows and when the mail had no text part |
html |
string | null | HTML body, capped at 256 KiB. Same nullability rule |
headers |
[name, value][] |
Ordered pairs, names lowercased. See the note below |
attachments |
object[] | {filename, mimeType, size} — metadata only; content and raw MIME are not retrievable |
truncated |
boolean | true if anything was cut, or if the mail was stored headers-only |
rawSize |
integer | Size in bytes of the raw message as received |
receivedAt |
string | When the service received it |
About headers. The list carries every header of the message, with names
lowercased, in the platform's canonical (sorted) order, and repeated headers —
Received, most often — joined into a single value separated by ", ". The
one exception is set-cookie (crafted mail only): Fetch Headers iteration
special-cases it, so repeats appear as separate pairs rather than one joined
value. Look headers up by name; do not rely on their position, and expect a
joined value where a header legitimately repeats:
const auth = message.headers.find(([n]) => n === "authentication-results")?.[1];
Nothing about expiry appears on a message: receivedAt plus the retention rule
below is the whole contract.
Truncation and headers-only rows
text,html, and the serializedheadersare each capped at 256 KiB. If anything was cut,truncatedistrue(textandhtmlare cut at a character boundary;headersloses whole pairs from the end).- A message whose raw size exceeds 1 MiB is stored headers-only: the body
is never parsed, so
textandhtmlarenull,attachmentsis[],fromis the envelope sender, andtruncatedistrue. This is a CPU guard, not a failure — the message is stored and readable.
Gate every body assertion on
truncated. If you assert ontextorhtml, checktruncated === falsefirst, or a large mail will fail your test with a confusingnull.
Retention
- A stored message lives 24 hours from receipt, capped by its subscription's own expiry when the subscription has one. A subscription that expires in 15 minutes takes its mail with it; a permanent mailbox keeps each message for its full 24 hours.
- Expired rows are removed by an hourly cleanup. A message past its retention may therefore still be readable for up to an hour — do not treat "still there" as a guarantee, and do not treat "gone" as an error.
Duplicates
- Mail carrying an RFC 5322
Message-ID— which is all mail from real providers — is collapsed server-side across SMTP redeliveries into one stored message. "Exactly one email arrived" assertions are sound for such mail. - Mail without a
Message-IDis deduplicated best-effort on a fallback key over the sender-originatedFrom,To,SubjectandDateheaders. Two genuinely distinct messages that agree on all four collapse into one row, and nothing is guaranteed. If your system under test sends without aMessage-ID, do not assert on exact counts.
Where a message came from
Anyone who learns an address can put mail in its mailbox. A test asserting
origin must check from — and, where it matters, the stored
authentication-results header — rather than mere presence.
MCP
A Model Context Protocol server on the same host, so an AI coding agent can register an address and read the mail itself instead of being told how to call this API.
POST /mcp
Streamable HTTP transport at https://api.fabricatedemail.com/mcp. It takes
the same Authorization: Bearer <api-key> as every other endpoint, and the
same key rules, suspension 403 and rate limit apply. Send Accept: application/json, text/event-stream and Content-Type: application/json, as
the protocol requires. The server holds no session: each request is answered
with a single JSON response.
Configuring a client is the endpoint and the key:
{
"mcpServers": {
"tempmail": {
"type": "http",
"url": "https://api.fabricatedemail.com/mcp",
"headers": { "Authorization": "Bearer <api-key>" }
}
}
}
Tools, each a thin call to the endpoint beside it:
| Tool | Arguments | Endpoint |
|---|---|---|
create_address |
localPart (optional), ttlSeconds (optional, null for permanent) |
POST /subscriptions. Without localPart the tool generates a random one of 64 bits |
list_addresses |
— | GET /subscriptions |
delete_address |
subscriptionId |
DELETE /subscriptions/{sid} |
wait_for_message |
subscriptionId, after (optional), waitSeconds (optional, at most 30, default 30) |
GET /subscriptions/{sid}/messages |
get_message |
subscriptionId, messageId |
GET /subscriptions/{sid}/messages/{id} |
extract |
subscriptionId, messageId |
GET /subscriptions/{sid}/messages/{id}/extract |
A tool answers with its endpoint's JSON response as text. delete_address,
whose endpoint has no body, answers {"deleted": true}. A status the endpoint
refuses with — a 404 on a mailbox that is gone, a 409 on an address another
key holds — comes back as a tool error carrying
{"status": 404, "error": "Not found"}, so the agent is told exactly what a
client would be told.
The tools do what the endpoints do and nothing more: the same quotas, the same statuses, and the same warnings about randomness and re-registration.
Health
GET /health
Unauthenticated liveness check; runs a trivial query through the database and exposes no data.
200 {"ok": true}500if the database binding is unavailable. Treat the status as the contract; the body is a generic error object.
Admin (operator-only)
These endpoints require the operator's ADMIN_TOKEN, not a client key:
Authorization: Bearer <admin-token>. A client key presented here is 401.
Consumers never call them; they are documented so the operator has one place to
look. The runbook that uses them is SETUP.md.
POST /admin/keys
Body {"name": "norna-ci"} — any non-empty string, stored trimmed.
201
{"id": "…", "name": "norna-ci", "key": "<64 hex chars>", "createdAt": "2026-08-10T12:00:00.000Z"}
key is returned exactly once and never again: only its SHA-256 is stored.
A missing, blank or non-string name is 400.
GET /admin/keys
200
{"keys": [{"id": "…", "name": "norna-ci", "createdAt": "…", "revokedAt": null}]}
Every key ever minted, oldest first; revoked keys stay listed with their
revokedAt. The key value itself is never returned.
DELETE /admin/keys/{id}
Revokes the key and deletes all of its subscriptions and messages in one
transaction. 204; unknown id: 404. The key fails authentication from the
next request on. Revoking an already-revoked key is 204 and keeps the
original revocation time. A key created from the customer dashboard is the same
kind of key and behaves exactly the same way when it is revoked, from either
side.
Limits and caps
| Limit | Value | What happens at the limit |
|---|---|---|
| Address local part | 1–64 chars of [a-z0-9._+-] |
400 |
| Address randomness | ≥ 64 bits, required for jobs that can overlap | Not enforced; a collision destroys the other job's mailbox |
ttlSeconds |
60 s – 24 h, default 1 h, or null for permanent |
400 |
| Live addresses per account | Free 1, Pro 25, Premium unlimited — counted across every key of the account, not per key | 429 with no Retry-After. Approximate under concurrent registrations — it bounds runaway loops, it is not an exact quota. Re-registering an address you already hold still succeeds at the cap |
| Live addresses, technical bound | 10 000 per account | 429 with no Retry-After, whatever the tier allows. A bound on the service, not a tier feature |
| Requests per key | 600 per minute | 429 with Retry-After: 60. A 30-second poll loop costs about two requests a minute |
| Messages stored per account per month | Free 60, Pro 25 000, Premium unlimited — the UTC calendar month | Further mail is silently dropped, with no status anywhere in the API. Approximate under concurrent deliveries. An upgrade does not reset the count, and there is no reset other than the next month |
| Stored messages per subscription | 1,000 | Further mail is silently dropped — no error anywhere in the API. Only a server log line records it |
wait |
0–30 s | Values above 30 are clamped to 30 |
text, html, serialized headers |
256 KiB each | Cut, truncated: true |
| Raw message size for body parsing | 1 MiB | Above it, stored headers-only with truncated: true |
| Inbound message size | 25 MiB | Rejected by the mail platform before the service sees it |
| Message retention | 24 h from receipt, capped by the subscription's expiry | Removed by the hourly cleanup |
| Poll latency | ~1 s after SMTP delivery |
Why mail might not arrive
The failure modes below are the ones that matter when a test goes red. Most of them are silent by design — dropping mail is deliberate, because rejecting it would bounce every misdirected test mail back at the sending provider's reputation.
- The sender is not authenticated. Cloudflare rejects inbound mail that
fails both SPF and DKIM, mail failing the sender domain's DMARC policy, and
mail from blocklisted IPs — before this service runs, non-configurably.
Unauthenticated dev or staging SMTP vanishes without a trace here.
Requirement: test mail must come from a properly configured sender (SES, SendGrid, Postmark, … with DNS set up). Verify this once, explicitly, before relying on any negative assertion.
- No live subscription for the address. The mail is dropped silently: no bounce, no rejection, nothing readable through the API. Expired, deleted, or replaced subscriptions all look like this. Check that you registered the exact address, lowercased, on the mail domain.
- The account is over its monthly message cap. Mail is dropped silently for the rest of the UTC calendar month, for every address of the account. Nothing in the API says so; the dashboard shows the month's count, and a warning is mailed at 80% and again at 100%.
- The account is suspended. Mail is dropped silently while it is, and
every request answers
403. Reinstatement restores everything as it was, except the mail that arrived meanwhile, which is gone. - The mailbox holds 1,000 messages. Further mail is dropped silently.
- The message is over 25 MiB. Rejected by the platform at SMTP receipt.
- The subscription was deleted or replaced while the mail was being stored. Dropped; the handle it targeted no longer exists.
- A database failure during storage. Storage is best-effort to the extent the database is available: such a message may be lost. Re-running the test regenerates it. The service claims no delivery guarantee stronger than this.
- Retention. Mail older than 24 hours is gone, and mail belonging to an expired subscription went with it.
When a negative assertion is load-bearing — or when a message you expected never showed up — ask the operator to check Workers Logs and the zone's Email Routing activity log. Platform-edge rejections (case 1) appear only in the latter. Log retention is three days, so investigate promptly.
The service cannot send mail. A test that needs to send mail into a system under test uses its own provider.
Assertion recipes
All snippets assume the BASE, KEY and h bindings from
Quick start.
const api = (path: string, init?: RequestInit) =>
fetch(`${BASE}${path}`, { ...init, headers: { ...h, ...init?.headers } });
Wait for a one-time code
The common case. Long-poll, don't sleep-and-check. The regex below is the one
GET .../extract exists to save you
writing; use it instead when its rules fit your mail.
async function waitForCode(sid: string, timeoutMs = 60_000): Promise<string> {
const deadline = Date.now() + timeoutMs;
let after = 0;
while (Date.now() < deadline) {
const r = await api(`/subscriptions/${sid}/messages?after=${after}&wait=30`);
if (r.status === 404) throw new Error("subscription is gone — re-create it");
const { messages } = await r.json();
for (const m of messages) {
after = m.id;
if (m.truncated) continue; // body not usable
const code = /\b(\d{6})\b/.exec(m.text ?? "")?.[1];
if (code) return code;
}
}
throw new Error("no code within the timeout");
}
Exactly one email
Sound only for mail carrying a Message-ID (all real providers) on a
freshly registered random address. On a reused address, late mail from an
earlier run makes this assertion meaningless.
await triggerSignup(address);
// Long-poll until the first message lands.
const { messages: arrived } = await api(`/subscriptions/${sid}/messages?wait=30`)
.then((r) => r.json());
expect(arrived).not.toHaveLength(0);
await new Promise((r) => setTimeout(r, 10_000)); // quiet period
// Re-read the whole mailbox: redeliveries collapse, so a second row means a
// genuinely second message.
const { messages } = await api(`/subscriptions/${sid}/messages`).then((r) => r.json());
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe(arrived[0].id);
No email was sent
Poll for the expected quiet period and assert the list stays empty.
const { messages } = await api(`/subscriptions/${sid}/messages?wait=30`).then((r) => r.json());
expect(messages).toEqual([]);
Sound up to the loss modes above: a silent drop is indistinguishable from "nothing was sent". Before this assertion carries weight, prove once that a positive mail from the same sender does arrive — otherwise you are asserting that your SPF/DKIM setup is broken.
The mail came from the right place
const [m] = messages;
expect(m.from).toBe("noreply@your-product.example");
const authResults = m.headers.find(([n]) => n === "authentication-results")?.[1] ?? "";
expect(authResults).toMatch(/spf=pass/);
Parallel jobs
One random address per run, registered by that run, deleted (or simply left to expire) by that run:
const rand = Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join("");
const address = `ci-${rand}@fabricatedemail.com`;
const { id: sid } = await api("/subscriptions", {
method: "POST",
body: JSON.stringify({ address, ttlSeconds: 900 }),
}).then((r) => r.json());
try {
/* … the test … */
} finally {
await api(`/subscriptions/${sid}`, { method: "DELETE" });
}
Never share an address between jobs that can overlap: the second registration deletes the first job's mailbox and its messages.
Handling the create-retry edge case
async function pollOrRecreate(sid: string, address: string) {
const r = await api(`/subscriptions/${sid}/messages?wait=30`);
if (r.status !== 404) return { sid, body: await r.json() };
// The one documented case: a create retry whose original committed later.
const recreated = await api("/subscriptions", {
method: "POST",
body: JSON.stringify({ address }),
}).then((x) => x.json());
return { sid: recreated.id, body: { messages: [] } };
}
Re-create once. A second 404 is a real expiry or deletion, not this race.
Checklist for a new consumer
- Key stored as a CI secret; base URL and mail domain configured.
- Addresses generated per run, ≥ 64 bits of randomness, on the mail domain.
- TTL chosen for the job's realistic duration (60 s – 24 h; default 1 h).
- Polling uses
wait=30with anaftercursor carried fromm.id. -
404on a subscription treated as terminal (re-create once if the subscription was just created). - Body assertions gated on
truncated === false. -
messageIdnever used as a cursor or path parameter. - Sending provider verified to pass SPF/DKIM before any negative assertion is trusted.