Skip to content
Engineering

How to Debug Missing Verification Emails Fast

| | 14 min read
A cyberpunk night street scene focused on a single verification email incident traced across a glowing workflow line: an app trigger node, a provider handoff checkpoint, a secure inbox receipt panel, a matcher alert, and a parsed OTP result card all aligned over rain-slick pavement. Neon signage, holographic timestamps, subtle circuitry, and drifting fog surround the path, with electric cyan, hot magenta, deep purple, and warm orange reflections on wet surfaces. The composition is wide and cinematic with strong depth, noir shadows, visible light rays through haze, and organic edges fading into smoke and black.
A cyberpunk night street scene focused on a single verification email incident traced across a glowing workflow line: an app trigger node, a provider handoff checkpoint, a secure inbox receipt panel, a matcher alert, and a parsed OTP result card all aligned over rain-slick pavement. Neon signage, holographic timestamps, subtle circuitry, and drifting fog surround the path, with electric cyan, hot magenta, deep purple, and warm orange reflections on wet surfaces. The composition is wide and cinematic with strong depth, noir shadows, visible light rays through haze, and organic edges fading into smoke and black.

Missing verification emails are frustrating because they feel invisible. A user clicks “Sign up,” your test waits, an AI agent times out, and everyone starts guessing: spam folder, provider delay, broken template, bad address, flaky CI.

The fastest way to debug missing verification emails is to stop treating “email” as one black box. Split the path into observable boundaries: trigger, send, provider acceptance, routing, inbox receipt, matching, parsing, and token consumption. Once each boundary emits a small piece of evidence, the problem usually becomes obvious within minutes.

First, define what “missing” means

An email can be “missing” in several different ways. Each one points to a different owner and fix.

What you see What it usually means Fastest signal to check
No outbound event exists The app never attempted to send Application logs, job queue, feature flags
Provider rejected or deferred it The sender handed it off, but delivery stalled Email provider event logs, suppression list, bounce reason
Sent to the wrong recipient The email exists, but not where your test is looking Recipient address, envelope recipient, run ID, inbox ID
Arrived but matcher ignored it The test looked for the wrong subject, sender, or timestamp Raw message list, matcher logs, correlation token
Arrived after timeout The wait budget was too short or used fixed sleeps Received timestamp vs test deadline
OTP or link not found Parser failed, template changed, or HTML scraping broke Structured text body, artifact extraction logs
Token rejected after extraction Email arrived, but verification state changed Token age, resend history, consume-once state

This distinction matters in CI and LLM agent workflows because the agent often only sees “timeout waiting for email.” That is not enough. A useful failure should say something like: “provider accepted message at 12:00:04, inbox received it at 12:00:07, matcher rejected it because subject did not contain run_id.”

The fast triage loop

Use this loop when a verification email does not appear. It works for signup verification, OTP codes, magic links, password resets, and account confirmation flows.

1. Confirm the verification action actually triggered a send

Start at the product boundary, not the inbox. Check whether the signup, login, or reset request reached the code path that sends email.

Useful fields to log at this point include run_id, attempt_id, user_id or test user key, recipient address, verification flow type, template ID, and queue job ID if email is sent asynchronously.

Common failures at this layer are simple but easy to miss. The verification email may be behind a feature flag. The user may already be verified. A resend throttle may suppress the second attempt. The test environment may be configured to “capture” or “log only” instead of sending.

2. Check the outbound provider handoff

If your app says it sent the email, verify whether your outbound provider accepted it. You are looking for a provider event that corresponds to the attempt.

Do not rely only on your app’s “sendEmail() returned success” log. Many systems enqueue email successfully before the provider accepts the message. If you have provider events, check whether the message was accepted, deferred, bounced, suppressed, or blocked by sandbox settings.

Fast questions to ask:

  • Was the exact recipient address accepted by the provider?
  • Is the address on a suppression or bounce list?
  • Is the sender domain allowed in this environment?
  • Did the provider rewrite, drop, or delay the message?
  • Is the environment using the expected API key or sending identity?

3. Verify the recipient and routing path

A surprising number of “missing verification emails” are routing problems. The email was sent, but not to the inbox your automation is watching.

For automated tests and agents, treat the inbox as a resource, not just a string address. Store both the email address and an inbox_id or equivalent handle. Then your test can ask, “Did this specific inbox receive a message?” instead of searching through a shared mailbox.

Also remember that the SMTP envelope recipient and the visible To header are not the same thing. SMTP routing is defined around envelope commands in RFC 5321, while message headers are described separately in RFC 5322. If your system uses aliases, catch-all domains, plus addressing, or encoded local-parts, debug the envelope recipient and the final inbox mapping, not only the visible header.

4. Inspect the inbox source of truth

A human mailbox UI is a poor source of truth for automation. It can hide messages in tabs, apply filters, collapse threads, delay refreshes, or show the wrong account.

