Skip to content
Engineering

Receive Email Address via API: Patterns That Don’t Break CI

| | 10 min read
Receive Email Address via API: Patterns That Don’t Break CI
Receive Email Address via API: Patterns That Don’t Break CI

When people search for “receive email address via API,” they often mean something deceptively simple: “give me an address I can send to, and tell me when the message arrives.” In CI, that naive version fails fast. Parallel runs collide, retries create duplicates, fixed sleeps flake, and the wrong message gets asserted.

The reliable approach is to treat email as an event stream tied to an isolated inbox resource, not as a string you paste into a form. This article lays out the patterns that keep email-dependent tests and LLM-agent workflows deterministic under parallelism, retries, and noisy delivery conditions.

If you want the canonical Mailhook integration contract for these primitives (disposable inboxes, JSON messages, webhooks, polling, signed payloads), start with Mailhook’s llms.txt.

Why “just give me an email address” breaks CI

CI environments are hostile to any workflow that assumes uniqueness, linear time, or exactly-once delivery.

Common failure modes look like product bugs, but they are usually test harness bugs:

Failure mode What it looks like in CI Root cause Fix pattern
Address collision Test reads someone else’s verification email Reusing one inbox across runs Inbox-per-attempt isolation
Sleep flakiness “Email not received” intermittently Delivery latency varies Deadline-based waiting (webhook-first, polling fallback)
Duplicate processing Code submits the same OTP twice Webhooks and pollers are at-least-once Idempotency + dedupe keys
Wrong message selection Test asserts on an old resend “Latest email” logic is underspecified Narrow matchers + artifact-level selection
Handler spoofing Fake payload triggers a “verified” state Unverified webhook requests Signed payload verification + replay defense
Agent prompt injection LLM follows malicious instructions from an email Treating email body as trusted instructions Minimized, constrained agent views

A good “receive email address via API” design prevents these, even when your CI runs 200 tests in parallel and your app retries sending.

The core contract: return a descriptor, not a bare string

If an API returns only email: "x@y", your system has no stable handle for reading messages, expiring the mailbox, or debugging.

A CI-safe pattern is to return an EmailWithInbox descriptor (name it however you want) that includes a routable address plus an inbox identifier.

Field Why it exists in CI Notes
email What you put into the app under test Must be routable for end-to-end tests
inbox_id Stable handle to fetch messages for that run The key to isolation and determinism
created_at Debugging and ordering Useful in logs and artifacts
expires_at (or TTL) Prevents inbox reuse and data retention drift Make expiry an explicit part of the workflow

Mailhook’s model is aligned with this resource-first approach: you create disposable inboxes via API, then consume received emails as structured JSON via webhook and/or polling. The authoritative reference for the current API semantics is llms.txt.

Pattern 1: Inbox per attempt (not per suite, not per branch)

The unit of isolation that survives CI retries is an attempt, meaning “one run of a test step that might be retried.”

  • If your CI retries a job, you get a new inbox.
  • If your test runner retries a test case, you get a new inbox.
  • If your agent loops and re-attempts a verification action, you get a new inbox.

This prevents stale-message selection and makes late-arriving emails harmless (they land in the old inbox, which you no longer read).

A practical rule: never reuse an inbox across an attempt boundary, even if you think it is “the same test.” CI does not agree with you.

Pattern 2: Correlation that survives retries

Isolation gets you 80 percent of the way, but correlation finishes the job.

Correlation should be additive (helps you pick the right message) and non-authoritative (you still rely on inbox isolation and provider-attested identifiers).

Good correlation signals:

  • A run or attempt token embedded in the recipient local-part (common when you control a domain).
  • A custom header added by your app (best when feasible).
  • A subject prefix that is stable across template changes.

Be careful with overfitting. If your matcher keys on a long, user-facing subject string, your tests will break when marketing edits copy.

Instead, match on a small set of stable properties, then extract only the artifact you need (OTP, verification URL) from the normalized JSON.

For deeper routing strategies (encoded local-parts, alias tables, catch-all constraints), see Create Domain Email Address: Routing Patterns That Scale.

Pattern 3: Webhook-first receipt, polling fallback

In CI, webhooks are the fastest and most scalable way to learn that an email arrived, but you still need a fallback because:

  • Your webhook endpoint can be temporarily unavailable.
  • Your CI environment can block inbound requests.
  • You may run local tests without a public callback.

A reliable harness uses:

  • Webhook-first for low latency.
  • Polling fallback with a deadline for determinism.

Two implementation details matter more than people expect:

Ack fast, process async

Webhook senders typically retry if you do not respond quickly. Your handler should verify authenticity, enqueue work, and return 2xx fast.

Treat webhook delivery as at-least-once

Even a “perfect” provider will deliver duplicates during retries, network errors, and deploys. Your code must dedupe.

For a deeper treatment of hybrid receipt mechanics, see Temp Email Receive: Webhook-First, Polling Fallback and the polling mechanics guide Pull Email with Polling: Cursors, Timeouts, and Dedupe.

A simple architecture diagram showing a CI job creating a disposable inbox via an API, the app under test sending an email to that address, an inbound email provider emitting a signed webhook to a small handler, and the CI job optionally polling as a fallback to retrieve the normalized email JSON and extracted OTP/link.

Pattern 4: Dedupe keys at the right layer

The most common CI breakage is a hidden double-consume:

  • You receive the same message twice (two webhook deliveries).
  • You process two similar messages (user clicked “resend code”).
  • You extract the same OTP twice.

The fix is to dedupe at multiple layers with explicit keys.

