AI agents are getting better at driving browsers and APIs, but email verification is still a surprisingly messy boundary. An agent can create an account, request a code, and wait for a message. The trouble starts when several runs share an inbox, a retry consumes the first token, or an old message looks like a fresh success.
The usual response is to add a longer timeout. That helps sometimes, but it does not solve ownership. A more easier model is to give every test run a short-lived mailbox lease, a message cursor, and a failure receipt.
This is useful for teams building Automation around signup flows, AI test agents, and Developer Tools. It also gives a clean way to reason about searches such as get temporary email or temp mail for facebook: the phrase may describe a user journey, but a test still needs a controlled address and an explicit lifecycle.
Why AI email tests need ownership
An AI-driven test often has more moving parts than a normal unit or API test:
- The agent decides which button or endpoint to use.
- The application queues an email asynchronously.
- A mailbox provider exposes messages through an API or UI.
- The agent extracts a link or one-time code.
- A retry may repeat only part of the workflow.
When these steps use a shared inbox, the test cannot tell whether it found its own message. The result is a flaky test that sounds random: “verification sometimes passes.” In reality, the data has no owner.
The same boundary is useful when checking an email outbox in PostgreSQL. The database can prove that a message was created, while the mailbox lease proves which run is allowed to consume it. For a wider CI example, compare this with contract-tested signup emails, where the delivery contract is checked before a browser has to interpret the message.
The mailbox lease contract
A lease is simply a record that says: this run owns this mailbox until a known expiry time. A practical lease contains:
lease_id = lease_01J...
run_id = ci_8472
mailbox = unique address for this run
issued_at = 2026-10-09T01:00:00Z
expires_at = 2026-10-09T01:15:00Z
status = active
The rules are small, but they is important:
- A mailbox has at most one active owner.
- The run records the lease before sending the signup request.
- The consumer filters messages by the lease creation time.
- Expired leases cannot be used by retries without an explicit renewal.
- Cleanup is idempotent, so repeating it is safe.
Do not put the mailbox message body into an AI prompt unless the test actually needs it. A subject, message ID, timestamp, and redacted link are often enough. This keeps the test useful while limiting accidental exposure of tokens or personal-looking data.
A small implementation pattern
The test harness can keep the lifecycle in one object. The exact provider API will differ, but the control flow should stay boring:
lease = mailboxes.acquire(run_id=run_id, ttl_seconds=900)
try:
cursor = mailboxes.cursor(lease.mailbox)
app.create_account(email=lease.address, password=test_password)
message = mailboxes.wait_for(
lease.mailbox,
after=cursor,
subject="Verify your account",
timeout_seconds=120,
)
assert message.run_id == run_id
browser.open(extract_verification_url(message))
finally:
mailboxes.release(lease.lease_id)
The cursor matters as much as the address. If a retry asks for “the newest message,” an old token can win a race against the new one. A cursor says “only messages observed after this point,” which is a much stronger contract.
For backend teams, PostgreSQL outbox checks can complement the mailbox assertion. The outbox check answers “did the application enqueue the message?” The mailbox check answers “did the run receive the expected message?” Those are different signals and should not be collapsed into one vague wait.
Make the message cursor explicit
A cursor can be a provider message ID, an observed timestamp, or a monotonic sequence returned by the mailbox adapter. Message IDs are usually the clearest option. Timestamps alone can be ambiguous when several messages arrive in the same second.
If the provider does not offer a cursor, store a set of message IDs before the action and ignore those IDs afterward. It is less elegant, but still better than selecting the newest item without context. A small adapter can hide this detail from the agent and return only messages that belong to the current lease.
A useful error should say what happened:
{
"run_id": "ci_8472",
"lease_id": "lease_01J...",
"mailbox": "redacted",
"cursor": "msg_1021",
"expected_subject": "Verify your account",
"observed_messages": 3,
"failure": "no matching message before deadline"
}
This receipt make an AI agent much better at recovery. It can renew a lease, inspect the outbox, or report a provider delay instead of blindly clicking the flow again. In test logs, a phrase like temp gamil com or tempail mail may appear because a search fixture is intentionally testing messy user input; keep those values as data, never as an inbox owner.
Failure receipts are part of the test
A failed email test should preserve enough context to explain the failure without preserving sensitive content. Keep the run ID, lease ID, provider request ID, message IDs, timestamps, and redacted URLs. Avoid logging full verification links or complete email bodies.
This also helps distinguish four common failures:
- The application never enqueued a message.
- The provider accepted it but delivery was delayed.
- The message arrived outside the cursor boundary.
- The agent extracted the wrong link or code.
Those failures need different fixes. A generic timeout hides that distinction and makes the next retry less useful.
Cleanup, retention, and safe boundaries
Release the lease in a finally block, then run a bounded cleanup job for leases that expired during a crashed worker. Keep a short retention window for metadata so a failed CI run can be investigated. Delete message bodies earlier when possible.
The lease should be long enough for a slow runner, but short enough that abandoned state does not become a shared resource. When a job resumes after expiry, create a new lease and a new cursor. Reusing an old mailbox is faster then it is safe only in a demo.
For a public signup flow, follow the target service's terms and rate limits. A disposable test inbox is a test isolation tool, not a way to bypass account controls. The same boundary keeps staging data away from real people and makes automation easier to audit.
A practical checklist
- [ ] Acquire one mailbox lease per run.
- [ ] Create the cursor before sending the email-triggering request.
- [ ] Match by run-safe attributes such as subject, recipient, and message ID.
- [ ] Record outbox and mailbox results as separate signals.
- [ ] Redact tokens, full links, and message bodies in failure logs.
- [ ] Release the lease in cleanup code and reclaim expired leases.
- [ ] Start a new lease after a retry that crosses the expiry boundary.
The main idea is simple: email is not a single wait operation. It is a resource with ownership, a cursor, and a lifecycle. Once those pieces are explicit, AI agents can test verification flows with fewer mysterious retries and much more useful evidence when something does fail.
Top comments (0)