Use an API-backed inbox or structured message list instead. For each attempt, fetch messages by inbox ID, then compare received_at, sender, subject, recipient, headers, and body. If you use webhooks, keep polling as a fallback during debugging. If the webhook handler failed, polling can prove the message arrived and isolate the bug to delivery-to-code.

This is where programmable temp inboxes are especially useful. With Mailhook, you can create disposable inboxes via API and receive inbound messages as structured JSON through real-time webhooks or the polling API. That gives the test harness concrete evidence instead of a screenshot of an empty mailbox.

5. Debug the matcher before the parser

If the inbox contains the message, but your test still says “missing,” the matcher is probably too broad, too narrow, or looking at the wrong field.

A good verification-email matcher usually checks multiple facts: correct inbox ID, message received after the trigger timestamp, expected sender or sender domain, expected flow type, and a correlation token such as run_id when your template can include one.

Avoid matching only on subject. Subjects change during localization, A/B tests, and template edits. Prefer stable identifiers and scoped time windows. If your system can add a correlation header or include a run token in the email body, debugging becomes much faster.

6. Then debug OTP or link extraction

Once you know the right message arrived, inspect artifact extraction. For OTPs, the bug may be a regex that captures the wrong number, such as a year, support ticket number, or footer code. For magic links, the bug may be HTML entity encoding, URL wrapping, tracking links, or selecting an unsubscribe link by accident.

Prefer text/plain when available, and treat HTML as untrusted input. If your email API returns structured JSON, extract from a normalized text field where possible. When you do need links, validate the host, path, scheme, and expected token parameters before using them.

The “debug packet” every verification attempt should produce

Fast debugging depends on having the right fields before the failure happens. A practical debug packet does not need to store entire emails forever. It should preserve the minimum identifiers needed to trace one attempt across systems.

{
  "run_id": "ci-2026-06-03-abc123",
  "attempt_id": "signup-attempt-01",
  "flow": "signup_verification",
  "recipient": "[email protected]",
  "inbox_id": "inbox_123",
  "triggered_at": "2026-06-03T21:10:00Z",
  "provider_event_id": "provider-message-456",
  "message_id": "message-789",
  "delivery_id": "delivery-101",
  "received_at": "2026-06-03T21:10:07Z",
  "matched": true,
  "artifact_type": "otp",
  "artifact_hash": "sha256:...",
  "verification_result": "accepted"
}

The important part is not the exact field names. The important part is continuity. You want to trace one attempt from browser action to application log, provider handoff, inbox receipt, message match, artifact extraction, and final verification result.

For LLM agents, expose a minimized version of this packet. The agent usually needs to know whether a message arrived and what typed artifact was extracted. It does not need raw HTML, full headers, unrelated links, or long-lived secrets.

Common root causes and fast fixes

Symptom Likely root cause Fast fix
Works locally, fails in parallel CI Multiple tests share one inbox Create one disposable inbox per attempt
First run passes, retry fails Stale email selected from previous attempt Match by inbox ID, trigger timestamp, and correlation token
Message arrives after the test fails Fixed sleep or short timeout Use deadline-based wait with webhook plus polling fallback
Webhook never fires, but polling finds email Webhook handler, signature, or network issue Verify signed payload first, ack quickly, process async
OTP parser returns wrong code Single regex matches unrelated numbers Score candidates by context and template intent
Magic link opens wrong environment Link host not validated Allowlist expected hosts and environments
Email sent to visible address but not routed Alias, catch-all, or MX mapping issue Log envelope recipient and final inbox ID
Agent follows unsafe link Raw email exposed directly to model Extract minimal artifact and validate URL before action

The best long-term fix is usually not “increase the timeout.” Timeouts hide the problem until the suite gets slower again. Instead, make the email step observable and bounded.

A reliable wait pattern for verification emails

For automation, a verification email wait should have a clear contract: wait for a message in this inbox, after this timestamp, matching this flow, until this deadline. Webhooks give low latency, while polling gives a safety net.

Provider-neutral pseudocode looks like this:

async function waitForVerificationEmail({ inboxId, after, deadline, match }) {
  const seen = new Set();

  while (Date.now() < deadline) {
    const messages = await listMessages({ inboxId, after });

    for (const message of messages) {
      if (seen.has(message.message_id)) continue;
      seen.add(message.message_id);

      if (match(message)) {
        return message;
      }
    }

    await sleepWithBackoff();
  }

  throw new Error(`Timed out waiting for verification email in ${inboxId}`);
}

In production-grade test harnesses, the same logic often listens for a webhook first and uses polling only if no matching event arrives before the deadline. That hybrid pattern gives you speed, reliability, and better failure evidence.

With Mailhook, this maps naturally to disposable inbox creation, structured JSON email output, real-time webhook notifications, and polling fallback. For exact integration details and machine-readable capability information, see the Mailhook llms.txt reference.

Debugging custom domains and MX issues

