If your product sends sign-up links, password resets, OTPs, magic links, or delivery notifications, the test surface does not end at the API response. The hard part is usually what happens after the email leaves your app, because the test now has to observe a second system, wait for delivery, parse content, and decide whether the message really matches the user journey you intended.

That is why teams often build a test email inbox with Mailgun and IMAP. Mailgun gives you controlled message delivery and routing, IMAP gives your automation a standard retrieval path, and the combination can support email verification automation in CI or in an agentic QA workflow that needs to verify real outbound behavior.

This article shows a reproducible build path for that setup, with the tradeoffs spelled out. It assumes you want disposable test mailboxes, are comfortable maintaining a small service, and need something more realistic than mocks but lighter than a full production email operations stack.

What this setup is good for

A mailbox-based test harness is most useful when you need to validate flows like:

  • account sign-up and email verification
  • password reset links
  • magic-link login
  • notification emails, such as receipts or shipping updates
  • locale-sensitive or template-sensitive transactional email testing

It is less useful when you only need to prove that a backend job emitted an event. In that case, a message bus assertion or an outbox table check may be enough.

The key question is not “can the email be sent?”, it is “can the automation prove the user would have received the right message, at the right time, and the right content?”

That distinction matters because an inbox test adds asynchronous behavior, parsing, and cleanup. Those pieces are exactly where maintenance cost appears.

Architecture at a glance

A simple version of the system has five parts:

  1. Your application or test environment sends transactional email through Mailgun.
  2. Mailgun routes that email to a dedicated test domain or accepted recipient pattern.
  3. A disposable inbox exists for the test run, often one inbox per test or one inbox per suite shard.
  4. A poller checks the mailbox over IMAP until the expected message appears.
  5. The test parses the message, extracts a token or link, and continues the flow.

You can implement this in a few ways:

  • Mailgun Routes forwarding to a catch-all mailbox
  • Mailgun sending to a real mailbox provider that exposes IMAP
  • Mailgun’s own storage plus an API-based retrieval path, then IMAP only for the mailbox layer if your provider supports it

This article focuses on the second pattern because it matches the requested setup directly, with Mailgun as the delivery layer and IMAP as the retrieval layer.

Why Mailgun plus IMAP instead of mocks

Mocks are cheap and fast, but they only verify that your code attempted to send something. They do not prove that the rendered subject is correct, that template variables were populated, that links are unbroken, or that the message survived formatting and provider quirks.

Mailgun plus IMAP gives you a closer approximation of reality:

  • HTML and text bodies are rendered as actual message parts
  • headers are visible to the test harness
  • you can inspect MIME structure
  • template regressions become observable
  • inbox routing, foldering, and spam-like behavior can be tested in a limited way

The tradeoff is ownership. Once you own mailbox polling, you also own:

  • retries and backoff
  • MIME parsing
  • duplicate message handling
  • mailbox cleanup
  • inbox provisioning
  • provider credentials and rotation
  • test flakiness caused by eventual delivery

That maintenance overhead is the central cost of this pattern.

Step 1, create a dedicated test delivery path in Mailgun

Start by separating test email from production traffic. Use a dedicated Mailgun domain or subdomain for testing, such as test-mail.example.com, and avoid reusing operational mailboxes.

The Mailgun documentation covers domains, routing, and sending APIs in detail at the official docs site, which is the right place to confirm your exact plan and region behavior: Mailgun documentation.

A practical test-friendly configuration usually includes:

  • a dedicated sending domain
  • a sandbox or restricted recipient setup during development
  • routing rules that deliver test messages to a known inbox
  • clear separation between test and production credentials

If you control the recipient domain, one common pattern is to point test recipients to a catch-all address like test+<run-id>@mail.test.example.com. The plus-tag is not required for IMAP, but it helps with routing and correlation.

What to record in the outgoing email

To make polling reliable, include a stable correlation token in the email metadata or body. Good options include:

  • a custom header such as X-Test-Run-Id
  • the recipient alias, for example test+abc123@...
  • a link containing a short-lived token tied to the test run

Headers are often cleaner than parsing body text, because they survive HTML rewriting and content localization.

Step 2, provision disposable inboxes

Your retrieval layer needs a mailbox per test run or per isolated suite worker. The best option depends on concurrency.

One mailbox per run

This is simplest to reason about:

  • create mailbox
  • run test
  • poll until email arrives
  • delete mailbox

It is easy to clean up, but expensive in setup time if your email provider provisions mailboxes slowly.

One mailbox per worker

