Reading time: 9 min | Topics: automated email testing, email testing API, OTP extraction, CI pipelines, private testing domains
TL;DR: Automated email testing comes down to three HTTP calls — poll an inbox endpoint until a message arrives, fetch that message by ID, then parse the body for the token or link you need. The pattern is identical whether your suite runs in Python, Java, JavaScript, C#, Go, or Postman. What changes is syntax, not architecture. This guide shows the three calls, then implements them in five languages.
Most email testing guides are written for one framework. Search for automated email testing and you get a Cypress tutorial, a Playwright tutorial, and a Selenium tutorial, each rewriting the same logic in a different dialect. That is backwards. Email testing is not a browser problem. It is an HTTP problem, and the browser is usually the wrong tool for it.
If your test suite can make an HTTP request, it can test email. That is the whole prerequisite.
Why Is Automated Email Testing Treated as Hard?
Email testing looks hard because teams start from the wrong place: a real inbox. Real inboxes need credentials, hit IMAP rate limits, collide between parallel test runs, and leak test data into someone’s actual mail. The difficulty is infrastructure, not logic. Point your app at a testing domain with an API and the problem becomes an ordinary integration test.
The specific failures teams hit with real inboxes are predictable:
- Credential management. An IMAP password in CI is a secret you now have to rotate, scope, and explain to your security team.
- Parallelism collisions. Two CI jobs testing signup at the same time both look for “Confirm your account” in the same mailbox and grab each other’s message.
- Rate limits and lockouts. Poll Gmail aggressively from a CI runner and you will get throttled, then challenged, then locked.
- Nondeterministic cleanup. Yesterday’s test messages are still sitting there, and your regex finds one of those instead of the message this run produced.
A dedicated testing domain removes all four at once. Each test generates its own inbox name, so there is nothing to collide with, nothing to clean up, and nothing to authenticate beyond a single API token.
What Are the Three Calls Behind Every Email Test?
Every automated email test, in every language, is the same three operations: poll an inbox endpoint until a matching message appears, fetch that message’s full body by ID, and parse the body for an OTP, link, or field you want to assert on. Everything else — framework glue, custom commands, fixtures — is packaging around those three calls.
Against the Mailinator API, they map to these endpoints:
| Step | Endpoint | What it returns |
|---|---|---|
| 1. Poll | GET /api/v2/domains/{domain}/inboxes/{inbox} |
Message summaries: id, subject, from, time |
| 2. Fetch | GET /api/v2/domains/{domain}/inboxes/{inbox}/messages/{messageId} |
Full message with headers and parts |
| 3. Parse | (no call — your own regex or parser) | The OTP, link, or asserted value |
Base URL is https://api.mailinator.com. Authentication is one header on every request:
Authorization: YOUR_API_TOKEN
There is a fourth endpoint worth knowing, because it removes the most fragile part of link testing:
GET /api/v2/domains/{domain}/inboxes/{inbox}/messages/{messageId}/links
That returns the links already extracted from the message, so you are not writing an HTML-parsing regex to find a confirmation URL inside a templated email.
How Do You Write the Polling Loop Correctly?
Poll on a fixed interval against a deadline, and match on something unique to this test run — not on the subject line alone. The single most common source of flaky email tests is a loop that matches the first message with the right subject, which on a shared inbox is frequently the previous run’s message.
Two rules make this deterministic:
Give every test its own inbox. Inbox names on a private domain are created implicitly on first delivery. You do not provision them. So generate one per test:
signup-{uuid}@your-team-domain.com
Now the inbox has exactly one message in it and matching is trivial.
Never use a fixed sleep. sleep(10) is either slower than you need on a good day or shorter than you need on a bad one. Poll every two seconds against a thirty-second deadline and fail with a message that says what you were waiting for.
Python
import os, re, time, requests
TOKEN = os.environ["MAILINATOR_TOKEN"]
DOMAIN = "your-team-domain.com"
BASE = "https://api.mailinator.com/api/v2"
HEADERS = {"Authorization": TOKEN}
def wait_for_message(inbox, subject_contains, timeout=30, interval=2):
"""Poll an inbox until a matching message arrives; return the full message."""
deadline = time.time() + timeout
while time.time() < deadline:
r = requests.get(f"{BASE}/domains/{DOMAIN}/inboxes/{inbox}",
headers=HEADERS, timeout=10)
r.raise_for_status()
for summary in r.json().get("msgs", []):
if subject_contains.lower() in summary["subject"].lower():
full = requests.get(f"{BASE}/domains/{DOMAIN}/inboxes/{inbox}/messages/{summary['id']}",
headers=HEADERS, timeout=10)
full.raise_for_status()
return full.json()
time.sleep(interval)
raise AssertionError(
f"No message matching '{subject_contains}' in {inbox} after {timeout}s"
)
def body_text(message):
return " ".join(part.get("body", "") for part in message.get("parts", []))
def extract_otp(message, digits=6):
match = re.search(rf"\b(\d{{{digits}}})\b", body_text(message))
if not match:
raise AssertionError(f"No {digits}-digit code found in message")
return match.group(1)
Used in a pytest case:
def test_signup_otp(app):
inbox = f"signup-{uuid.uuid4().hex[:12]}"
app.register(email=f"{inbox}@{DOMAIN}")
message = wait_for_message(inbox, "verify your account")
assert message["from"] == "noreply@yourapp.com"
app.submit_otp(extract_otp(message))
assert app.is_logged_in()
Java
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.regex.*;
public class MailinatorClient {
private static final String BASE = "https://api.mailinator.com/api/v2";
private final HttpClient http = HttpClient.newHttpClient();
private final String token, domain;
public MailinatorClient(String token, String domain) {
this.token = token;
this.domain = domain;
}
private String get(String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", token)
.timeout(Duration.ofSeconds(10))
.build();
return http.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
public String waitForMessage(String inbox, String subject, int timeoutSec)
throws Exception {
long deadline = System.currentTimeMillis() + timeoutSec * 1000L;
while (System.currentTimeMillis() < deadline) {
String list = get("/domains/" + domain + "/inboxes/" + inbox);
Matcher m = Pattern
.compile("\"id\"\\s*:\\s*\"([^\"]+)\"[^}]*" + Pattern.quote(subject))
.matcher(list);
if (m.find()) return get("/domains/" + domain + "/inboxes/" + inbox + "/messages/" + m.group(1));
Thread.sleep(2000);
}
throw new AssertionError("No message matching '" + subject + "' in " + inbox);
}
}
In production, swap the regex for Jackson and deserialize the summary list properly — the regex is here to keep the example to one file.
JavaScript / Node
const BASE = 'https://api.mailinator.com/api/v2';
const headers = { Authorization: process.env.MAILINATOR_TOKEN };
export async function waitForMessage(domain, inbox, subject, timeoutMs = 30000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${BASE}/domains/${domain}/inboxes/${inbox}`, { headers });
const { msgs = [] } = await res.json();
const hit = msgs.find(m =>
m.subject.toLowerCase().includes(subject.toLowerCase()));
if (hit) {
const full = await fetch(`${BASE}/domains/${domain}/inboxes/${inbox}/messages/${hit.id}`, { headers });
return full.json();
}
await new Promise(r => setTimeout(r, 2000));
}
throw new Error(`No message matching "${subject}" in ${inbox}`);
}
This is framework-agnostic on purpose. Wrap it in a cy.task() for Cypress, call it directly from a Playwright test, or import it into a Jest suite — the function does not care.
Go
func WaitForMessage(domain, inbox, subject string, timeout time.Duration) ([]byte, error) {
deadline := time.Now().Add(timeout)
base := "https://api.mailinator.com/api/v2"
for time.Now().Before(deadline) {
var list struct {
Msgs []struct {
ID string `json:"id"`
Subject string `json:"subject"`
} `json:"msgs"`
}
if err := getJSON(fmt.Sprintf("%s/domains/%s/inboxes/%s", base, domain, inbox), &list); err != nil {
return nil, err
}
for _, m := range list.Msgs {
if strings.Contains(strings.ToLower(m.Subject), strings.ToLower(subject)) {
return getRaw(fmt.Sprintf("%s/domains/%s/inboxes/%s/messages/%s", base, domain, inbox, m.ID))
}
}
time.Sleep(2 * time.Second)
}
return nil, fmt.Errorf("no message matching %q in %s", subject, inbox)
}
Postman and Newman
Postman handles this without any code beyond a test script. Create two requests:
GET {{base}}/domains/{{domain}}/inboxes/{{inbox}}withAuthorization: {{token}}GET {{base}}/domains/{{domain}}/inboxes/{{inbox}}/messages/{{messageId}}
Then add a retry loop to the first request’s Tests tab, using postman.setNextRequest to re-run itself until a message appears:
const msgs = pm.response.json().msgs || [];
const hit = msgs.find(m => m.subject.includes(pm.variables.get('subject')));
if (hit) {
pm.collectionVariables.set('messageId', hit.id);
postman.setNextRequest('Fetch message');
} else {
const tries = (pm.collectionVariables.get('tries') || 0) + 1;
pm.collectionVariables.set('tries', tries);
if (tries > 15) throw new Error('Timed out waiting for message');
setTimeout(() => {}, 2000);
postman.setNextRequest('Poll inbox');
}
The same collection runs headless in CI through Newman, which makes this a reasonable option for teams whose API tests already live in Postman.
Robot Framework
*** Settings ***
Library RequestsLibrary
Library String
*** Variables ***
${BASE} https://api.mailinator.com/api/v2
${DOMAIN} your-team-domain.com
*** Keywords ***
Wait For Message
[Arguments] ${inbox} ${subject}
${headers}= Create Dictionary Authorization=%{MAILINATOR_TOKEN}
Wait Until Keyword Succeeds 30s 2s
... Message Should Exist ${inbox} ${subject} ${headers}
Message Should Exist
[Arguments] ${inbox} ${subject} ${headers}
${resp}= GET ${BASE}/domains/${DOMAIN}/inboxes/${inbox} headers=${headers}
${hit}= Evaluate
... next((m for m in $resp.json()['msgs'] if '${subject}' in m['subject']), None)
Should Not Be Equal ${hit} ${None}
RETURN ${hit}
Wait Until Keyword Succeeds gives you the polling loop for free, which makes Robot Framework one of the tidier fits for this pattern.
What Should an Email Test Actually Assert?
Assert on the things that break silently in production: the sender address, the presence and shape of the token, and the destination of the CTA link. Asserting that “an email arrived” catches almost nothing — misconfigured senders, expired templates, and links pointing at staging all pass that check.
A useful assertion set for a signup confirmation:
- From address matches the expected sender, not a default or a staging address
- Subject matches the current template, so a template swap fails loudly
- OTP shape is the right length and character class, not just “a number exists”
- CTA link host points at the environment under test, not a hardcoded production URL
- Delivery time is inside your SLA — the summary’s timestamp makes this free to check
That last one is easy to skip and worth keeping. A confirmation email that arrives in ninety seconds passes a functional test and fails a real user.
How Does This Run in CI?
It runs like any other HTTP-dependent test: store the API token as a CI secret, expose it as an environment variable, and let the suite run. There is no inbox to provision, no IMAP connection to keep open, and no browser required for the email half of the test.
Two things worth configuring once:
Scope the token. Use a team token dedicated to CI rather than a personal one, so rotating it does not break someone’s local runs.
Namespace inboxes by run. Prefix inbox names with the CI run ID alongside the per-test UUID. When a test fails, you can open that exact inbox and read the message the test saw, which turns a flaky-test investigation into a thirty-second lookup.
Where Does the Browser Still Belong?
The browser belongs in the parts of the flow a user actually clicks: submitting the signup form, typing the OTP, landing on the page behind the magic link. It does not belong in the retrieval step. Driving a browser to a webmail UI to read a message is slower, flakier, and harder to debug than one HTTP call.
The clean division is: browser drives the app, API reads the mail, the test asserts across both. Our framework-specific guides apply exactly that split — Cypress, Playwright, and Selenium each use the same three calls underneath.
Frequently Asked Questions
Automated email testing is the practice of verifying email-dependent flows — signup confirmations, OTPs, password resets, magic links — inside an automated test suite rather than by hand. It works by sending application mail to a testing domain and reading it back through an API, so a test can assert on the message’s contents.
Yes. The retrieval half is pure HTTP, so any language with an HTTP client can do it. A browser is only needed where the flow requires one, such as submitting a form or following a magic link into an authenticated session.
Generate a unique inbox per test so there is nothing to collide with, poll on a short interval against a deadline instead of using a fixed sleep, and match on something specific to the run rather than the subject line alone.
Whichever your test suite already uses. The pattern is three HTTP calls, so Python, Java, JavaScript, C#, Go, PHP, and Postman all implement it the same way. Matching your existing suite matters far more than the language itself.
Fetch the full message, concatenate the text parts, and apply a regex scoped to the code’s shape — \b\d{6}\b for a six-digit numeric code. Assert on the shape before using it, so a template change fails with a clear error instead of submitting a wrong value.