DEV Community

ryanlee
ryanlee

Posted on

A React Inbox Adapter for Reliable Email Tests

A React Inbox Adapter for Reliable Email Tests

Email verification tests often become flaky at the boundary between a React screen and a real inbox. The component submits a form, a test starts polling, a message arrives at an unpredictable moment, and suddenly the failure report says only “verification timed out.” That is not much help when the signup flow has five async states.

I have had better results by treating the inbox as an adapter with a small contract. The React component owns user-facing state. A TypeScript service owns polling and message matching. The test owns assertions and cleanup. That separation makes the feature easier to ship and the failure easier to explain.

Why the inbox should be an adapter

An email provider has its own API, timing, retention rules, and odd response shapes. Those details should not leak into a button click handler or a Playwright fixture. The adapter gives the rest of the app a stable vocabulary:

export type VerificationMessage = {
  id: string;
  receivedAt: string;
  verificationUrl: string;
};

export type Inbox = {
  address: string;
  waitForVerification: (options?: { timeoutMs?: number }) => Promise<VerificationMessage>;
  dispose: () => Promise<void>;
};
Enter fullscreen mode Exit fullscreen mode

This interface is intentionally boring. It does not expose provider-specific cursors or ask React to understand whether a 404 means “not arrived yet.” That sounds small, but it remove a surprising amount of branching from the UI and test code.

For a test that needs a temporary email generator, the provider can be swapped behind this interface. The test still receives an address, waits for one matching message, and disposes the inbox when the run finishes.

Define matching and timeout behavior

Polling needs more than a while loop. Define the message boundary before writing the request code:

type PollOptions = {
  after: number;
  timeoutMs: number;
  intervalMs: number;
};

function isVerificationMessage(message: VerificationMessage) {
  return message.verificationUrl.includes("/verify");
}
Enter fullscreen mode Exit fullscreen mode

The after timestamp prevents an old message from satisfying a new signup. A bounded timeout prevents a stuck provider from holding the whole suite forever. In my experience, a retry budget are more useful than an aggressive polling interval: fast requests do not fix an inbox with delayed delivery.

The adapter can return a typed error when the deadline is reached. Include the address, last cursor, elapsed time, and message count in diagnostic data, while keeping tokens and full message bodies out of logs. If the message arrive after the deadline, the report should say that clearly rather than pretending the verification URL was invalid.

Keep polling outside the component

React should receive a promise or a result, not coordinate every timer. A hook can model the user-facing states without knowing how messages are fetched:

type VerificationState =
  | { status: "idle" }
  | { status: "waiting"; address: string }
  | { status: "verified"; at: string }
  | { status: "failed"; message: string };

function VerificationStatus({ state }: { state: VerificationState }) {
  if (state.status === "idle") return <p>Ready to verify</p>;
  if (state.status === "waiting") return <p>Check {state.address}</p>;
  if (state.status === "verified") return <p>Verified at {state.at}</p>;
  return <p role="alert">Verification failed: {state.message}</p>;
}
Enter fullscreen mode Exit fullscreen mode

The screen can now handle loading, success, and error states consistently. It also make accessibility work more direct: the waiting message can use a polite live region, while a timeout can use an alert without exposing provider internals.

For recovery flows, I also like to keep the proof path visible in the test result. This pairs well with a failure map for matrix jobs, because each browser or Node.js run can report its own inbox state instead of collapsing everything into one red job.

Make cleanup and evidence first-class

An inbox is test data with a lifetime. Create it with a run ID, record its creation time, and dispose it in a finally block:

const inbox = await createInbox({ runId });

try {
  await completeSignup(inbox.address);
  const message = await inbox.waitForVerification({ timeoutMs: 30_000 });
  await openVerificationUrl(message.verificationUrl);
} finally {
  await inbox.dispose();
}
Enter fullscreen mode Exit fullscreen mode

The cleanup path should run after assertion failures too. If the provider supports a throwaway email generator, use a run-scoped address and keep its retention shorter than the test data retention window. Never put a live verification token in a screenshot, CI annotation, or shared log.

The same idea applies to API design: idempotency for signup APIs helps ensure a retry does not create a second account while the email test is still waiting. The inbox contract and signup contract should agree on a run identifier, so a failure can be followed across both sides.

A practical review checklist

Before merging an email test helper, check:

  • Does each run get a fresh, run-scoped address?
  • Is old mail excluded with a timestamp or cursor?
  • Are timeout and retry behavior explicit?
  • Does the error include useful evidence without secrets?
  • Does cleanup happen in a finally block?
  • Can the React component render the states without provider knowledge?
  • Are test addresses like temp gamil com handled as ordinary fixture data, rather than silently corrected?

If one of these answers is unclear, the test may still pass locally, but it will be harder to trust in CI. A short contract usually fixes that faster than adding another retry.

Final thoughts

Reliable email verification tests are less about polling faster and more about choosing a clean boundary. Keep provider behavior in a TypeScript adapter, keep React focused on visible states, and return evidence that explains the deadline, cursor, and cleanup result. The result is easier to review and easier to maintain when the inbox service changes.

It is a modest bit of architecture, but it lets a team ship signup improvements with less confusion—and fewer mysterious red builds.

Top comments (0)