This is usually better for CI:

  • create one mailbox per shard or worker
  • use unique recipient aliases per test
  • clean the mailbox between runs or filter by run ID

This reduces provisioning overhead and makes parallel runs practical.

One shared mailbox with strict correlation

This is the cheapest to operate, but the easiest to get wrong. Shared inboxes can work if every message has a run ID and your poller filters carefully, but message leakage between tests becomes a real risk.

Shared mailboxes are where many flake bugs hide, because the inbox is correct but the message selection logic is not.

For agentic QA workflows, one mailbox per shard is often the best compromise. Agents can inspect the same mailbox state as the test run, and your cleanup is bounded.

Step 3, poll IMAP with explicit timeouts and message selection rules

IMAP is a standard protocol for retrieving messages from a mailbox, and the current standard is documented in RFC 9051. Most mailbox providers support a compatible subset that is sufficient for Test automation.

The poller should do three things well:

  1. wait for delivery with a bounded timeout
  2. select the correct message among all visible messages
  3. parse the message safely and deterministically

A minimal polling loop looks like this in Python:

import imaplib
import email
import time

IMAP_HOST = “imap.example.com” USER = “test@example.com” PASSWORD = “secret” RUN_ID = “abc123”

def find_message(): with imaplib.IMAP4_SSL(IMAP_HOST) as M: M.login(USER, PASSWORD) M.select(“INBOX”) typ, data = M.search(None, f’(HEADER X-Test-Run-Id “{RUN_ID}”)’) ids = data[0].split() if not ids: return None latest = ids[-1] typ, msg_data = M.fetch(latest, “(RFC822)”) raw = msg_data[0][1] return email.message_from_bytes(raw)

deadline = time.time() + 60 msg = None while time.time() < deadline and msg is None: msg = find_message() if msg is None: time.sleep(3)

if msg is None: raise TimeoutError(“Verification email did not arrive in time”)

print(msg[“Subject”])

This is intentionally small, because the hidden complexity is not the network code, it is the retrieval policy.

Better message selection rules

Do not select the first email in the inbox. Select by multiple signals:

  • recipient address
  • custom header
  • subject prefix
  • sender address
  • timestamp window

If you expect multiple emails in one run, include a semantic key in the body, for example the flow name or step ID.

A useful selection rule might be:

  • same recipient alias
  • X-Test-Run-Id matches the current run
  • date is newer than the test start time
  • subject contains Verify your email

That combination greatly reduces false positives when the mailbox is noisy.

Transactional email testing usually needs one of two extractions:

  • an OTP or numeric code
  • a confirmation link or reset URL

Email bodies can be multipart, HTML, plain text, or a mixture of both. Do not assume the first payload is the one you want.

Here is a simple pattern for extracting a verification link from multipart content:

import re

def get_body(msg): if msg.is_multipart(): for part in msg.walk(): ctype = part.get_content_type() disp = str(part.get(“Content-Disposition”, “”)) if ctype == “text/plain” and “attachment” not in disp: return part.get_payload(decode=True).decode(part.get_content_charset() or “utf-8”) return msg.get_payload(decode=True).decode(msg.get_content_charset() or “utf-8”)

