A Playwright email fixture should give each test attempt its own inbox, expose a bounded way to read the expected message, and remove the inbox when the test finishes. Put that lifecycle around await use(inbox) so a failed browser assertion still reaches teardown.
If you have copied inbox creation and deletion into several specs, move them into one test-scoped fixture. The recipe below keeps credentials in Node, takes a message baseline before the application sends, and gives each retry a new address. Your application sends the email; MailHulk receives it.
Choose the inbox owner first
Use one inbox per test attempt for signup, password reset, and other journeys that change account state. A worker can execute several tests; sharing its inbox also shares old mail and the application identity tied to that address.
| Scope | What gets shared? | Use it when |
|---|---|---|
| Test | One attempt owns the address | Each journey needs fresh email and account state |
| Worker | Tests in one process reuse an address | A controlled setup flow owns incoming mail, with explicit freshness rules |
| Whole suite | Concurrent processes share a mailbox | You have designed an independent routing and correlation contract |
Playwright documents test and worker fixtures separately. Worker processes are reused and can be replaced after failures; see its parallel execution model. A worker number is useful diagnostic metadata, but is not a globally unique inbox identity across jobs and shards.
Prepare the staging journey
Use an existing Playwright TypeScript project and a staging application that can send to the generated address. Set MAILHULK_URL to the API origin without /v1, MAILHULK_API_KEY to a project-scoped key, APP_URL to your staging origin, and OTP_SUBJECT to the exact verification-email subject. Keep the key in your CI secret store.
The example assumes one six-digit OTP email per trigger. Replace the signup path, labels, buttons, and final success heading with your application's behavior. If your setup sends a welcome email first, define a different message-selection rule before adopting this helper.
For the email project, use this configuration and apply the same settings to any project-level overrides. Recordings and browser error details can contain entered authentication values; keep reports private even with recordings disabled.
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 120_000,
use: {
actionTimeout: 10_000,
navigationTimeout: 10_000,
trace: 'off',
screenshot: 'off',
video: 'off',
},
});
Validation boundary, October 28, 2026: the examples were checked against Playwright 1.62.1's TypeScript declarations and MailHulk's current API contract. They require adaptation to your application; no live staging email journey was executed for this article.
Copy the test-scoped Playwright email fixture
Save this as tests/fixtures/email.ts. It exports the extended test, its matching expect, and a lifecycle helper for the worker variant later in the article.
import { randomUUID } from 'node:crypto';
import { test as base, expect, request } from '@playwright/test';
const EMPTY = '00000000000000000000000000';
export function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before running email tests`);
return value;
}
type Message = {
id: string;
subject: string | null;
codes: string[];
};
export type EmailInbox = {
address: string;
diagnostic: Record<string, unknown>;
mark(): Promise<string>;
waitForOtp(after: string, subject: string): Promise<string>;
};
export async function openInbox(name: string, ttlSeconds = 900) {
const api = await request.newContext({
baseURL: required('MAILHULK_URL'),
extraHTTPHeaders: {
Authorization: `Bearer ${required('MAILHULK_API_KEY')}`,
},
timeout: 10_000,
maxRedirects: 0,
});
try {
const response = await api.post('/v1/inboxes', {
headers: { 'Idempotency-Key': randomUUID() },
data: { name, ttl_seconds: ttlSeconds },
});
if (response.status() !== 201) {
throw new Error(`Inbox creation failed: HTTP ${response.status()}`);
}
const { data } = await response.json();
const id: string = data.id;
const latest = `/v1/inboxes/${id}/messages/latest`;
const diagnostic: Record<string, unknown> = { inboxId: id };
const inbox: EmailInbox = {
address: data.address,
diagnostic,
async mark() {
diagnostic.phase = 'baseline';
const result = await api.get(latest, {
params: { view: 'summary' },
});
diagnostic.httpStatus = result.status();
if (result.status() === 204) return EMPTY;
if (result.status() !== 200) {
throw new Error(`Baseline lookup failed: HTTP ${result.status()}`);
}
const { data: message } = await result.json();
return message.id as string;
},
async waitForOtp(after, subject) {
Object.assign(diagnostic, {
phase: 'wait', after, waitStartedAt: new Date().toISOString(),
});
const result = await api.get(latest, {
params: { after, wait: 60, view: 'summary' },
timeout: 65_000,
});
const status = result.status();
const degraded = result.headers()['x-mailhulk-wait-degraded'];
Object.assign(diagnostic, {
httpStatus: status,
requestId: result.headers()['x-request-id'],
degraded: degraded ?? null,
});
if (status === 204) {
throw new Error(degraded
? 'Email wait skipped: wait capacity reached'
: 'No eligible email arrived within the 60-second wait');
}
if (status !== 200) {
throw new Error(`Email lookup failed: HTTP ${status}`);
}
const { data: message }: { data: Message } = await result.json();
Object.assign(diagnostic, {
phase: 'extract', messageId: message.id,
subjectMatches: message.subject === subject,
codeCount: message.codes.length,
});
if (message.subject !== subject) {
throw new Error('First eligible email has an unexpected subject');
}
if (message.codes.length !== 1 || !/^\d{6}$/.test(message.codes[0])) {
throw new Error('Expected exactly one six-digit OTP');
}
diagnostic.phase = 'verify';
return message.codes[0];
},
};
return {
inbox,
async close() {
try {
const removed = await api.delete(`/v1/inboxes/${id}`);
if (![204, 404].includes(removed.status())) {
throw new Error(`Inbox cleanup failed: HTTP ${removed.status()}`);
}
} finally {
await api.dispose();
}
},
};
} catch (error) {
await api.dispose();
throw error;
}
}
export const test = base.extend<{ emailInbox: EmailInbox }>({
emailInbox: [async ({}, use, testInfo) => {
const lease = await openInbox(`pw-retry-${testInfo.retry}`);
try {
await use(lease.inbox);
} finally {
const failed = testInfo.status !== testInfo.expectedStatus;
try {
await lease.close();
} catch {
testInfo.annotations.push({
type: 'cleanup', description: 'Inbox deletion failed; TTL is fallback',
});
if (!failed) throw new Error('Inbox cleanup failed after test');
} finally {
if (failed) {
testInfo.attachments.push({
name: 'email-diagnostic', contentType: 'application/json',
body: Buffer.from(JSON.stringify({
...lease.inbox.diagnostic, retry: testInfo.retry,
}, null, 2)),
});
}
}
}
}, { timeout: 30_000 }],
});
export { expect };
The fixture runs only for tests that request emailInbox. Its 30-second fixture budget covers setup and teardown; the test has a separate 120-second budget. The arrival wait asks for up to 60 seconds, with 65 seconds allowed for the HTTP request. Adjust these together with your staging and CI job limits; Playwright documents test timeout budgets and request contexts and their options.
The Idempotency-Key identifies one creation attempt. If you add an HTTP retry around creation, reuse that key within the retry loop. A new Playwright test attempt calls openInbox again with a new key and a generated address; its readable name is only a label.
Use the received code in a real journey
Import test from the fixture file so Playwright can supply emailInbox. Take the baseline before clicking the action that sends mail.
import { test, expect, required } from './fixtures/email';
test('signup accepts its own email code', async ({ page, emailInbox }) => {
await page.goto(new URL('/signup', required('APP_URL')).href);
await page.getByLabel('Email', { exact: true }).fill(emailInbox.address);
const after = await emailInbox.mark();
emailInbox.diagnostic.phase = 'trigger';
emailInbox.diagnostic.triggeredAt = new Date().toISOString();
await page.getByRole('button', { name: 'Send code', exact: true }).click();
const code = await emailInbox.waitForOtp(after, required('OTP_SUBJECT'));
await page.getByLabel('Verification code', { exact: true }).fill(code);
await page.getByRole('button', { name: 'Verify', exact: true }).click();
await expect(page.getByRole('heading', {
name: 'Email verified', exact: true,
})).toBeVisible({ timeout: 10_000 });
});
Run npx playwright test tests/signup-email.spec.ts. Add the fixture to another independent journey and run with --workers=2 to check parallel ownership. Each attempt should receive a different address, and the passing assertion should prove the application accepted the received code.
A fresh inbox returns 204 from mark(), which becomes the lower-bound ULID cursor. If mail arrives between the click and waitForOtp(), it is still eligible. Without after, a wait uses the newest message at request time as its baseline and can miss that early arrival. The MailHulk API reference defines this behavior.
For the framework-independent reasoning behind the journey, read how to test OTP emails without flaky waits. This fixture centralizes ownership and cleanup so you can reuse that pattern across specs.
Make teardown survive an assertion failure
The code after await use() owns teardown. The finally block attempts deletion even if the test fails. A 204 confirms deletion; a 404 is accepted because an absent inbox needs no further deletion.
If cleanup fails after an otherwise successful test, the fixture reports a teardown failure. If the test already failed, the fixture annotates the cleanup problem and preserves the original failure. Disposing the request context releases its resources; it does not delete a server-side inbox.
The requested 900-second TTL backs up deletion when a worker is killed or cannot finish setup. A remote creation can succeed even if its response is lost, so expiry also matters when the caller never learns the inbox ID. Check the returned expires_at when choosing a lifetime for longer flows; inbox expiry and plan-based message retention are separate settings.
Deleting a MailHulk inbox does not remove the account created in your staging application. Give that account its own cleanup policy or fixture.
When can a worker-scoped inbox be safe?
Worker scope can fit a setup-only authentication flow: one worker fixture receives the login email once, creates reusable authentication state, and dependent tests reuse that state without sending more email. It can also fit a deliberately sequential mailbox workflow when you can exclude late deliveries from earlier steps and correlate every trigger.
For those controlled suites, this is the allocation pattern. Save it separately as tests/fixtures/worker-email.ts. It reuses the lifecycle helper above, requests a one-hour TTL, and deletes the inbox when the worker fixture tears down. Tests import its test and request workerInbox; the per-test fixture remains the default for independent journeys.
import { test as base, expect } from '@playwright/test';
import { openInbox, type EmailInbox } from './email';
export const test = base.extend<{}, { workerInbox: EmailInbox }>({
workerInbox: [async ({}, use, workerInfo) => {
const lease = await openInbox(`pw-worker-${workerInfo.workerIndex}`, 3600);
try {
await use(lease.inbox);
} finally {
await lease.close();
}
}, { scope: 'worker', timeout: 30_000 }],
});
export { expect };
This is allocation and teardown, not a generic message-ownership solution. Before each controlled send, call mark() and pass that result to waitForOtp(). A delayed email from an earlier trigger can still arrive after the new baseline. A matching subject does not distinguish two OTPs with the same subject, and this helper deliberately fails on an unexpected first message rather than searching indefinitely.
Do not turn a repeated signup suite into a worker-scoped suite just by changing the scope option. The address may already belong to an account; resends and old tokens need an application-specific contract. Long-lived workers also need an expiry-renewal policy if they can outlive the effective TTL. Prefer fresh test-scoped inboxes whenever you cannot make these conditions explicit.
The worker variant reports cleanup errors at worker teardown and omits the test fixture's per-attempt diagnostic attachment. Use the test-scoped version when that evidence is needed. For the broader ownership trade-off, read shared versus isolated test inboxes in parallel CI.
Diagnose the phase that failed
- Creation or baseline fails: inspect the HTTP status, project key, quota, and inbox lifetime. A request failure here has not established a delivery problem.
- Normal wait returns 204: inspect the staging send attempt, recipient, and provider response. A timeout alone cannot identify which part of delivery failed.
- Degraded wait: the API can skip waiting when capacity is reached. The helper reports this separately; it does not claim 60 seconds elapsed. Any retry policy needs backoff and one overall deadline.
- Wrong subject or missing code: inspect the received message privately. Confirm message order, template, and extraction assumptions before increasing timeouts.
- Browser verification fails: investigate code expiry, recipient binding, resend ordering, and application state.
- Cleanup fails: inspect connectivity and inbox scope, then confirm the effective expiry. Do not hide failed deletion behind a passing browser assertion.
The test-scoped failure attachment records internal identifiers, the phase, cursor, request status, and extraction counts. It omits message bodies, addresses, subjects, codes, and credentials. Keep the attachment private and give CI artifacts a retention policy. The original browser failure can still contain entered values.
Check the fixture before expanding coverage
- Run two independent journeys concurrently and confirm distinct inbox addresses.
- Fail a browser assertion deliberately; confirm the inbox is still deleted.
- Retry a failed test and confirm its new attempt gets a fresh address.
- Stop the staging send; confirm the failure identifies the wait phase.
- Send the wrong template; confirm it fails extraction or subject validation.
- Check the effective inbox expiry and the application's separate account cleanup.
- Review private artifact access before enabling the CI job.
Use mocks or test hooks when the check only needs application logic. A local SMTP catcher can be sufficient when the application can route mail to it and that is the boundary you want to prove. Keep a focused real-reception suite for journeys that need that wider boundary.
Move one journey onto the fixture
Copy the test-scoped fixture, adapt one signup flow, and verify deletion after a failed assertion before moving more specs onto it. For the underlying wait pattern, read testing OTP emails without fixed waits.
MailHulk is in preview, with private-beta rollout still being prepared. Bring one email journey for evaluation if you want to try the fixture against a receive-only test inbox.
For the shared setup and a choice of OTP, magic-link, and reset journeys, read the Playwright email-testing guide.