July 16, 2026
How to Test Magic Links, IMAP Inbox Polling, and One-Time Codes in CI Without a Real Mailbox
A practical guide to test magic links in CI using IMAP test automation, fake inboxes, polling patterns, and timeout handling for login email workflows.
Email-based login is convenient for users and awkward for Test automation. Magic links expire, one-time codes race against timers, IMAP servers have their own latency, and a “check your inbox” flow can fail in ways that are hard to reproduce locally. The result is a familiar testing problem: the app works, but the login email workflow becomes flaky in CI.
The good news is that you do not need a real personal mailbox to test these flows well. You need a repeatable email capture strategy, a polling loop with explicit timeouts, and enough protocol awareness to know where the failures actually come from. In practice, that means separating the concerns of email delivery, inbox retrieval, token parsing, and browser automation.
This article shows a reproducible way to test magic links in CI, along with IMAP inbox polling patterns and one-time code handling. The examples use common tools such as Playwright and a test mailbox API, but the design works with Selenium, Cypress, or custom HTTP clients too. The important part is the structure.
The core idea is simple: do not test email by pretending email is just another API response. Treat it as an asynchronous system with delivery delays, message ordering, expiring tokens, and explicit failure modes.
What you are actually testing
Before writing code, define the boundaries of the test. Email login usually has four moving parts:
- The app sends an email after a user enters an address.
- The email lands in a mailbox or inbox service.
- The test retrieves the message and extracts the link or code.
- The browser uses that link or code to finish authentication.
Each step can fail independently. If you only assert that a message was “sent,” you miss inbox delays. If you only test parsing, you miss expired links. If you only automate the browser, you miss the backend token generation.
A good CI test for this flow answers a narrow question, such as:
- Does the app send a valid magic link to the correct address?
- Does the link work when used from a fresh browser session?
- Does the one-time code expire when expected?
- Does the login email workflow remain reliable under polling delays?
That is different from testing the mail provider itself. You do not need to verify SMTP delivery to the public internet in every run. You need a controlled mailbox that your tests can interrogate deterministically.
The three practical test setups
There are three common patterns for email authentication testing, and each one trades realism for control in a different way.
1. A fake inbox service with an API
This is the easiest pattern for CI. Your app sends email to a test address, and the inbox service exposes a REST API to list messages. Your test waits for the message, extracts the token, and continues.
Why teams use it:
- Fast to implement
- Good isolation between test runs
- Easy to clean up
- Less brittle than scraping a real webmail UI
Tradeoffs:
- Not the same as testing IMAP directly
- You are trusting the service’s inbox semantics
- Some edge cases, such as server-side threading or message flags, are abstracted away
2. Direct IMAP polling against a controlled mailbox
This is the closest thing to “real email” that still stays automatable. The test connects to an IMAP inbox, searches for the newest relevant message, fetches the raw MIME content, and parses it. For protocol-level coverage, this is a strong choice.
The IMAP standard defines the protocol behavior, which matters because mailbox search, message flags, folders, and fetch semantics can affect how your test should read messages.
Tradeoffs:
- More moving parts than a fake inbox API
- Requires real credentials and careful secret handling
- Polling can be slow or flaky if the timeout strategy is weak
- MIME parsing is easy to get wrong
3. Hybrid, fake delivery plus protocol parsing in lower environments
Some teams send mail to a controlled test sink in CI, then run a smaller set of protocol tests against an IMAP mailbox in staging. This reduces load on the IMAP path while keeping one protocol-level check alive.
Tradeoffs:
- Two systems to maintain
- More setup work
- Useful when compliance or infra constraints make direct IMAP polling expensive
For most teams, the practical path is to start with one controlled inbox provider or IMAP mailbox, then add protocol-level tests only where it proves value.
A clean architecture for email login tests
The easiest way to keep these tests maintainable is to split them into four small modules:
trigger-login-email(), which drives the browser or API to request emailwait-for-message(), which polls the inbox until a message appears or the timeout expiresextract-auth-token(), which parses the email bodycomplete-login(), which opens the link or submits the code
That separation makes failures easier to diagnose. If wait-for-message() times out, you know the mail step failed. If extract-auth-token() fails, the MIME format changed. If complete-login() fails, the app rejected the token or the browser state was wrong.
Here is the diagram in words:
- Browser creates login request
- Backend sends email
- Poller reads mailbox
- Parser extracts token or code
- Browser confirms session
That may sound obvious, but many flaky tests blur those boundaries and then spend hours debugging the wrong layer.
A Playwright example for magic links
For browser-driven login, Playwright is a good fit because it handles isolated browser contexts well. The test below assumes your app sends a magic link to a test address and that your inbox is reachable by an API.
import { test, expect } from '@playwright/test';
async function waitForEmail(fetchMessages: () => Promise<any[]>, timeoutMs = 60_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const messages = await fetchMessages(); const match = messages.find(m => m.subject?.includes(‘Sign in’)); if (match) return match; await new Promise(r => setTimeout(r, 2_000)); } throw new Error(‘Timed out waiting for login email’); }
test('magic link login', async ({ page }) => {
await page.goto('https://app.example.test/login');
await page.getByLabel('Email').fill('ci-user@example.test');
await page.getByRole('button', { name: 'Send link' }).click();
const email = await waitForEmail(async () => { const res = await fetch(‘https://inbox.example.test/api/messages?to=ci-user@example.test’); return res.json(); });
const link = email.body.match(/https:\/\/app.example.test\/auth\/magic\/[A-Za-z0-9_-]+/)[0]; const context = await page.context().newPage(); await context.goto(link); await expect(context.getByText(‘Signed in’)).toBeVisible(); });
A few implementation notes matter here:
- Keep the polling interval fixed and small enough to be responsive, but not so small that you hammer the inbox API.
- Use a deadline, not an open-ended retry loop.
- Prefer a stable locator strategy in the browser flow, such as role-based selectors.
- Parse the exact token format your app sends, not a generic URL guess.
A common failure mode is a test that passes locally because the email arrives in two seconds, then fails in CI because the inbox takes fifteen seconds to update. The fix is not “more retries forever.” The fix is a timeout that reflects actual delivery variance plus a message retrieval loop that can be explained in logs.
IMAP test automation when you need protocol coverage
If your goal is to validate the mailbox path itself, IMAP test automation is worth the extra effort. It gives you direct access to the inbox state, but it also means you must respect mailbox semantics, including folder selection, search criteria, and message flags.
The outline is straightforward:
- Connect to the IMAP server over TLS.
- Authenticate with a dedicated test account.
- Select the inbox.
- Search for messages matching recipient, subject, or timestamp.
- Fetch the raw MIME body.
- Parse the email content for the link or code.
With Python, the standard library’s imaplib is enough for many test cases.
import imaplib
import email
from email.header import decode_header
mail = imaplib.IMAP4_SSL(‘imap.example.test’) mail.login(‘ci-user@example.test’, ‘secret’) mail.select(‘INBOX’)
status, data = mail.search(None, ‘(UNSEEN SUBJECT “Sign in”)’) message_ids = data[0].split() latest_id = message_ids[-1]
status, msg_data = mail.fetch(latest_id, ‘(RFC822)’) raw = msg_data[0][1] msg = email.message_from_bytes(raw) body = msg.get_payload(decode=True).decode(‘utf-8’, errors=’replace’)
That example is deliberately small. In real tests, you should add:
- Unicode-safe decoding for subject headers
- MIME multipart handling for HTML and text versions
- A stable way to identify the correct message, such as recipient plus creation time
- Message cleanup or folder isolation between runs
IMAP search is not a magic filter. If your mailbox receives multiple messages with the same subject, and your test blindly takes the last message, you are one retry away from a false positive.
What to watch for with IMAP
Mailbox polling is often flaky because of these issues:
- The message is delivered, but the mailbox index updates later.
- The search query matches an older email.
- The body is HTML-only and your parser only reads plain text.
- The code is in a link inside a redirect page rather than in the email body.
- The server marks the message as seen during fetch, which changes subsequent searches.
To reduce ambiguity, include a unique test marker in the email address or subject line. For example, use a timestamp or build number in the recipient alias if your mailbox provider supports plus addressing.
One-time code testing without brittle selectors
One-time code testing is usually simpler than magic links because the code is visible, short-lived, and often inserted directly into the UI. The tricky part is not the browser automation, it is making sure the code is fetched from the right email and submitted before it expires.
A solid pattern looks like this:
- Request a code
- Poll the inbox until the matching message arrives
- Extract a 6-digit or 8-digit code using a strict regex
- Fill code inputs as a group, not one field at a time if your UI supports paste
- Assert that the session is established
const codeMatch = email.body.match(/\b\d{6}\b/);
if (!codeMatch) throw new Error('No one-time code found');
await page.getByLabel(‘Code’).fill(codeMatch[0]);
await page.getByRole('button', { name: 'Verify' }).click();
If your UI uses multiple single-character inputs, you may need to simulate paste or fill them individually. The important part is to keep the extraction logic strict. A loose regex like /\d+/ can accidentally match dates, order numbers, or other digits in the email footer.
Polling strategy, timeouts, and backoff
Email tests fail most often because the waiting logic is too naive. The best polling strategy is simple enough to debug and explicit enough to tune.
Use this mental model:
- Short polling interval for the first few seconds
- Longer interval after that, to reduce repeated API calls
- Absolute timeout that matches the slowest expected delivery path in CI
- Clear error message showing how long you waited and what you searched for
A practical polling loop might look like this:
typescript
async function pollUntil<T>(fn: () => Promise<T | null>, timeoutMs: number) {
const start = Date.now();
let delay = 1000;
while (Date.now() - start < timeoutMs) { const value = await fn(); if (value) return value; await new Promise(r => setTimeout(r, delay)); delay = Math.min(delay * 1.5, 5000); }
throw new Error(Timed out after ${timeoutMs}ms);
}
The tradeoff is clear. Short polling gives faster feedback, but more load. Longer polling reduces pressure, but hides failures longer. In CI, a fixed upper bound is usually more important than “smart” retry logic, because you want failures to be reproducible and searchable in logs.
Designing test mailboxes that do not leak state
A mailbox that accumulates old messages will eventually make your tests lie. The fix is to make each test run own a unique mailbox namespace or a uniquely identifiable address.
Good options:
- Create one inbox per CI run
- Use plus addressing, such as
ci+build-123@example.test - Put a unique token in the subject line
- Search by recipient and creation timestamp together
If you share one test inbox across parallel jobs, isolation becomes harder. One job may consume a message meant for another. That problem often appears only under load, which makes it especially unpleasant to debug.
A robust pattern is to record the exact test address and generated request time, then search for messages newer than that timestamp. That reduces accidental matches from earlier runs.
Secure handling of auth emails in CI
Email auth tests often touch secrets, so the security model matters. Even in a test environment, avoid leaking real tokens into logs.
Practical safeguards:
- Store mailbox credentials in CI secrets, not in repository files
- Redact magic links and codes from test logs
- Use a dedicated test domain or sandbox mailbox provider
- Ensure links point to a test environment, not production
- Scope mailbox access to the minimum required permissions
If your email content includes both a link and a code, decide which one the test consumes. Reading both can be helpful for diagnostics, but do not print them unredacted.
Debugging checklist for flaky login email workflows
When a test fails, classify the failure before changing code. Most issues fall into one of these buckets:
Email never sent
- Backend job failed
- Mailer configuration missing in CI
- Rate limit or sandbox restriction
- Feature flag disabled
Email sent, but not found
- Wrong recipient address
- Search query too narrow
- Mailbox index delay
- Parallel test collision
Email found, but token parsing failed
- HTML-only formatting
- Link wrapped across lines
- Code length changed
- Subject body localization changed
Token found, but login failed
- Expired link
- Token already consumed
- Browser context reused
- CSRF or origin mismatch
That breakdown turns a vague “CI is broken” into a smaller set of actions.
Where a real mailbox still helps
There are cases where a controlled inbox service is enough, and cases where direct IMAP polling earns its place. If your app only needs one smoke test for email login, a fake inbox API may be sufficient. If your system depends on mailbox behavior, message flags, or protocol-specific conditions, IMAP coverage is justified.
A reasonable testing mix for many teams is:
- Unit tests for token generation and expiry logic
- API tests for email request endpoints
- Inbox polling tests for end-to-end delivery and parsing
- Browser tests for the final login step
That mix keeps the slowest tests focused on the parts that are actually asynchronous.
A CI workflow example
Here is a small GitHub Actions job that runs browser tests with a dedicated mailbox secret and a separate environment URL.
name: email-auth-tests
on: pull_request: push: branches: [main]
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install –with-deps - run: npm test – –grep “magic link|one-time code” env: APP_URL: https://app.example.test INBOX_API_KEY: $
This keeps the secrets out of the repository and makes the test target explicit. If the login email workflow changes, you want that to fail early in CI, not after release.
Selection guide, not a one-size-fits-all answer
If you are choosing a strategy, use these criteria:
- Use a fake inbox API when you want the lowest maintenance burden and good CI stability.
- Use IMAP test automation when protocol behavior matters or you need to validate mailbox retrieval more deeply.
- Use both when your app is sensitive to delivery timing, parsing, and real inbox behavior.
The most common mistake is trying to make one test cover every layer with one giant script. Smaller tests with clear responsibilities are easier to retry, easier to debug, and easier to keep green.
Final checklist
Before you merge an email login test into CI, make sure it answers these questions:
- Does every run use a unique recipient or search key?
- Is the polling timeout documented and intentional?
- Can the test distinguish “not delivered” from “not parsed” from “not accepted”?
- Are links and codes redacted from logs?
- Does the test cleanly handle expired or already-used tokens?
- Is there at least one lower-level test for the token generation path?
If those answers are yes, you have something better than a flaky end-to-end script. You have a reproducible check that exercises the same asynchronous systems your users rely on, without needing a real personal mailbox.
That is the practical goal: make email-based authentication testable, understandable, and boring in CI.