Clones

Email workflow testing: OTPs, invites, notifications

Signup, password reset, team invites, receipts — half your product’s most important moments end in an inbox. If your tests stop at “form submitted,” they’re not covering the flow users actually experience.

Pratik Rana 8 min read

01The inbox is part of your app surface

Consider a normal B2B signup: user submits the form → receives a verification code → confirms → gets a team invite → clicks a link → lands in the app. Three of five steps happen outside your UI, over email. A test suite that mocks the mailer with “assume sent” verifies nothing: broken templates, wrong merge fields, dead links, expired tokens, and missing emails all ship to production looking green.

Teams avoid asserting on email because the traditional options are bad. Real SMTP through a provider is slow (seconds to minutes per message), costs money at CI volume, risks sending actual mail to actual people, and gives you no clean programmatic inbox to assert against. Shared Gmail test inboxes accumulate state across runs and trip spam filters. What you need instead is a mail system built for machines — which is exactly what an email clone provides: SMTP/API compatible, instantly delivered, fully inspectable.

02What to actually assert

For every transactional email your app sends, three properties are worth verifying in E2E tests:

  • Arrival. The right email reaches the right address within your SLA window — one assertion catches the entire broken-template / crashed-worker class of bugs.
  • Links. Every actionable URL resolves: extract it from the message, request or navigate it, assert the destination state (“reset confirmed”, not a 404). This catches token-expiry misconfigurations and base-URL drift between environments.
  • Codes. For OTP flows, extract the code and drive the UI with it — proving the full loop works, not just that something was rendered.
test('password reset completes via emailed link', async ({ page }) => {
  await requestReset(page, 'ci-run-a7f3+user@mail.clone');
  const mail = await clone.waitForMessage({
    to: 'ci-run-a7f3+user@mail.clone',
    subjectContains: 'Reset your password',
    timeoutMs: 5_000,
  });
  const link = mail.links.find(l => l.href.includes('/reset/'));
  await page.goto(link.href);
  await page.fill('#new-password', 'correct-horse-battery');
  await expect(page.getByText('Password updated')).toBeVisible();
});

Notice what’s not asserted here: pixel-perfect rendering of the template. That belongs in a separate, slower template-snapshot job, not in the critical path of every run.

Apply the same three-part lens to the other transactional types. Invites: assert the email reaches the invitee, the accept link lands them in the right workspace with the right role, and a stale or already- accepted link shows a sensible state instead of an error. Notifications (receipts, shipping, alerts): assert arrival and that dynamic fields rendered with real values — “Your order #1042 shipped” not “Your order #undefined shipped.” Merge-field bugs are embarrassing precisely because they only appear in the email body, which is exactly where untested suites never look.

03Unique-per-run addresses

The single biggest source of email-test flakiness is address reuse. If two runs read from test@example.com, run B sees run A’s messages, filters get confused, and parallel workers corrupt each other’s assertions. Give every run its own namespace instead — plus-addressing or a wildcard catch-all domain both work:

  • run-{CI_JOB_ID}+signup@clone.test — sortable, greppable, self-documenting.
  • Anything @clone.test is accepted by the mail clone, so you never provision addresses.

Unique addresses make assertions trivially race-free: “wait for exactly one message to this exact address” has no cross-talk by construction. They also make cleanup honest — purge by prefix after the run, and nothing leaks between jobs. This is standard test data hygiene applied to the mailbox.

Never point automated tests at domains you don’t control. A typo’d recipient on a live provider is how test traffic becomes accidental email to strangers — with your product’s name on it. Clones keep every message inside the run sandbox; nothing leaves the machine.

04Code extraction helpers

OTP emails vary in format — “Your code is 123456”, spaced digits, embedded buttons. Centralize parsing in one helper so template changes break one place, not forty tests:

// helpers/mail.ts
export function extractOtp(mail) {
  const m = mail.text.match(/\b(\d[\s-]?){5}(\d)\b/g)?.[0];
  if (!m) throw new Error(`No OTP found in: ${mail.subject}`);
  return m.replace(/\D/g, '');
}

export async function latestCodeTo(clone, addr) {
  const mail = await clone.waitForMessage({ to: addr, timeoutMs: 5_000 });
  return extractOtp(mail);
}

Beyond codes, expose two more primitives from the same helper: linksFor(addr) returning parsed anchor hrefs (so link assertions stay one line), and assertNoMessage(addr, subject) for the negative cases teams forget — password reset on an account that doesn’t exist must not send anything, or you’ve built a user-enumeration oracle. Negative email assertions are impossible with real inboxes (absence is indistinguishable from slow delivery) and trivial with a deterministic clone.

Two details worth stealing: fail loudly with the raw body in the error (debugging “why didn’t my regex match” without the payload is misery), and prefer waiting on subject + recipient rather than sleep — polling a deterministic clone returns in milliseconds anyway.

05No real inbox: safety and determinism by default

The clone approach removes the entire risk surface of real-mail testing: no sends to third parties, no provider rate limits in CI, no deliverability lottery, no cost scaling with run count. Delivery is local and synchronous-fast, which means an OTP step adds milliseconds, not minutes — keeping suites under the latency budget where developers actually run them. It composes with the rest of your stack too: pair it with the SMS side described in Twilio OTP testing, and run the whole thing on a schedule against staging as part of your regression strategy.

Molar’s email clone ships as part of the platform’s clone set — alongside Stripe, Twilio, auth, S3, and GitHub — with Trace capturing each message as evidence next to DOM snapshots and network logs. When an invite flow fails, you see the exact email the system produced, not a screenshot of its absence.

Test the full email path, inbox included

Molar’s email clone delivers instantly, accepts any address, and hands every code and link to your tests as data. No SMTP servers, no real recipients, no flakes.