Home / Guides / Read a verification code from an email in a test

Read a verification code or magic link from an email in an automated test

Your end-to-end test signs up a new user, and the app sends a six-digit code or a "confirm your email" link. The test has to read that email before it can go on. This guide shows the free way to do it with a local mail catcher and a short script, the details that make these tests flaky, and where the local approach stops.

Short answer.

Point the app's SMTP settings at a local catcher such as Mailpit. It keeps every message and has an HTTP API. The test polls the API until a message to its own address arrives, then pulls the code or link out of the decoded text or HTML with a regular expression. That works well when the app and the catcher run next to the test. When the email comes from a deployed test or staging system, a hosted test inbox such as MailPier Inbox receives it instead and returns the codes and links already extracted.

The free way: catch the mail locally with Mailpit

Mailpit is a free, open-source mail catcher: one program with an SMTP server on port 1025 and a web inbox and REST API on port 8025. It accepts every message and delivers none of them. Download the Windows, Linux or macOS build from its GitHub releases page and start it, or run the official Docker image:

# Windows: unzip mailpit-windows-amd64.zip from the releases page, then
.\mailpit.exe --smtp 127.0.0.1:1025 --listen 127.0.0.1:8025

# Docker
docker run -d --name=mailpit -p 8025:8025 -p 1025:1025 axllent/mailpit

Then change only the test configuration of your app: SMTP host localhost, port 1025, no TLS, no login. Open http://localhost:8025 and you see every email the app sends.

Wait for the email and read the code (PowerShell)

Two Mailpit API calls do the work. GET /api/v1/search?query=... finds messages with filters such as to:"address" and subject:"text", newest first. GET /api/v1/message/{ID} returns one message with its Text and HTML parts already decoded. These functions wrap them:

# Wait-TestEmail.ps1: wait for an email in Mailpit, then pull out the one-time code and a link.
# Dot-source it in a test:  . .\Wait-TestEmail.ps1

function Wait-MailpitMessage {
    param(
        [Parameter(Mandatory)][string]$To,
        [string]$Subject,
        [int]$TimeoutSeconds = 60,
        [string]$Mailpit = 'http://localhost:8025'
    )
    $query = 'to:"{0}"' -f $To
    if ($Subject) { $query += ' subject:"{0}"' -f $Subject }
    $search   = "$Mailpit/api/v1/search?limit=50&query=" + [uri]::EscapeDataString($query)
    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    while ((Get-Date) -lt $deadline) {
        $result = Invoke-RestMethod -Uri $search
        # Newest first. to: also matches longer addresses that contain $To, so check the exact address.
        $hit = $result.messages | Where-Object { @($_.To | ForEach-Object { $_.Address }) -contains $To } | Select-Object -First 1
        if ($hit) {
            # The full message, with its decoded Text and HTML parts.
            return Invoke-RestMethod -Uri "$Mailpit/api/v1/message/$($hit.ID)"
        }
        Start-Sleep -Milliseconds 500
    }
    throw "No email to $To arrived within $TimeoutSeconds seconds."
}

function Get-EmailCode {
    # The first match of the pattern (or its first group) in the text part, else in the HTML without its tags.
    param([Parameter(Mandatory)]$Message, [string]$Pattern = '\b\d{6}\b')
    $body = if ($Message.Text) { [string]$Message.Text }
            else { [System.Net.WebUtility]::HtmlDecode(([string]$Message.HTML -replace '<[^>]+>', ' ')) }
    $m = [regex]::Match($body, $Pattern)
    if (-not $m.Success) { throw 'No code found in the email.' }
    if ($m.Groups.Count -gt 1) { $m.Groups[1].Value } else { $m.Value }
}

