Abstract flat illustration of an open envelope releasing a row of six code dots toward a small robot head, with a padlock beside it

Email OTP for AI Agents: Read Codes Without Temp Mail

Your agent filled in the sign-up form in four seconds. Then the page said “Enter the code we sent to your email,” and the run stopped.

The agent needs an inbox it can read through an API, that nobody else can read, and that will still be there for the next login code. Temp-mail APIs miss the last two. Your own Gmail passes all three and hands the agent the rest of your mail along with them.

The quick answer: give the agent its own inbox on CarlyEmail, hand it a key that can only read that one inbox, and wait for the code over a WebSocket (or poll every two seconds). Pull the code out, submit it, carry on. The free plan covers 3 inboxes and 1,000 emails a month with no card, and received mail never counts against that quota, so an inbox that only catches codes costs nothing. Working Python is below, built on the open-source verification-codes example.

Do this only on accounts you’re authorized to operate, and don’t use it to get around security on services whose terms forbid automation.

Where the code can go

Your own inboxTemp-mail APIThe agent’s own inbox
Who can read the codeThe agent, plus every other email you haveOn public services like Mailinator, anyone who types the addressOnly keys scoped to that inbox
Accepted by the sign-up formYes, but the account is yours, and +agent variants can be blockedOften refused: disposable domains sit on shared blocklistsYes, and on your own domain it’s an ordinary company address
The next login code or password resetLands with youAddress abandoned, messages auto-deletedSame inbox, mail kept until you delete it
Who can take over the accountAnyone who steers the agent while it holds your mailboxAnyone who requests a reset to the public addressWhoever holds that inbox’s key

Why temp-mail and disposable email APIs break agent sign-ups

Public inboxes are public. Mailinator’s docs say every @mailinator.com address already exists, and its inboxes and emails are “readable and delete-able by anyone. By design, there is NO privacy.” Messages auto-delete after a few hours. The same page warns against sending “private tokens” to public inboxes, and a one-time code is exactly that. Worse, anyone who knows the address can request a password reset and read it, which hands them the account.

Disposable domains get blocked at the form. PyPI refuses registrations from the community-maintained disposable-email-domains list (over 9,000 domains, including mailinator.com, guerrillamail.com, yopmail.com and mail.tm) plus its own internal list. Auth providers make it a switch: Clerk’s dashboard has “Block sign-ups that use disposable email addresses,” and a separate setting blocks subaddresses like josh+agent@. Temp-mail services keep adding fresh domains and the lists keep catching up, so the domain that works today can be listed tomorrow, with your agent’s account stuck behind it.

The account outlives the address. The next “new device, enter the code” email, the password reset, the receipt: all of it goes to an address that’s gone or that anyone can read.

The private ones are testing tools. Password-protected temp-mail APIs fix the privacy problem but not the other two. mail.tm, for example, is free, needs no API key and allows 8 requests a second per IP. Tools like that are fine for testing your own sign-up flow in CI. An agent operating real accounts on other services needs an address that looks like a business and lasts, which is what CarlyEmail gives it.

What about routing codes to your own inbox?

Handing the agent your mailbox works, and it’s the widest grant you can make. Gmail’s read scopes cover the whole mailbox, so the agent that came for one code can read your contracts, your bank alerts and any email written to steer it. Apps also can’t tell the agent from you. Gmail MCP covers what that setup exposes.

Forwarding just the codes is the narrow version, for accounts that must stay in your name. A Gmail filter on from:noreply@github.com can forward only that sender’s mail to the agent’s inbox. Gmail first sends a verification link to the forwarding address, which lands in the agent’s inbox, so it can confirm the forward itself.

Reading codes out by hand works at 2pm and fails at 3am, when nobody is awake to read them. A catch-all address has the opposite problem: every agent’s codes land in one mailbox, where each can read the others’.

Step 1: give the agent its own inbox and a read-only key

You run this once with your organization key (pip install carlyemail). The agent never sees that key.

from carlyemail import CarlyEmail

carly = CarlyEmail()  # your organization key, from CARLYEMAIL_API_KEY. Never give this one to the agent.

inbox = carly.inboxes.create({"username": "josh-signups", "client_id": "josh-signups"})  # safe to rerun

key = carly.api_keys.create_inbox(inbox["email"], {
    "name": "signup agent, read only",
    "permissions": {"message_read": True},
})

print(inbox["email"])   # josh-signups@carlyemail.com: what the agent types into sign-up forms
print(key["api_key"])   # shown once: the only key the agent holds

That key reaches one inbox and can only read it. Permissions are a whitelist: anything not granted is denied, so it can’t send, and a request for any other inbox returns inbox_out_of_scope. If a page the agent reads mid-sign-up is a prompt injection, the damage stops at one mailbox full of verification emails. Want the address on your own domain? The free plan includes one custom domain with SPF, DKIM and DMARC set up for you, and an address there looks like any other company inbox to a sign-up form. For why an address works as an agent’s identity at all, see agent identity.