If you use a custom domain or subdomain for verification tests, missing emails can come from DNS or routing rather than your app.

Check the basics first. Is the MX record present on the exact subdomain receiving mail? Has DNS propagated to the resolver your sender uses? Are there multiple MX records with unexpected priorities? Is the local-part format accepted by your routing rules? Is the inbox already expired or closed by the time the message arrives?

For test automation, dedicated subdomains are usually easier to reason about than primary production domains. For example, a team might route all CI verification emails through a subdomain reserved for test inboxes, while production user email remains separate. Mailhook supports shared domains for fast setup and custom domain support when teams need more control over routing and environment separation.

How to make failures actionable in CI

A CI failure should not say only “email not received.” It should include enough context for the next engineer, or agent, to know where to look.

Attach a compact email debug artifact to the failed run. Include the inbox ID, recipient, trigger timestamp, provider status if available, number of messages received, matcher rejection reasons, and whether webhook and polling disagreed. If a message matched but parsing failed, include sanitized text excerpts around candidate OTPs or links, not the full raw email.

Good failure messages look like this:

Timed out waiting for signup_verification email.
run_id=ci-abc123
inbox_id=inbox_123
[email protected]
triggered_at=2026-06-03T21:10:00Z
provider_status=accepted
messages_received=1
latest_received_at=2026-06-03T21:10:07Z
matcher_rejections=["missing run_id in body"]
webhook_events=0
polling_events=1

That tells you the email was not truly missing. It arrived, polling saw it, but the template did not include the expected run ID and the webhook path may need inspection.

Extra guardrails for AI agents and LLM workflows

LLM agents make email debugging more powerful, but they also increase risk if raw messages are handed to the model. Treat inbound email as untrusted input. A malicious email can contain prompt injection, misleading links, fake instructions, or content designed to trick an agent into taking an unsafe action.

Keep the agent interface small. A safe tool might expose create_inbox, wait_for_message, and extract_verification_artifact, where the final response contains only a typed OTP or validated verification URL. The model should not browse arbitrary email HTML, decide which link is safe, or process webhook payloads before signature verification.

For webhook-driven workflows, verify signed payloads before parsing or invoking an agent. Use idempotency keys so duplicate webhook deliveries do not cause duplicate token submissions. Mailhook supports signed payloads, which helps separate “this event came from the inbox provider” from “this email content is trustworthy.” The content itself should still be treated carefully.

When to use Mailhook for this workflow

Mailhook is designed for teams that need email to behave like an API resource. Instead of logging into shared mailboxes, you can create disposable inboxes through a RESTful API, receive emails as structured JSON, react through real-time webhooks, or poll for messages when that is simpler.

That makes it a good fit for:

  • QA suites that need isolated disposable email addresses for every verification attempt
  • LLM agents that need a safe, machine-readable way to receive verification emails
  • Signup, login, OTP, password reset, and client-operation flows that need repeatable email automation
  • CI systems where failures need inbox IDs, JSON payloads, and clear logs rather than screenshots

Mailhook also supports instant shared domains for quick starts, custom domain support for teams that need routing control, signed payloads for webhook security, and batch email processing for higher-volume workflows.

Frequently Asked Questions

Why do verification emails go missing in CI but work manually? CI usually adds parallelism, retries, shorter deadlines, and shared resources. A human checking one mailbox manually may not see collisions, stale messages, or late delivery. Use one inbox per attempt and log the full path from trigger to inbox receipt.

How long should a test wait for a verification email? Use a bounded deadline based on your environment rather than a fixed sleep. Many teams start with a short webhook wait and a polling fallback until the overall deadline. The key is to log whether the timeout happened before provider acceptance, inbox receipt, matching, or parsing.

Should I debug with a shared mailbox? Shared mailboxes are useful for humans, but they are a common source of flaky tests. For automation, create an isolated disposable inbox per attempt so messages cannot collide across tests, retries, or agents.

What if the email arrives but the OTP is not extracted? Treat that as a parser failure, not a delivery failure. Inspect the structured text body, check whether the template changed, avoid single-regex extraction, and validate candidates using context such as flow type, nearby words, length, and expiration rules.

Can an LLM agent safely read verification emails? Yes, if you expose a minimized, validated view. Do not give the model raw HTML or arbitrary links. Verify webhook signatures, parse email into structured JSON, extract only the OTP or approved verification URL, and keep idempotent logs.

Turn missing verification emails into debuggable events

If your team is still refreshing shared inboxes or adding longer sleeps to CI, the email step is under-instrumented. The faster path is to create an isolated inbox, receive the message as JSON, match it with clear rules, and log every boundary.

Mailhook gives developers and agents programmable temp inboxes, structured JSON emails, real-time webhooks, polling, signed payloads, and shared or custom domain options for verification workflows. For implementation details, start with the Mailhook llms.txt reference, then replace “email not received” with a traceable, actionable event in your next test run.

Related Articles