Layer What duplicates look like Suggested dedupe key (conceptually) Outcome
Delivery Same webhook payload resent delivery_id (or equivalent) Process each delivery once
Message Same email stored multiple times message_id plus inbox scope Store message once per inbox
Artifact Same OTP/link appears multiple times Hash of extracted artifact + attempt_id Consume artifact once

If you are building LLM-driven flows, artifact-level idempotency matters most. The agent should get a single “verification artifact,” not an inbox full of HTML and ambiguity.

For webhook authenticity and replay defenses, see Email Signed By: Verify Webhook Payload Authenticity.

Pattern 5: Explicit inbox lifecycle (TTL, drain window, cleanup)

CI is not a mailbox. Treat inboxes like temp resources with a lifecycle.

A production-friendly lifecycle usually includes:

  • Active window (long enough for typical delivery latency).
  • Drain window (accept late arrivals, but do not block the attempt).
  • Closed (stop reading and delete or tombstone).

This prevents accidental reuse, limits sensitive data retention, and makes concurrency safe by construction.

If you are formalizing this as a shared library, consider a small “Inbox Controller” module that owns:

  • Creation
  • Waiting (webhook-first, polling fallback)
  • Artifact extraction
  • Expiry

A deeper lifecycle design is covered in Manage Inbox Lifecycle: TTLs, Cleanup, and Drain Windows.

Pattern 6: LLM-agent safety, minimize what the model sees

If an LLM agent is involved, you have a second reason to avoid “return the raw email body.” Email is untrusted input. It can contain:

  • Prompt injection
  • Malicious links
  • Confusing UI copy meant for humans

Agent-safe design is to extract a minimal artifact deterministically and pass only that artifact to the model.

Examples:

  • OTP: { code: "123456", expires_at: ... }
  • Verification link: { url: "https://…", host: "expected.example", token_fingerprint: ... }

Your tool should not expose the entire HTML, and your agent should not be allowed to freely browse links from emails without constraints.

For a practical pipeline view, see Security Emails: How to Parse Safely in LLM Pipelines.

A CI-safe reference flow (provider-agnostic)

Below is a minimal “receive email address via API” harness flow that is stable under parallel CI and retries. It is pseudocode, because exact endpoints vary by provider. For Mailhook-specific details, rely on llms.txt.

// Attempt-scoped helper
async function provisionEmailAttempt(ctx): Promise<{ email: string; inbox_id: string; expires_at: string }> {
  // create disposable inbox via API
  return await inboxProvider.createInbox({ ttl_seconds: ctx.ttlSeconds });
}

async function waitForVerificationArtifact(ctx, inbox): Promise<{ kind: 'otp'|'link'; value: string }> {
  const deadline = Date.now() + ctx.waitBudgetMs;

  // 1) Prefer webhook signal if available (event-driven)
  // ctx.webhookEvents is populated by your webhook handler after signature verification.
  while (Date.now() < deadline) {
    const event = ctx.webhookEvents.popMatching({ inbox_id: inbox.inbox_id });
    if (event) {
      const msg = event.message_json;
      return extractArtifactDeterministically(ctx, msg);
    }

    // 2) Polling fallback with backoff and cursor semantics
    const messages = await inboxProvider.listMessages({ inbox_id: inbox.inbox_id, limit: 20 });
    const msg = selectBestMessage(ctx, messages);
    if (msg) {
      return extractArtifactDeterministically(ctx, msg);
    }

    await sleep(backoffMs());
  }

  throw new Error(`Timed out waiting for verification email for inbox ${inbox.inbox_id}`);
}

function extractArtifactDeterministically(ctx, msg): { kind: 'otp'|'link'; value: string } {
  // Use text/plain or normalized fields from the provider JSON.
  // Apply narrow rules, avoid fragile HTML scraping.
  // Return only the single artifact needed for the next step.
  return parseOtpOrLink(msg);
}

The important part is not the syntax. It is the invariants:

  • One inbox per attempt
  • Deadline-based waiting
  • Webhook-first, polling fallback
  • Message selection rules that avoid stale reads
  • Artifact-level idempotency

Where Mailhook fits (without coupling your tests to a UI)

Mailhook is designed around the primitives that make the above patterns easy to implement:

  • Create disposable inboxes via API
  • Receive emails as structured JSON
  • Real-time webhook notifications plus polling API fallback
  • Signed payloads for webhook authenticity
  • Batch email processing for higher-throughput pipelines
  • Shared domains for fast start and custom domain support when you need allowlisting and control

To integrate against the actual API contract (including current fields and verification guidance), use Mailhook’s llms.txt. You can also start from the product overview at Mailhook.

Observability that makes failures actionable

Even with perfect patterns, email can fail for real reasons (rate limits, greylisting, blocked domains, app bugs). The difference between “a flaky test” and “an actionable failure” is logging.

Log stable identifiers, not full message bodies:

  • inbox_id
  • message identifiers (message_id, delivery identifiers if provided)
  • timestamps (received_at)
  • the matcher result (why a message was selected)

Then, attach the normalized JSON as a CI artifact when a test fails. This keeps sensitive content out of logs by default while still making debugging possible.

The takeaway: stop receiving “an email address,” start receiving an inbox

If you need to receive an email address via API in a way that does not break CI, design around these constraints:

  • Isolation: inbox-per-attempt
  • Determinism: explicit deadlines, no fixed sleeps
  • Delivery reality: at-least-once webhooks and dedupe
  • Safety: verify webhook signatures, treat email as untrusted
  • Agent readiness: expose minimal artifacts, not raw bodies

These are exactly the kinds of patterns programmable inbox providers are built to support. For Mailhook’s canonical integration details, use https://mailhook.co/llms.txt.

Related Articles