The agent can also set itself up. Paste Read https://docs.carlyemail.com/llms.txt and set yourself up with an email address. into it. Until you confirm the six-digit code CarlyEmail emails you, the account can only send to you, but it can already receive and read its own mail, which is all a code inbox needs.

Step 2: catch the code by polling

codes.py from the verification-codes example is the whole thing, with no model involved. Copy it next to your agent.

from datetime import UTC, datetime

from carlyemail import CarlyEmail
from codes import wait_for_code

carly = CarlyEmail()   # reads CARLYEMAIL_API_KEY: the read-only inbox key
INBOX = "josh-signups@carlyemail.com"

asked = datetime.now(UTC)   # before the form is submitted, so a fast code isn't missed
submit_form(INBOX)          # your agent fills in the form and clicks "Send code"

code = wait_for_code(carly, INBOX, sender="@github.com", after=asked)
print(code.value)           # "482913"

Four details make it hold up where a ten-line regex loop doesn’t:

  • The sender is matched whole. @github.com matches the address parsed out of the From header, so noreply@github.com.evil.net doesn’t pass. A substring check would let it through.
  • Old codes are skipped. after ignores anything that arrived before you asked, such as the code from a failed first attempt.
  • It picks the code out of the other numbers. extract() looks at lines that mention “code”, “verify”, “OTP” and similar, and prefers six-digit runs, so “Order #12345678” and “2026” don’t come back as the code.
  • Forged mail never shows up. CarlyEmail checks SPF, DKIM and DMARC on arrival. Mail that fails DMARC, or fails both SPF and DKIM, is labelled unauthenticated and left out of the default listing, so a spoofed “GitHub” email can’t feed the agent a code.

Step 3: catch it over a WebSocket

Polling is fine when the agent is blocked mid-form anyway. An agent running many sign-ups, or one that shouldn’t spend its time on list calls, can hold one WebSocket and react the moment the message is stored. Python 3.11+, pip install websockets carlyemail:

import asyncio
import json
import os
from datetime import datetime, timezone
from urllib.parse import quote

import websockets
from carlyemail import CarlyEmail
from codes import extract, wait_for_code   # codes.py from the verification-codes example

KEY = os.environ["CARLYEMAIL_API_KEY"]     # the inbox-scoped, read-only key
INBOX = os.environ["CARLYEMAIL_INBOX"]     # josh-signups@carlyemail.com
carly = CarlyEmail(api_key=KEY)


def from_sender(header: str, wanted: str) -> bool:
    address = header.rsplit("<", 1)[-1].rstrip(">").strip().lower()
    return address.endswith(wanted) if wanted.startswith("@") else address == wanted


async def get_code(submit, sender: str, timeout: float = 120) -> str:
    """Subscribe first, then let the agent submit the form, then wait for the code."""
    started = datetime.now(timezone.utc)
    submitted = False
    url = f"wss://ws.carlyemail.com/v0?api_key={quote(KEY)}"
    try:
        async with asyncio.timeout(timeout), websockets.connect(url) as ws:
            await ws.send(json.dumps({"type": "subscribe", "event_types": ["message.received"]}))
            while json.loads(await ws.recv()).get("type") != "subscribed":
                pass
            await submit()  # the agent clicks "Send code" only once the socket is listening
            submitted = True
            async for raw in ws:
                frame = json.loads(raw)
                if frame.get("event_type") != "message.received":
                    continue  # keepalive pings
                msg = frame["message"]
                if msg.get("truncated") or not msg.get("from"):
                    msg = carly.messages.get(INBOX, msg["message_id"])  # large mail: fetch the body
                if not from_sender(msg["from"], sender):
                    continue
                text = msg.get("extracted_text") or msg.get("text") or ""
                if code := extract(f"{msg.get('subject') or ''}\n{text}"):
                    return code
    except (TimeoutError, OSError, websockets.ConnectionClosed):
        pass
    # Socket dropped or timed out: whatever arrived since `started` is still in the inbox.
    if not submitted:
        await submit()
    left = timeout - (datetime.now(timezone.utc) - started).total_seconds()
    found = await asyncio.to_thread(
        wait_for_code, carly, INBOX, sender=sender, after=started, timeout=max(left, 15)
    )
    return found.value


# code = asyncio.run(get_code(click_send_code, sender="@github.com"))

What it handles:

  • Subscribe before submit. A code can arrive in under a second. The function waits for CarlyEmail’s subscribed acknowledgement before it lets the agent click the button, so the event can’t fire into a socket that isn’t listening yet.
  • The key’s scope is the filter. An inbox-scoped key subscribes to its own inbox whatever the frame asks for, so there’s no inbox_ids to get wrong.
  • Big HTML emails. A large message can arrive on the socket with its body dropped and truncated: true. The code then fetches the message by id.
  • Dropped connections. Socket delivery is at least once, and the platform can close a connection for maintenance. Nothing is lost when that happens: the message is already in the inbox, so the function falls back to polling for whatever landed since it started.

