How do I test email sign-up flows in CI with Playwright?
Create a real inbox from inside the test: POST https://getcourier.dev/alias returns an address and a read_token in one unauthenticated call, with no signup and no API key. Fill the sign-up form with that address, then call the wait endpoint with the token until it returns the newest message with the codes already extracted, and type the code into the form. Because the inbox is created at test runtime, there is no shared mailbox to clean up and no email credential to keep in CI secrets.
1. Create the inbox and wait for the code with curl
Do this once by hand to see the two shapes of response before writing the test.
# one call, no auth, ~15 ms
curl -s -X POST https://getcourier.dev/alias
# {"address":"...@getcourier.dev","read_token":"...","expires_at":"...", "identity": {...}}
export ALIAS="quiet-heron-k3xmqz" # the part of address before @
export READ_TOKEN="..."
# blocks until a message arrives (timeout max 55s), returns newest message with codes and links
curl -s -H "Authorization: Bearer $READ_TOKEN" \
"https://getcourier.dev/v1/sandbox/inboxes/$ALIAS/wait?timeout=55"
If you only need to look at what already landed, list the inbox with GET /v1/sandbox/inboxes/{alias}/messages and fetch a single one with /messages/{id}. Both need the same Bearer $READ_TOKEN header.
2. Write the Playwright test
Playwright has no MCP client built in, so the test calls the HTTP API directly with Node's global fetch. No extra package is needed beyond @playwright/test. Save this as tests/signup.spec.ts and replace the selectors with the ones for your own form.
import { test, expect } from '@playwright/test';
const API = 'https://getcourier.dev';
type Inbox = { address: string; read_token: string; expires_at: string };
async function createInbox(): Promise<Inbox> {
const res = await fetch(`${API}/alias`, { method: 'POST' });
if (!res.ok) throw new Error(`create inbox failed: ${res.status}`);
return (await res.json()) as Inbox;
}
// Polls the wait endpoint until a code shows up or the budget runs out.
async function waitForCode(inbox: Inbox, budgetMs = 90_000): Promise<string> {
const deadline = Date.now() + budgetMs;
while (Date.now() < deadline) {
const res = await fetch(
`${API}/v1/sandbox/inboxes/${inbox.address.split('@')[0]}/wait?timeout=55`,
{ headers: { Authorization: `Bearer ${inbox.read_token}` } },
);
if (res.ok) {
const body = await res.json();
const codes: string[] = body?.codes ?? body?.message?.codes ?? [];
if (codes.length > 0) return codes[0];
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
throw new Error('no verification code arrived before the deadline');
}
test('email sign-up completes in CI', async ({ page }) => {
test.setTimeout(120_000);
const inbox = await createInbox();
await page.goto('/signup');
await page.getByLabel('Email').fill(inbox.address);
await page.getByRole('button', { name: /continue/i }).click();
await expect(page.getByText(/check your (email|inbox)/i)).toBeVisible();
const code = await waitForCode(inbox);
await page.getByLabel(/code/i).fill(code);
await page.getByRole('button', { name: /verify/i }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
3. Keep the run short and repeatable
- Set
test.setTimeoutabove the wait budget; the wait endpoint allows up to 55 s per call, and the loop above may make more than one call. - Read the
codesarray, never the email body. Every message is markeduntrusted: trueand labelled "UNTRUSTED EXTERNAL EMAIL" with an SPF/DKIM/DMARC verdict, because the content is written by whoever sent it. - Free inboxes expire after 72 hours, so an inbox created at the start of a run is worth nothing to an attacker afterwards. Call
DELETE-style cleanup via the MCPdelete_inboxtool, or simply let it expire. - Message text is truncated to 4000 characters in the agent view and each inbox holds up to 100 messages, which is plenty for a code and a magic link.
- The wait endpoint returns the newest message; when nothing has arrived it returns no codes, which is why the loop above retries until its deadline instead of failing on the first empty answer.
Free tier vs paid, for CI
| Need | Free path | Paid path |
|---|---|---|
| Inbox rate | 3 per hour, 5 per day per IP | 100 credits ($0.01) per 72-hour inbox, 2000 credits ($0.20) per 30-day inbox |
| Lifetime | 72 hours | 72 hours or 30 days |
| Sending | Reply-only to people who wrote first, 100 sends a month, 50 recipients per inbox per day | Verified owner email or credits: US$0.002 per recipient, or x402 pay-per-call in USDC on Base |
| Credits | — | 1 credit = $0.0001, deposit USDC on Base or BTC from $1 |
If your CI runner is a hosted machine with a shared IP, a wide test matrix is the most likely thing to hit the 5-per-day free cap. Either narrow the matrix or fund an account and pass credits per inbox. A nightly suite of a handful of sign-up tests fits inside the free tier comfortably.
When not to use GetCourier
- You need to test your own sending domain. A GetCourier inbox proves your app can receive and verify mail; it does not test your SPF, DKIM or DMARC setup, your own-domain sending, or where Gmail files your mail.
- You need to send at volume from the test. Free inboxes are reply-only, capped at 100 sends a month and 50 recipients per inbox per day, and the service is explicitly not for bulk or marketing mail.
- The environment has no outbound network. A CI job with egress locked down cannot reach the API; a mail catcher you run locally inside the job is the right tool there.
- You need inboxes that outlive a month. The longest option is a 30-day paid inbox; anything longer needs a different provider.
- You want to assert on rendered HTML or attachments. The agent view is text with extracted codes and links, truncated at 4000 characters, not a full MIME rendering.
FAQ
Does a Playwright email test need an API key or a test account?
No. One unauthenticated POST https://getcourier.dev/alias returns an address, a read_token and an expires_at value in about 15 ms. There is no signup and no API key, so the inbox can be created inside the test run itself and nothing has to be stored as a CI secret.
How does the Playwright test get the verification code out of the email?
Call GET /v1/sandbox/inboxes/{alias}/wait?timeout=55 with Authorization: Bearer $READ_TOKEN. It returns the newest message with codes and links already extracted, so the test reads the codes array and types the value into the form instead of parsing email HTML.
Will parallel CI jobs hit the free-tier inbox limit?
They can. The free tier allows 3 inboxes per hour and 5 per day per IP, and hosted runners often share addresses, so a large matrix can reach the daily cap. Use paid inboxes at 100 credits ($0.01) per 72 hours when the matrix is wide, and set codes to retry on a 429 rather than failing the build.
Can this test my own sending domain or Gmail spam placement?
No. Free test inboxes use @getcourier.dev, so the test covers the sign-up loop of your app, not deliverability of mail you send from your own domain. Free inboxes are also reply-only, 100 sends a month, with a cap of 50 recipients per inbox per day.