How to test a password reset email end to end

A password reset is one of the few flows where the email is the product. If the mail does not arrive, or the link in it is broken, the user is locked out. Yet it is usually the least tested path in the codebase, because testing it properly means receiving real mail.

This walks through a full reset test: request the reset, receive the message, extract the link, follow it, and confirm the new password works.

The shape of the problem

Unlike a signup OTP, a reset flow gives you a link, not a code. So the test needs to read the message body and find the right URL — which is exactly the part people get wrong by writing a brittle regex over the HTML.

InboxDome parses the links out for you. Every message exposes a links array with a type and a confidence, so you can ask for the action link rather than the unsubscribe footer or the logo’s href.

1. Create an inbox and trigger the reset

import { InboxDome } from 'inboxdome';

const client = new InboxDome({ apiKey: process.env.INBOXDOME_KEY! });

const inbox = await client.createInbox();
// inbox.emailAddress → e.g. "k7f2p9@mail.inboxdome.com"

Register a user with inbox.emailAddress (or seed one), then hit your “forgot password” endpoint with that address.

waitForMessage long-polls the server, so there is no sleep loop and no IMAP client. It returns a summary; fetch the full message to get the parsed links.

const { message } = await client.waitForMessage(inbox.id, { timeoutSeconds: 30 });
const detail = await client.getMessage(inbox.id, message.id);

// detail.links → [{ url, type, confidence }, ...]
const reset = detail.links.find((l) => l.url.includes('/reset'));
if (!reset) throw new Error(`No reset link in: ${detail.subject}`);

Prefer matching on your own known URL shape (/reset, /account/recover) over trusting a heuristic. The type and confidence fields are there to help you rank candidates, not to replace knowing what your own app sends.

In Playwright the extracted URL is just a URL:

await page.goto(reset.url);
await page.getByLabel('New password').fill('n3w-S3cure!pass');
await page.getByRole('button', { name: 'Set password' }).click();
await expect(page.getByText('Password updated')).toBeVisible();

Then prove the reset actually took effect — log in with the new password. A reset test that stops at “Password updated” has not tested the reset.

Assert on the email itself

Separately from the browser flow, it is worth asserting on the quality of the mail — that it arrives quickly, says what it should, and is authenticated. One call does all of it:

const run = await client.assertEmail(
  inbox.id,
  [
    { type: 'containsText', text: 'reset your password' },
    { type: 'auth', check: 'dkim', expect: 'pass' },
    { type: 'auth', check: 'spf', expect: 'pass' },
  ],
  { timeoutSeconds: 30 },
);

console.log(run.passed, run.arrivalSeconds, run.results);

assertEmail never throws on a failed assertion — inspect run.passed and run.results so your test reports which condition failed, not just that something did.

One warning about linkResponds

There is a linkResponds assertion that fetches the detected link server-side and fails on a 4xx/5xx. It is genuinely useful for catching a reset link that points at a dead route.

But be careful with it on a password reset specifically: if your reset tokens are single-use and consumed on GET, the assertion will burn the token before your browser gets there. Either make the token consumable only on POST (which you probably want anyway, because mail scanners and link prefetchers will otherwise eat your users’ reset links), or skip linkResponds for this flow and rely on the browser step to prove the link works.

That failure mode is not hypothetical. Corporate mail scanners follow links in email. If a single GET invalidates your reset token, some fraction of your real users are already being locked out of the flow before they click.

In CI

Store INBOXDOME_KEY as a secret. The free tier allows 50 inboxes a day, which is plenty for a reset suite that creates one inbox per test. Inboxes expire on their own, so cleanup is optional.

See also: Testing OTP emails with Playwright · GitHub Actions · API reference


Get a free API key — 50 inboxes a day, no card. Orgrab a temporary inbox without signing up at all.