Documentation menu
Guides
Test email in CI
Send an email from your app, wait for it to arrive, and assert on its subject, body and links. No sleeps, no polling loops.
How waiting works
GET /v1/inboxes/{id}/messages/wait returns the newest message that matches your filters, in full. If one already exists it answers at once; if not, it holds the connection open until one arrives. It starts listening before it first looks, so an email that lands in between is never missed.
If nothing arrives in time, you get 404 with the code wait_timeout. The default wait is 20 seconds and the most you can ask for is 30.
Match the right email
Tests run in parallel and inboxes keep old mail, so always narrow the match. Two filters do most of the work:
- A unique recipient. Generate an address per test (
signup-<uuid>@example.com) and filter withto=. This is the most reliable option: it does not depend on clocks or test order. - A start time.
since=2026-10-12T10:00:00Zignores anything received before it. Useful when you cannot choose the recipient, but it compares your clock with ours, so leave a few seconds of margin.
You can also filter by from and subject, or use q for a search expression such as q=is:unread has:attachment.
A helper for your test suite
Put the call in one place and reuse it. The result is the full message. The Node version works with Jest, Vitest and Playwright, the Python one with pytest, and the PHP one with PHPUnit and Laravel.
// test/email.ts
const API = process.env.SANDBOX_API_URL!; // https://app.mailbench.dev
const KEY = process.env.SANDBOX_API_KEY!;
const INBOX = process.env.SANDBOX_INBOX_ID!;
/** Waits for an email sent to `to` and returns it, with its text, HTML and links. */
export async function waitForEmail(to: string, timeoutSeconds = 20) {
const url = new URL(`/v1/inboxes/${INBOX}/messages/wait`, API);
url.searchParams.set("to", to);
url.searchParams.set("timeout", String(timeoutSeconds));
const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
const body = await res.json();
if (!res.ok) {
throw new Error(`No email for ${to}: ${body.error?.code} (${body.error?.message})`);
}
return body.data as {
id: string;
subject: string;
from: string;
text?: string;
html?: string;
links: string[];
};
}# tests/email_helpers.py
import os
import requests
API = os.environ["SANDBOX_API_URL"] # https://app.mailbench.dev
KEY = os.environ["SANDBOX_API_KEY"]
INBOX = os.environ["SANDBOX_INBOX_ID"]
def wait_for_email(to: str, timeout: int = 20) -> dict:
"""Waits for an email sent to `to` and returns it, with its text, HTML and links."""
res = requests.get(
f"{API}/v1/inboxes/{INBOX}/messages/wait",
params={"to": to, "timeout": timeout},
headers={"Authorization": f"Bearer {KEY}"},
timeout=timeout + 5,
)
body = res.json()
if not res.ok:
raise AssertionError(f"No email for {to}: {body['error']['code']} ({body['error']['message']})")
return body["data"]<?php
// tests/Support/WaitsForEmail.php
namespace Tests\Support;
use Illuminate\Support\Facades\Http;
trait WaitsForEmail
{
/** Waits for an email sent to $to and returns it, with its text, HTML and links. */
protected function waitForEmail(string $to, int $timeout = 20): array
{
$response = Http::withToken(env('SANDBOX_API_KEY'))
->timeout($timeout + 5)
->get(env('SANDBOX_API_URL') . '/v1/inboxes/' . env('SANDBOX_INBOX_ID') . '/messages/wait', [
'to' => $to,
'timeout' => $timeout,
]);
$this->assertTrue(
$response->successful(),
"No email for {$to}: " . $response->json('error.code') . ' (' . $response->json('error.message') . ')'
);
return $response->json('data');
}
}Use it in a test
import { test, expect } from "vitest";
import { waitForEmail } from "./email";
test("signing up sends a verification email", async () => {
const address = `signup-${crypto.randomUUID()}@example.com`;
await signUp({ email: address, password: "correct horse" }); // your code under test
const message = await waitForEmail(address);
expect(message.subject).toBe("Verify your email");
const link = message.links.find((l) => l.includes("/verify/"));
expect(link).toBeDefined();
await visit(link!); // follow it, assert the account is now active
});import uuid
from email_helpers import wait_for_email
def test_signing_up_sends_a_verification_email(client):
address = f"signup-{uuid.uuid4()}@example.com"
client.post("/signup", json={"email": address, "password": "correct horse"}) # your code under test
message = wait_for_email(address)
assert message["subject"] == "Verify your email"
link = next(l for l in message["links"] if "/verify/" in l)
assert client.get(link).status_code == 200<?php
use Tests\Support\WaitsForEmail;
use Tests\TestCase;
class SignupEmailTest extends TestCase
{
use WaitsForEmail;
public function test_signing_up_sends_a_verification_email(): void
{
$address = 'signup-' . uniqid() . '@example.com';
$this->post('/register', ['email' => $address, 'password' => 'correct horse']); // your code under test
$message = $this->waitForEmail($address);
$this->assertSame('Verify your email', $message['subject']);
$link = collect($message['links'])->first(fn ($l) => str_contains($l, '/verify/'));
$this->assertNotNull($link);
}
}The message has subject, from, recipients, text, html, links, headers and attachments. See Messages for every field.
Run it in GitHub Actions
Store the API key and the inbox's SMTP credentials as secrets, and the URLs and inbox ID as variables. Clearing the inbox first keeps runs independent; it needs a read_write key.
name: e2e
on: [push]
jobs:
e2e:
runs-on: ubuntu-latest
env:
SANDBOX_API_URL: ${{ vars.SANDBOX_API_URL }}
SANDBOX_API_KEY: ${{ secrets.SANDBOX_API_KEY }}
SANDBOX_INBOX_ID: ${{ vars.SANDBOX_INBOX_ID }}
# Your app's mailer, pointed at the inbox:
MAIL_HOST: ${{ vars.SANDBOX_SMTP_HOST }}
MAIL_PORT: 587
MAIL_USERNAME: ${{ secrets.SANDBOX_SMTP_USERNAME }}
MAIL_PASSWORD: ${{ secrets.SANDBOX_SMTP_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- name: Start from an empty inbox
run: |
curl -fsS -X DELETE "$SANDBOX_API_URL/v1/inboxes/$SANDBOX_INBOX_ID/messages" \
-H "Authorization: Bearer $SANDBOX_API_KEY"
- run: npm testMore things to check
- HTML rendering. HTML check reports CSS and tags that popular email clients do not support.
- Broken links. Link check requests every link in the email and reports its status.
- Failure handling. Chaos makes the inbox's SMTP server refuse senders or recipients on purpose, so you can test how your app copes.
- Visual tests.
GET /v1/inboxes/{id}/messages/latest/view.htmlreturns the latest email rendered as a page you can screenshot.
Keep in mind
- Each email counts toward your plan's monthly quota. See plan limits.
- Each
waitcounts as one request against your rate limit, however long it stays open. - Mail is deleted after your plan's retention period (7, 30 or 90 days).