body = get_body(msg) match = re.search(r”https://[^\s>]+/verify\?token=[A-Za-z0-9_-]+”, body) if not match: raise ValueError(“Verification link not found”)

verification_url = match.group(0)

A few practical notes:

  • prefer plain text bodies when you can, because they are easier to parse and review
  • if you must parse HTML, use a real HTML parser rather than regex
  • normalize URL encoding before passing the link to a browser step
  • be careful with line wrapping in MIME-encoded content

For OTPs, extract the numeric token with a constrained regex and verify length. If the expected token is six digits, reject anything else.

Step 5, drive the browser or API from the retrieved email

Once you have the link or code, hand it back to the rest of the test.

Example with Playwright

import { test, expect } from '@playwright/test';
test('sign up and verify email', async ({ page }) => {
  await page.goto('https://app.example.com/signup');
  await page.fill('#email', 'test+abc123@mail.test.example.com');
  await page.fill('#password', 'StrongPassw0rd!');
  await page.click('button[type=submit]');

const verificationUrl = process.env.VERIFICATION_URL!; await page.goto(verificationUrl); await expect(page.getByText(‘Email verified’)).toBeVisible(); });

In practice, the IMAP poller and browser runner are often separate processes. That separation is good, because it makes polling timeouts and browser failures easier to diagnose independently.

Step 6, clean up aggressively

Mailbox cleanup is not optional. Without it, old messages become false positives and your inbox gets harder to reason about over time.

Cleanup should cover:

  • deleting test messages after the run
  • expiring inboxes or aliases tied to failed runs
  • rotating credentials if the mailbox is reused
  • removing any temporary forwarding rules

If the mailbox provider supports folders or labels, move consumed messages out of the inbox into an archive folder so subsequent polls only inspect fresh messages.

A common failure mode is the test passing because it found a previous run’s email with the same subject. This is especially likely if the inbox is shared and the assertion is only based on the subject line.

Common failure modes and how to debug them

Delivery delay versus delivery failure

Your test should distinguish between “not here yet” and “never arrived.” Use a fixed deadline and log every polling attempt with timestamps. If possible, record the message IDs or search result count on each poll.

A useful debug log looks like this:

text [00:00] connected to IMAP [00:01] mailbox selected, 0 matches for run_id=abc123 [00:04] mailbox selected, 0 matches for run_id=abc123 [00:07] mailbox selected, 1 match, fetching message

That kind of log makes it obvious whether the problem is latency, routing, or parsing.

Message landed in spam or a different folder

IMAP usually sees folders explicitly, so if the provider sorts messages away from INBOX, your poller needs to search those folders too. Decide whether the test treats non-INBOX delivery as failure or acceptable behavior.

HTML changed, parser broke

This is common when templates are edited. Prefer extracting a stable URL token from the plain-text part or a custom header. If you must parse HTML, treat selector changes as a maintenance task.

Duplicate sends

Retries in your application can create multiple identical emails. Your poller should select the newest message or the message with the matching run ID, not just any matching subject.

Parallel CI interference

If two jobs share a mailbox and use the same alias pattern, they can race. Solve this with per-worker mailboxes or unique run IDs in every address and header.

Maintenance overhead you should expect

This pattern is straightforward to build, but not free to keep alive.

You will likely own:

  • mailbox credentials and secrets management
  • provider changes to IMAP behavior
  • parsing code for MIME and HTML
  • CI flakiness triage around async delivery
  • cleanup jobs for messages and test inboxes
  • observability for polling latency and failure reasons

That is manageable for teams that already own testing infrastructure. It is less attractive when the team wants the email path covered but does not want to maintain mailbox plumbing as a product.

The total cost is not just code. It is review time, debugging time, and the concentration risk of having one person who understands why message selection is fragile.

When this pattern is the right choice

A Mailgun plus IMAP harness is a good fit when:

  • you need high-fidelity verification of real email content
  • your app has a small number of critical email journeys
  • you want to keep the flow inside your existing test stack
  • your team can tolerate some infrastructure ownership
  • you need to validate provider-specific behavior, such as headers or content formatting

It is probably too much when:

  • your only goal is “did the email get generated?”
  • you need many disposable inboxes with low friction
  • your team does not want to own polling, parsing, and cleanup code
  • you need this capability across many suites and many environments

A pragmatic alternative for teams that do not want to own the plumbing

If mailbox polling starts to look like its own product, it is worth looking at a maintained platform that handles message retrieval and evidence capture for you. One example is Endtest, an agentic AI test automation platform,’s email and SMS testing, which is designed to use real inboxes and extract codes or links without making your team build the mailbox layer from scratch.

That is not a reason to abandon custom infrastructure automatically. It is simply a reminder that the hardest part of this problem is often the long-term maintenance of polling, parsing, retries, and stored evidence, not the first working prototype.

Practical decision checklist

Before you implement a test email inbox with Mailgun and IMAP, ask these questions:

  • Do we need real-message fidelity, or would a mail API assertion be enough?
  • Can we reliably isolate test runs by inbox, alias, or run ID?
  • What is our timeout policy for delayed delivery?
  • How will we detect and ignore stale messages?
  • Who owns mailbox cleanup and credential rotation?
  • What evidence do we need when a test fails, subject, body, headers, or raw MIME?

If you can answer those clearly, the implementation is usually manageable.

Closing thought

A disposable inbox is a useful piece of test infrastructure because it turns an asynchronous side effect into something your automation can observe. Mailgun handles the outbound path, IMAP gives you a standard retrieval mechanism, and careful correlation logic turns the mailbox into a reliable assertion point.

The catch is that the inbox is not just a test fixture, it is a small integration system. The more your flows depend on email, the more important it becomes to treat message selection, cleanup, and failure reporting as first-class engineering work.

For teams that want that control, this pattern is solid. For teams that want the coverage without building and maintaining the plumbing, a maintained agentic QA platform is often the simpler path.