function Get-EmailLink {
    # The first link that contains the given text: href values from the HTML part, else URLs in the text part.
    param([Parameter(Mandatory)]$Message, [string]$Contains = '')
    $links = @([regex]::Matches([string]$Message.HTML, 'href\s*=\s*["'']([^"'']+)["'']') |
               ForEach-Object { [System.Net.WebUtility]::HtmlDecode($_.Groups[1].Value) })
    if ($links.Count -eq 0) {
        $links = @([regex]::Matches([string]$Message.Text, 'https?://[^\s<>"]+') | ForEach-Object { $_.Value })
    }
    $link = $links | Where-Object { $_ -like "http*$Contains*" } | Select-Object -First 1
    if (-not $link) { throw "No link containing '$Contains' found in the email." }
    $link
}

A test then gives each run its own address, triggers the email and reads it:

. .\Wait-TestEmail.ps1
$address = 'signup-{0}@example.test' -f [guid]::NewGuid().ToString('N').Substring(0, 8)

# ... your test signs up with $address; the app sends to Mailpit on localhost:1025 ...

$mail = Wait-MailpitMessage -To $address -Subject 'Verify' -TimeoutSeconds 30
$code = Get-EmailCode -Message $mail -Pattern '(?i)code is (\d{6})'
$link = Get-EmailLink -Message $mail -Contains '/verify'

The same in JavaScript (Playwright, Jest, Vitest)

For a Node.js test runner (Node 18 or later has fetch built in), the same polling loop is a small module:

// mailpit.mjs: wait for an email in Mailpit and read the code and link (Node 18+, built-in fetch).
const MAILPIT = process.env.MAILPIT_URL ?? 'http://localhost:8025';

export async function waitForEmail(to, { subject, timeoutMs = 60_000 } = {}) {
  let query = `to:"${to}"`;
  if (subject) query += ` subject:"${subject}"`;
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(`${MAILPIT}/api/v1/search?limit=50&query=${encodeURIComponent(query)}`);
    if (!res.ok) throw new Error(`Mailpit search failed: ${res.status}`);
    const { messages } = await res.json();
    // Newest first; to: also matches longer addresses, so check the exact one.
    const hit = messages.find(m => m.To.some(a => a.Address.toLowerCase() === to.toLowerCase()));
    if (hit) return (await fetch(`${MAILPIT}/api/v1/message/${hit.ID}`)).json();
    await new Promise(r => setTimeout(r, 500));
  }
  throw new Error(`No email to ${to} arrived within ${timeoutMs} ms`);
}

// In a test:
//   const mail = await waitForEmail(address, { subject: 'Verify' });
//   const code = mail.Text.match(/\b\d{6}\b/)?.[0];
//   const link = [...mail.HTML.matchAll(/href="([^"]+)"/g)]
//     .map(m => m[1].replaceAll('&amp;', '&'))
//     .find(href => href.includes('/verify'));

Pitfalls that make these tests flaky

  • Poll with a deadline, never a fixed sleep. Mail can take a few hundred milliseconds or several seconds, depending on queues and retries in the app. A fixed Start-Sleep 5 is either too short on a busy build agent or wastes time on every run. Poll every half second and fail with a clear message when the deadline passes.
  • One address per test. Parallel tests, retries and earlier runs all leave mail behind. Put a random part in every address (signup-3f9c1a7e@example.test) and look for that exact address. Mailpit's to: filter also matches a longer address that contains yours (presignup-3f9c1a7e@example.test), which is why both scripts compare the address in the results.
  • Several messages to the same address. A resend or a retried step sends a second code, and only the newest is valid. The search returns the newest message first, so the scripts take the first exact match. Delete old mail between runs with DELETE /api/v1/search?query=... or DELETE /api/v1/messages.
  • HTML and text parts. Many emails have both, some only HTML (Mailpit then fills Text with a plain version of the HTML). Links in HTML are written with &amp; in place of &, so decode them before you open the link, and strip the tags before you search HTML for a code (<b>735102</b>).
  • Quoted-printable. Email bodies are often encoded: = becomes =3D and long lines are broken with a trailing =, which splits URLs and tokens in two. Read the decoded parts from the API, never the raw source.
  • Match the code by its wording. \b\d{6}\b also matches an order number or a reference in the same email. Anchor the pattern to the text around the code, for example (?i)code is (\d{6}), and keep it in step with the template.

Where a local catcher stops

