July 22, 2026
How to Test AI Agents That Use Email Links for Login, Verification, and Recovery
A practical guide to testing AI agents with email verification, IMAP test mailboxes, magic links, and recovery flows in CI without flaky timing or brittle parsing.
Email-based login and recovery flows look simple from the product side, but they are one of the easiest places for an AI agent test to become flaky. The flow crosses boundaries, your app sends mail, a mailbox receives it, the test extracts a token or link, and a browser session resumes with a new authenticated state. Each handoff introduces timing, parsing, and state-management problems.
If you are trying to test AI agents with email verification, the core challenge is not just “wait for an email.” It is building a reproducible loop that can tolerate delivery delay, isolate messages per test run, and fail with enough context to debug whether the problem was the app, the mailbox, or the agent workflow itself.
This article walks through a practical implementation strategy for email-driven browser workflows, with examples using IMAP, Mailgun-style test inboxes, and browser automation. The focus is on reliable mechanics, not mock-heavy shortcuts that hide the behavior users actually see.
What makes email-driven agent tests brittle
AI agents that sign up, verify, reset passwords, or resume sessions from email usually fail in one of four ways:
- Timing failures: the test looks for mail too early, before the message arrives.
- Ambiguity: multiple messages match the same inbox, and the test grabs the wrong one.
- Parsing failures: the link is hidden in HTML, wrapped by tracking URLs, or duplicated in a text part.
- State drift: the browser tab, mailbox, and backend identity no longer agree about which run is which.
Those are not abstract problems. They are the normal failure modes of any system that crosses asynchronous boundaries. Email is just especially awkward because delivery timing is outside your control.
A reliable test does not try to make email instant. It makes email observable, attributable, and bounded.
For background on the terms used here, it helps to separate the concepts:
- Software testing is the broader practice of checking system behavior against expectations.
- Test automation is the part we are applying here, using code to drive the browser and inspect mail.
- Continuous integration is where these tests need to run repeatedly without becoming a source of noise.
A practical architecture for email-based agent tests
A durable setup usually has four pieces:
1. A unique mailbox per run or per test class
The mailbox must be isolated enough that one run does not consume another run’s message. There are several ways to do this:
- unique recipient aliases, such as
run-2026-07-22T12-01Z@example.test - a dedicated test inbox, with message filtering by subject and timestamp
- an API-backed provider inbox, such as a Mailgun test inbox or similar service
- an IMAP mailbox that your tests can poll directly
If your environment allows it, unique addresses are the cleanest option because they reduce ambiguity. If not, you need stricter filtering by subject, sender, and body.
2. A mailbox retrieval layer
This layer should know how to:
- connect to IMAP or a provider API
- wait up to a bounded timeout
- filter messages by recipient, subject, and age
- extract either a magic link, verification code, or reset URL
If you use IMAP, the relevant standard is RFC 9051, which defines the IMAP4rev2 protocol. You do not need to implement the protocol itself, but you should know that mailbox polling is a stateful network interaction, not a simple file read.
3. A browser automation layer
Playwright, Selenium, or a comparable browser tool should open the signup or login page, submit the form, then continue after the verification message is handled. In practice, Playwright tends to be the easiest place to coordinate page state with asynchronous waits, but the same pattern works in Selenium if your team already has that stack.
4. A stable identity model
The test needs to know which mailbox belongs to which browser session. The simplest pattern is to generate a test ID at the beginning of the run and reuse it in the email address, subject correlation, and logs.
For example:
import { randomUUID } from 'crypto';
const runId = randomUUID();
const email = agent-${runId}@example.test;
That one identifier can be copied into browser form input, mailbox lookup, and log output. Without it, your test will eventually spend more time sorting through messages than verifying behavior.
Choosing between IMAP, provider inboxes, and mocks
There is no single correct approach. The right choice depends on what you are trying to prove.
IMAP test mailbox
An IMAP test mailbox is useful when your production mail path ends in a normal mailbox, or when you need to validate the actual content as it is received.
Pros:
- realistic delivery path
- works with many mail systems
- direct access to message headers and body parts
Tradeoffs:
- polling latency can be slow
- server-side retention and throttling vary
- parsing MIME multipart messages is easy to get wrong
A common mistake is to treat IMAP as if it were event-driven. It is not. Your test must poll with backoff and a timeout. That is fine, but the implementation needs to make that explicit.
Mailgun test inbox or similar provider inbox
A provider inbox, such as one exposed by a service like Mailgun documentation, can reduce plumbing when your application already sends through that provider. These environments often make it easier to fetch the exact message your app sent and inspect the body programmatically.
Pros:
- easier API access than raw IMAP in many cases
- better structured message retrieval
- less mailbox administration
Tradeoffs:
- couples tests to a provider-specific model
- may not match production delivery edge cases
- still requires careful filtering and cleanup
Use this when the provider API is already part of your architecture and you want fast, deterministic access to the generated message.
Mocks and local catch-all tools
Mocked email delivery or local catch-all tools are useful for unit and narrow integration tests, but they are not sufficient for proving the user-visible flow end to end. If the product’s risk is in the handoff from browser to mailbox and back, a mock can hide the very bugs you need to catch.
The practical rule is:
- mock if you only need to test message composition logic
- use a real mailbox if you need to validate the login, verification, or recovery journey
Building a reproducible email retrieval helper
The retrieval helper should do four things well: identify the right message, wait long enough, extract the token or URL, and fail with usable diagnostics.
Here is a small TypeScript-style shape for an IMAP polling helper, simplified for clarity:
type Message = {
subject: string;
from: string;
bodyText: string;
receivedAt: Date;
};
async function waitForVerificationMessage(
fetchMessages: () => Promise<Message[]>,
match: { to: string; subjectIncludes: string },
timeoutMs = 60_000
): Promise<Message> {
const started = Date.now();
while (Date.now() - started < timeoutMs) { const messages = await fetchMessages(); const hit = messages.find(m => m.subject.includes(match.subjectIncludes) && m.receivedAt.getTime() >= started );
if (hit) return hit;
await new Promise(r => setTimeout(r, 3000)); }
throw new Error(Timed out waiting for mail to ${match.to});
}
This is not production-ready, but it shows the important decisions:
- start time is captured before the action that triggers email
- the search filters by subject and age, not just subject
- timeout is bounded
- polling interval is explicit
A more complete implementation should also filter by recipient and sender, because shared test inboxes often accumulate unrelated messages.
Parsing the link safely
Magic links often appear in HTML and plain text bodies. Do not assume the first URL-looking substring is the one you need. Tracking links, unsubscribe links, and footer links are common distractions.
A more robust strategy is:
- prefer the text part if your app includes the link plainly there
- otherwise parse the HTML and look for anchor text or a known path pattern
- validate that the link hostname and path match the expected application route
Example of a targeted extractor:
function extractMagicLink(bodyText: string): string {
const urlMatches = bodyText.match(/https:\/\/[^\s"<>]+/g) ?? [];
const candidate = urlMatches.find(u => u.includes('/verify') || u.includes('/reset'));
if (!candidate) throw new Error('No verification link found');
return candidate;
}
Do not rely only on regex if the content is heavily templated. For HTML messages, use a parser and inspect anchors directly. The goal is not elegance, it is correctness under real message formats.
Coordinating browser state with mailbox state
The browser and mailbox are two separate systems. If you only coordinate them with sleep calls, the test will become a timing lottery.
Use this sequence instead:
- create unique test identity
- open the signup or login page
- submit the form with that identity
- wait for the exact email tied to that identity
- extract the link or code
- resume the browser flow from the link
- assert the authenticated or recovered state
In Playwright, that can look like this at a high level:
import { test, expect } from '@playwright/test';
test('magic link login', async ({ page }) => {
const email = `agent-${Date.now()}@example.test`;
await page.goto(‘/login’); await page.getByLabel(‘Email’).fill(email); await page.getByRole(‘button’, { name: ‘Send link’ }).click();
const message = await waitForVerificationMessage(fetchMessages, { to: email, subjectIncludes: ‘Your login link’ });
const link = extractMagicLink(message.bodyText); await page.goto(link); await expect(page.getByText(‘Welcome back’)).toBeVisible(); });
The browser step waits for mail only after the trigger event has happened, which avoids polling before the message can possibly exist. That sounds obvious, but it is a frequent source of flaky tests when helper functions are reused in the wrong order.
Testing login, verification, and recovery separately
These flows are related, but they should not be treated as one generic “email auth” test. Each has distinct failure modes.
Login via magic link
Login links usually expire quickly and should be single-use. That means the test should verify both the happy path and the error path.
Useful checks:
- link can be used exactly once
- expired link is rejected
- reused link is rejected
- authenticated session lands on the correct page
Signup verification
Signup verification often happens before account creation is complete. The test should validate that the user record is not fully active until the email is confirmed, if that is your product requirement.
Useful checks:
- verification email arrives after signup
- verification link activates the account
- repeated clicks do not duplicate or corrupt the account
- unverified users cannot reach protected areas
Password recovery
Recovery flows are the riskiest because they often have looser security and more complex state transitions.
Useful checks:
- reset email is sent only for known accounts, or returns a non-enumerating response
- reset link opens the correct tokenized page
- new password invalidates the old session if that is expected
- token is single-use and expires as documented
Making the test robust in CI
CI is where email-based tests reveal whether they are engineered or merely demo-friendly.
Use bounded polling, never infinite waits
Every mailbox wait must have a timeout. When the timeout is hit, the test should print:
- the expected recipient
- the subject filter
- the mailbox provider or connection mode
- the start time and current time
- the most recent message metadata if available
That context saves a lot of triage time.
Isolate runs with unique namespaces
If you can, make the email address itself unique per run. If you cannot, prefix the subject or add a hidden correlation token in the message body that your test can search for.
Clean up state aggressively
Email-driven flows often leave behind test accounts. Use API cleanup or seeded environments so those accounts do not accumulate and create ambiguous mailbox results later.
Fail on malformed content, not just missing content
A test that finds an email but cannot parse the link is still a failure. Do not silently accept a partial match and keep going. If the app sends HTML-only bodies, or wraps links in a tracking domain, the test should tell you that explicitly.
Keep the mailbox helper close to the test, but not inside the test body
A good pattern is a shared helper module that handles:
- connection setup
- polling and filtering
- MIME parsing
- logging and diagnostics
That keeps the actual test readable while still making the email mechanics inspectable.
Common edge cases worth testing deliberately
A useful suite should not only cover the happy path.
Duplicate delivery
Sometimes resend logic sends multiple messages. Your helper should decide whether to take the newest message, the first matching message after a given timestamp, or a message with a unique correlation token. Make this rule explicit.
HTML-only messages
If your production email template has no plain text body, you need an HTML parser. Otherwise your regex-based extractor will eventually fail.
Expired links
A good test suite verifies that expired links are rejected and that the retry path works. This is where a timing-sensitive negative test is especially valuable.
Concurrent runs
Parallel CI jobs can accidentally share inboxes if the identity strategy is weak. If two runs can reach the same mailbox, the system is not isolated enough.
Recovery flow resets session state unexpectedly
Password reset or magic-link login can invalidate existing sessions. If your test continues in another browser tab or reuses storage state incorrectly, the failure may look unrelated.
Where custom orchestration still makes sense
Custom IMAP and browser orchestration is still justified when you need full control over delivery semantics, message parsing, or security assertions. It is especially appropriate if:
- your product has strict compliance requirements
- you need to inspect exact MIME structure
- you want to validate provider-independent mail behavior
- your team already owns robust browser automation infrastructure
The tradeoff is maintenance. A mailbox adapter, HTML parser, retry policy, and browser state manager all need ownership. Over time, that can become a small framework with its own upgrade cycle and debugging surface.
For teams that want to reduce that plumbing, Endtest, an agentic AI test automation platform, is one possible alternative because it includes email and SMS testing with real inbox handling, extraction of links or codes, and browser continuation in a single platform flow. Its AI Test Creation Agent can generate editable, platform-native steps from plain-English scenarios, which can reduce the amount of custom orchestration your team has to maintain. That said, the platform choice still depends on how much control you need over mailbox internals versus how much test authoring and upkeep you want to own yourself.
A simple checklist before you add these tests to CI
- Every run uses a unique identity or correlation token
- The mailbox lookup has a hard timeout
- Message selection rules are deterministic
- The parser validates the expected link shape, not just any URL
- Negative paths are tested, including expired or reused links
- Logs include mailbox metadata and browser state transitions
- Cleanup removes or quarantines test accounts
- Parallel jobs cannot consume each other’s messages
The main design principle
If there is one idea to keep, it is this: treat email as an asynchronous subsystem with explicit observability, not as a side effect you can casually sleep through.
When you design the test around identity, bounded polling, and strict parsing, email-driven flows become reliable enough for CI. When you do not, they tend to become the test suite’s least trusted area.
The goal is not to make email verification trivial. The goal is to make it reproducible.