July 24, 2026
How to Build a Reliable Email and SMS Verification Test Harness with Mailgun, IMAP, and Twilio
A practical guide to building a reliable email and SMS verification test harness for sign-up, password reset, and MFA flows using Mailgun test emails, IMAP inbox polling, and Twilio SMS retrieval.
Modern verification flows are awkward to test for the same reason they are useful in production: they cross boundaries. Your app writes a record, sends a message, waits for a user action in another system, and then resumes stateful work when the user clicks a link or enters a code. That is a lot more fragile than a normal page transition, and it is exactly why ad hoc inbox checking tends to rot into a manual ritual that nobody trusts.
A good email and SMS verification test harness gives you a repeatable way to exercise sign-up, password reset, login links, and MFA without relying on a human to refresh an inbox or a phone screen. The harness should do three things well: send a real message, retrieve that message deterministically, and assert on the right content without coupling tests to brittle message formatting.
This article walks through a practical implementation using Mailgun documentation, IMAP polling based on the IMAP standard, and Twilio messaging. The goal is not a toy demo. The goal is a test utility your team can keep running in CI without treating every failure as a mystery novel.
What this harness should and should not do
Start with a simple definition of success. A verification harness is not a mail client, and it is not a general purpose message archival system. It is a narrow test utility with a few jobs:
- Trigger a user flow in the application under test.
- Detect that the app sent an email or SMS.
- Extract the value needed to continue, usually a link or one-time code.
- Confirm that the flow completes as expected.
- Leave enough trace data behind to debug failures quickly.
If your tests depend on an engineer opening a mailbox manually, the test is not automated, it is a scheduled interruption.
The harness should not attempt to parse every marketing email, monitor every inbox in the company, or replace your observability stack. Keep the scope tight. That is how you avoid a piece of “simple test plumbing” turning into an unofficial messaging platform.
Choose the verification channel deliberately
There are really two separate problems here, email and SMS, and they fail in different ways.
Email verification
Email is usually the easier channel to test because the message is text-rich and often contains a clickable link or a numeric code. The common patterns are:
- Account activation email with a tokenized link
- Password reset email with a one-time link
- Magic link login
- MFA fallback or recovery email
For automated testing, email has one major advantage, you can poll a mailbox over IMAP and inspect the full MIME content if you need to. That makes it possible to verify subject, sender, body, and time window without guessing.
SMS verification
SMS is simpler in content but harder in infrastructure. The message body is usually short, sometimes just a code. But you do not get IMAP-style mailbox access to a phone number. With Twilio, teams typically retrieve messages through Twilio APIs, not by pretending the phone is a local inbox.
That difference matters. Email verification can usually be built around mailbox polling. SMS verification is better treated as a message lookup problem against a provider API, with careful filtering by destination number and time window.
A reliable architecture for the harness
A robust harness has four parts:
- A unique test identity, usually an email address and phone number or test destination.
- A trigger step that drives the application flow.
- A retrieval step that waits for the verification message.
- A parsing step that extracts the code or link.
At a minimum, store these fields for each test run:
- test run ID
- user email or alias
- phone number or destination identifier
- request timestamp
- message provider response metadata
- extracted token or URL
That metadata is not decoration. It is what keeps you from debugging a failure with half a browser screenshot and a hunch.
A sensible inbox strategy
For email, you have a few implementation options:
- Dedicated mailbox per environment
- Plus-addressing aliases like
qa+run123@example.com - Catch-all domain forwarding into one IMAP inbox
- Provider-specific inbound parsing
Which one is right depends on throughput and isolation. Dedicated inboxes are easy to reason about but become expensive in operational attention if every parallel test needs one. Alias-based routing is usually the sweet spot for medium scale. Catch-all inboxes are convenient but can be noisy if you do not tag and filter aggressively.
A sensible SMS strategy
For SMS, the simplest approach is to reserve test numbers or use provider-side message retrieval. Twilio’s APIs make it practical to search by destination number and recency. The key is to keep the test number stable enough that your harness can query it, but isolated enough that other tests cannot race for the same message stream.
Building the email path with Mailgun and IMAP
Mailgun is often used for outbound delivery and inbound message handling. For test harness purposes, it can serve two roles. First, it can be the system that sends the actual verification email from your application. Second, it can provide a controlled environment around test addresses if your application is already configured to use Mailgun.
The retrieval side usually comes from IMAP, not Mailgun itself. IMAP is the part that gives your harness mailbox access, including polling for new messages and fetching bodies. The current standard is defined in RFC 9051.
IMAP polling done properly
The mistake many teams make is polling too aggressively and matching too loosely. That creates flaky tests that pass because they found an old email or fail because the email had not arrived yet.
A better polling loop should:
- Search only after the current test start time
- Filter by sender and subject when possible
- Prefer unseen messages only if your test mailbox is dedicated
- Back off between attempts, instead of hammering the mailbox
- Stop after a clear timeout with useful diagnostics
Here is a small Node.js style polling example for the email side, using a generic IMAP client. The point is the logic, not the library choice.
javascript
async function waitForEmail(fetchMessages, predicate, timeoutMs = 120000) {
const start = Date.now();
let delay = 2000;
while (Date.now() - start < timeoutMs) { const messages = await fetchMessages(); const match = messages.find(predicate); if (match) return match;
await new Promise(r => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 10000); }
throw new Error(‘Timed out waiting for verification email’); }
The predicate should be more specific than “contains six digits.” Use sender, subject, timestamp, and target address if available. Numeric-only matching is a classic source of false positives.
Parsing links and codes
Verification emails usually contain one of three things:
- A code like
482913 - A URL with a token parameter
- A deep link that opens the app and resumes the flow
Extracting a code is straightforward. Extracting a link is better when the app supports it, because the link verifies the full navigation path. If your app embeds a one-time token in the URL, parse the URL with a real parser instead of regexing the whole body. Regexes are fine for quick extraction, but they become brittle as soon as HTML templates change.
A practical pattern is to parse the MIME body, prefer the text part, and fall back to HTML only if needed. If the HTML email wraps the link behind tracking or formatting tags, parse the anchor href attribute rather than the rendered text.
Building the SMS path with Twilio
Twilio messaging is usually easier to query than email is to poll, but that does not mean it is automatically reliable. You still need a deterministic search window and a stable destination number.
The typical retrieval logic is:
- Record the timestamp before triggering the app action
- Trigger the SMS verification flow
- Query the Twilio Messages resource for messages to the destination number after that timestamp
- Wait and retry until the message appears or the timeout expires
- Extract the code from the body
The Twilio docs explain the messaging APIs and message resources in detail, and the important thing for test harnesses is that you use those resources as your source of truth, not screenshots or manual device checks.
Example retrieval flow
from datetime import datetime, timezone
import re
CODE_RE = re.compile(r’\b(\d{6})\b’)
def extract_code(body: str) -> str: match = CODE_RE.search(body) if not match: raise ValueError(‘No verification code found’) return match.group(1)
That extraction logic looks boring, and that is a good sign. Verification codes should be boring. If they need machine learning to parse, the message format probably needs to be simplified.
Make the test identity unique every run
One of the biggest sources of flakiness is message collision. If two tests reuse the same inbox or phone number, one test can consume another test’s message. That is how you get a passing run in the morning and a “random” failure after lunch.
Use a unique identity per run where you can. Common patterns include:
qa+<run-id>@example.comfor email aliases- A per-environment test mailbox with run IDs in the expected subject line
- One Twilio test number per test suite or worker
If your team runs tests in parallel, include a run correlation ID in the app request itself, then look for that ID in the message content or metadata. That gives you a deterministic join key.
The best verification harnesses are not just waiting for messages, they are looking for the right message among many plausible ones.
Stabilize the application behavior before you automate the harness
A harness cannot rescue a verification flow that is unpredictable by design. Before automating, make sure the product behavior is testable:
- Message templates are versioned or at least change-controlled
- Subjects are distinct enough to identify a flow
- Codes or links are consistent across environments
- Expiration windows are documented and reasonable
- Retry behavior is intentional, not accidental
A verification email that can be re-sent five times in three seconds sounds convenient until your test harness matches the second email and misses the first one, or vice versa. Define what “latest valid message” means and implement that rule explicitly.
Handle the failure modes you will actually see
The hard part of this kind of harness is not the happy path. It is the failure path that leaves you with no obvious clue.
Message never arrives
Possible causes include outbound provider issues, application bugs, suppressed emails, sandbox restrictions, or a broken template. Your harness should report more than timeout. Include the exact request payload, destination, and polling window.
Message arrives too slowly
Do not assume the app is broken just because the message did not appear in 5 seconds. Use a timeout aligned to your environment and provider behavior. A short timeout in CI can be useful, but it should be based on actual test tolerance, not impatience.
Stale message is picked up
This is the classic race condition. Solve it with timestamps, run IDs, and stricter search filters. Do not use “most recent message in inbox” unless the inbox is completely dedicated to one test at a time.
Body format changes
Product teams change email templates. That is normal. Your parser should be tolerant of formatting changes and rely on content structure, not exact HTML fragments. Extract the link or code by meaning, not by page layout.
Provider throttling or rate limits
Polling too aggressively can trigger rate limiting or invite intermittent failures. Back off intelligently. Cache provider client configuration. Keep retry limits visible in logs so developers know whether they hit a real problem or a test timeout.
A thin test helper is better than a giant framework
It is tempting to build a grand framework with dozens of abstractions, but verification flows benefit from small, explicit helpers. A narrow helper layer is easier to review and easier to update when your email template changes.
A good internal API might look like this:
interface VerificationMessage {
body: string;
subject: string;
receivedAt: string;
}
async function getVerificationCode(flow: 'signup' | 'reset' | 'mfa') {
const message = await waitForRelevantMessage(flow);
return extractCode(message.body);
}
The caller should read like a business workflow, not a mailbox implementation detail. That keeps the test itself focused on product behavior.
Integrate the harness into CI without making CI miserable
Verification tests are often the first tests teams disable when CI gets slow. That is a sign the design needs work.
A few CI practices help a lot:
- Run verification flows in a separate job or stage
- Keep secrets out of logs and screenshots
- Store message metadata as artifacts on failure
- Use environment-specific mailboxes and Twilio credentials
- Parallelize only if each worker has isolated destinations
A GitHub Actions job can expose the control flow cleanly:
name: verification-flows
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test -- verification
env:
IMAP_HOST: $
IMAP_USER: $
IMAP_PASS: $
TWILIO_ACCOUNT_SID: $
TWILIO_AUTH_TOKEN: $
The CI job should not be the place where you discover that your mailbox credentials expired three weeks ago. Add credential health checks and fail early with readable errors.
When custom harness code is justified
Building this plumbing yourself makes sense when the following are true:
- Your team has multiple verification flows with different message shapes
- You need tight integration with existing CI and test data factories
- You want to assert on exact content, links, and state transitions
- Your compliance or security model requires provider-specific handling
The tradeoff is obvious, you own the maintenance. Every template change, provider API change, and auth rotation lands on your team. That is acceptable if verification is a core testing concern and the harness is used broadly enough to justify the ownership.
For teams that do not want to maintain this plumbing, a platform with native email and SMS workflows can reduce the burden. For example, Endtest, an agentic AI test automation platform,’s Email and SMS Testing supports real inboxes and phone numbers inside the test flow, which can reduce the amount of custom harness maintenance when the supported workflow fits your needs. Its self-healing capabilities can also help with locator drift in the surrounding UI, which is often where these flows become flaky after the message is retrieved. If that route is interesting, the self-healing tests overview is a useful starting point.
A practical selection checklist
Use this checklist before you write the first line of harness code:
- Can you isolate test email addresses and phone numbers per environment?
- Do you have a deterministic way to find the right message?
- Are message templates stable enough to parse safely?
- Can failures surface message metadata without exposing secrets?
- Will your CI tolerate the polling delay?
- Who owns provider credentials and rotations?
If the answer to any of these is unclear, fix that before you automate. Otherwise you will build a very efficient way to fail for reasons nobody can explain.
Final take
An email and sms verification test harness is one of those systems that looks trivial until it becomes the difference between trustworthy CI and endless manual checking. Mailgun, IMAP, and Twilio give you the necessary building blocks, but the reliability comes from the way you connect them: unique test identities, strict message matching, bounded polling, and failure logs that actually help.
Teams that need maximum control can build the plumbing themselves and keep it lightweight. Teams that want to spend less time maintaining message handling, while still testing real signup, reset, and MFA flows, should also evaluate whether a maintained platform workflow is a better fit for parts of the problem. Either way, the bar is the same, tests should prove that the user can complete the verification flow, not just that a service somewhere claims to have sent a message.