← Writing

How to let AI agents verify email signups

·4 min read

An AI coding agent can scaffold a signup flow in about ninety seconds. Route, form, validation, a call to Resend, a token in the database. Then it stops.

It stops because the next step is open the inbox and click the link, and the agent has no inbox. So it does one of three things: mocks the email provider and declares victory, writes a test that asserts sendEmail was called, or asks you to check your Gmail and paste the code back. All three break the loop. The agent can no longer verify its own work.

This is a tooling gap, not a model limitation. The agent can drive a browser and read a database. It just can't receive mail.

The shape of the fix

Give the agent three capabilities and the loop closes:

  1. Create a throwaway inbox address
  2. Wait for a message to arrive at it
  3. Get the OTP or magic link out of that message as a value, not as HTML to parse

That last one matters more than it looks. If you hand an agent a raw email body, it will write a regex. It will write a different regex next time, and one of them will match the unsubscribe link instead of the verification link. Extraction belongs in the infrastructure, where it's deterministic.

Wiring it up

ZeroDrop ships an MCP server that exposes exactly those three tools. Install it into any MCP-capable client:

{
  "mcpServers": {
    "zerodrop": {
      "command": "npx",
      "args": ["-y", "zerodrop-mcp"]
    }
  }
}

No API key needed for the free tier. If you have one, add it:

{
  "mcpServers": {
    "zerodrop": {
      "command": "npx",
      "args": ["-y", "zerodrop-mcp"],
      "env": {
        "ZERODROP_API_KEY": "your-key"
      }
    }
  }
}

Restart the client and the agent has three new tools.

For Claude Code, one line does the same thing:

claude mcp add zerodrop -- npx -y zerodrop-mcp

The server is also listed in the official MCP registry as dev.zerodrop/zerodrop-mcp, verified against the domain over DNS.

The three tools

generate_inbox(prefix?) — returns an address immediately. No network round trip, no registration. Pass a prefix if you want the address to be recognisable in logs.

wait_for_email(inbox, timeout_seconds, from_contains, subject_contains, require_otp, require_magic_link) — blocks until a matching message lands, up to 120 seconds. The filters are the useful part: require_otp: true means the agent waits for a message that actually contains a code, not just the first thing that arrives. Marketing footers and double-sends stop being a source of flake.

check_inbox(inbox) — non-blocking read of what's there.

Every returned message carries otp and magic_link as top-level fields, extracted at the edge before the agent ever sees the body.

What the loop looks like

Ask an agent to verify its own signup flow and it now runs something like:

generate_inbox(prefix: "signup-test")
  → "[email protected]"

[drives the browser, submits the form with that address]

wait_for_email(
  inbox: "signup-test-a91f",
  require_otp: true,
  timeout_seconds: 30
)
  → { otp: "847291", subject: "Verify your email", ... }

[types 847291 into the verification field]
[asserts the dashboard loaded]

No mock. No mail server in Docker. No human pasting codes. The agent sent a real email through your real provider and read it back.

Why not just mock it?

Because a mock tests that you called a function. It does not test that your Resend API key is valid in this environment, that the template renders, that the token in the link matches the token in the database, or that the link isn't 404ing because of a trailing-slash mismatch in production. Every one of those has shipped broken while a green mock-based suite watched.

The agent-specific version of this problem is worse. An agent that mocks its own verification step has no signal about whether the feature works, so it reports success and moves on. You find out later.

Same infrastructure, human tests too

The MCP server is a wrapper over the same API the SDKs use, so an agent and your CI suite can share the approach:

import { ZeroDrop } from 'zerodrop-client'
 
const mail = new ZeroDrop()
const inbox = mail.generateInbox()
 
await page.getByLabel('Email').fill(inbox)
await page.getByRole('button', { name: 'Sign up' }).click()
 
const email = await mail.waitForLatest(inbox, {
  timeout: 15000,
  filter: { hasOtp: true },
})
 
await page.getByLabel('Code').fill(email.otp)

SDKs exist for TypeScript, Python, Go, Ruby, PHP, and Java. The agent path and the CI path hit the same edge, so a flow an agent verified locally behaves the same way in your pipeline.

Caveats worth stating

The free sandbox uses a shared domain, which is fine for testing and wrong for anything resembling production data. Messages live for 30 minutes and then the TTL removes them — that's a hard guarantee at the storage layer, not a cleanup job. Inbound mail passes a spam filter at the edge before it's stored, so genuinely spammy sends may not land.

Try it

claude mcp add zerodrop -- npx -y zerodrop-mcp

Then ask the agent to generate an inbox and wait for a message, and send it something from your own mail client. The code comes back extracted.

Then ask your agent to sign up for something and verify itself. If it comes back with a code it read out of a real inbox, the loop is closed.