Serverless agents: catch it with a webhook

A Worker or Lambda can’t hold a socket. Register a webhook for message.received on that inbox (once, with your organization key), and the Python SDK’s receiver verifies the signature, drops spoofed and spam mail, de-duplicates retries and checks the sender before your handler runs:

from carlyemail.inbound import create_email_router
from fastapi import FastAPI

from codes import extract

app = FastAPI()


async def on_email(email):
    if code := extract(f"{email.subject}\n{email.text}"):
        save_code(email.from_address, code)  # wherever the waiting agent looks: a queue, Redis, a row


app.include_router(create_email_router(on_email, path="/hooks/carlyemail", allow_from=["@github.com"]))

Webhooks need a confirmed account (the free plan includes 2). Email to webhook compares this with SendGrid, Mailgun, Postmark and SES inbound.

Browser agents: let the agent read the code itself

If the thing filling in the form is Claude Code, Cursor, Codex or another MCP client driving a browser, you don’t need any of the Python above. Connect CarlyEmail’s MCP server with the read-only key:

claude mcp add --transport http carlyemail https://api.carlyemail.com/mcp \
  --header "Authorization: Bearer $SIGNUPS_READ_KEY"

Then tell it: “Sign up for the tool with josh-signups@carlyemail.com and read the verification code from that inbox.” It finds the message with list_messages and reads it with get_thread. Because the key is scoped to one inbox, the agent doesn’t even need to name it. More hosts and setups are in email MCP servers.

Magic links. Some services send a link instead of digits. The first link in the email is often the logo, which points at the same domain, so filter to the sender’s domain and then pick the link that looks like a sign-in:

import re
from urllib.parse import urlparse

WORDS = ("verify", "confirm", "login", "signin", "magic", "token", "auth")


def verify_link(text: str, domain: str) -> str | None:
    links = []
    for url in re.findall(r"https://[^\s<>\"')]+", text):
        host = urlparse(url).hostname or ""
        if (host == domain or host.endswith("." + domain)) and "unsubscribe" not in url:
            links.append(url)
    for url in links:
        if any(word in url.lower() for word in WORDS):
            return url
    return max(links, key=len, default=None)  # one-time links carry a long token

Expired codes. Codes usually expire within minutes. If one does, ask for another and pass the time of the second request as after, so the stale code is skipped.

Several sign-ups at once. Always pass sender. If two runs could hit the same service at the same time, give each its own inbox: 3 on the free plan, 25 for $20 a month, 250 for $200.

A code that never shows up. Look before you retry. Spam and mail that failed authentication are kept, labelled, and fired as their own events (message.received.spam, message.received.unauthenticated), and you can list them with include_spam or include_unauthenticated (spam also needs a key with label_spam_read). AgentMail drops mail that fails SPF and DKIM; its own help docs call that the most common reason inbound mail goes missing. More differences are in CarlyEmail vs AgentMail.

SMS codes, authenticator apps and CAPTCHAs. An inbox handles email. Where a service offers email as a second-factor option, choose it; where it insists on SMS, you need a phone number instead. A CAPTCHA exists to stop automation, so leave it alone.

FAQ

What is an email OTP?

An email OTP (one-time passcode) is a short code a service emails to prove you control an address, usually six digits and valid for a few minutes. Services send them at sign-up, at login as a second factor, and before sensitive changes like a new password.

Can an AI agent get past 2FA?

When the second factor is an emailed code and the agent operates an account you’re authorized to run, yes: give it its own inbox and it reads the code the way a person would. It proves control of the address, which is exactly what the check asks for. SMS codes need a phone number, and CAPTCHAs exist to stop software.

Is there a free temporary email API?

Yes. mail.tm is free with no API key, and Mailinator’s public inboxes need no sign-up. Both suit testing your own sign-up flow. For an agent operating real accounts, public inboxes are readable by anyone and disposable domains get blocked, so use a private inbox instead; CarlyEmail gives you 3 with no card.

Why do websites block disposable email addresses?

Throwaway addresses are how spam and fake accounts get made at scale. PyPI, for example, blocks known disposable domains to cut off abuse and malware uploads, and auth providers such as Clerk offer blocking as a single dashboard setting.

Yes. It waits for the email the same way, keeps only links on the sender’s own domain, and picks the one that looks like a sign-in link (“verify”, “confirm”, “login”, or the longest, since one-time links carry a token). The first link in the message is often just the logo.

Does reading verification codes count against CarlyEmail’s email quota?

No. The daily and monthly caps count sent recipients only, and received mail never counts. Messages are kept until you delete them, within the plan’s storage (1 GB on the free plan).

Give your agent a real inbox

Your agent gets its own email address. People can email it, it answers in the same thread, and your personal inbox stays out of it. Start with 3 inboxes, no card needed.

Get started
See the prompt
Read https://docs.carlyemail.com/llms.txt and set yourself up with an email address.