Mailpit is the right tool while the app, the catcher and the test run together. It gets harder when they do not:

  • CI agents. Every job has to start Mailpit (a service container or the binary) and configure the app under test to send to it. That works for an app the job starts itself. It does not help when the test drives a system that is already deployed.
  • Shared test and staging environments. A deployed app sends through its own SMTP server, not to localhost on your build agent. A shared Mailpit for the environment means someone hosts it, secures its web inbox and API, keeps it running and gives every test run and every tester access to everyone's mail.
  • Mail sent by a real system. When the test system sends through a real email provider to real-looking addresses, the mail goes out over the internet. A local catcher never sees it, and reading a real mailbox means IMAP or a mail provider's API, sign-in and MIME parsing in every test.

A hosted test inbox with the codes already extracted

MailPier Inbox receives the mail on the internet instead of on your machine. Every account has addresses such as anything@qa.acme.mailpier.io for its qa environment, so a deployed system can send to them like to any other address, and the test reads the mail over HTTPS. Its wait call is a long poll (up to 120 seconds) that returns the message as JSON with codes and links already pulled out, so the test needs no regular expression and no MIME parsing:

$api     = 'https://api.mailpier.io/v1'
$headers = @{ Authorization = "Bearer $env:MPI_KEY" }   # an API key of the environment, kept as a secret
$since   = [DateTime]::UtcNow.ToString('o')
$address = (Invoke-RestMethod -Method Post -Uri "$api/addresses" -Headers $headers).address

# ... your test signs up with $address, so the app sends its verification email there ...

$wait = "$api/messages/wait?timeoutSeconds=60&to=" + [uri]::EscapeDataString($address) +
        '&subject=Verify&since=' + [uri]::EscapeDataString($since)
$mail = Invoke-RestMethod -Uri $wait -Headers $headers -TimeoutSec 90
if (-not $mail) { throw "No email to $address within 60 seconds." }   # 204 = nothing arrived

$code = $mail.codes | Select-Object -First 1
$link = ($mail.links | Where-Object { $_.href -like '*/verify*' } | Select-Object -First 1).href

codes holds numbers and short letter-and-digit codes that stand next to words such as code, verification, OTP or PIN, plus numbers in the subject line, subject first. links holds every http and https link of the HTML anchors and the text, each with its href and link text. Pass since (the time before your test triggered the email) so an older message to the same address is never returned.

Questions

How do I get a verification code from an email in a Playwright or Selenium test?

Send the app's test email to a mail catcher such as Mailpit instead of a real mailbox. The test polls the catcher's HTTP API for a message to its own address, reads the decoded text or HTML part, and takes the code with a regular expression. Use a new address for every test and a deadline instead of a fixed sleep.

Why does my regular expression miss the link in the email?

Usually because it reads the raw message. Email bodies are often quoted-printable: an equals sign becomes =3D and long lines are broken with a trailing =, which splits URLs. Read the decoded HTML or text part from the catcher's API instead, and decode & in HTML links to & before you open them.

Can parallel tests share one Mailpit?

Yes, if every test sends to its own unique address, for example with a random part from a GUID, and reads only mail to that exact address. Mailpit's to: filter also matches longer addresses that contain yours, so compare the address in the search results, as the scripts in this guide do.

Can I read the email when the app runs on a deployed staging server?

Only if that server sends to a catcher your test can reach. A local Mailpit on the build agent never sees mail a deployed system sends through its own SMTP server. You then need a catcher hosted for that environment, or a hosted test inbox such as MailPier Inbox, whose addresses receive mail from anywhere and whose API the test reads over HTTPS.

Does MailPier Inbox extract the code for me?

Yes. The message JSON from its API, including the wait call, has a codes list (numbers and short codes next to words such as code, verification, OTP or PIN, plus numbers in the subject) and a links list with each link's address and text. A new account starts with a 14-day Team trial, up to 5,000 messages, no card needed.

Related

Sources: Mailpit API v1, Mailpit search filters, Mailpit runtime options, Mailpit Docker images, Mailpit releases, MailPier Inbox quick start and API reference.

An unhandled error has occurred. 🗙