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 inbox | Temp-mail API | The agent’s own inbox | |
|---|---|---|---|
| Who can read the code | The agent, plus every other email you have | On public services like Mailinator, anyone who types the address | Only keys scoped to that inbox |
| Accepted by the sign-up form | Yes, but the account is yours, and +agent variants can be blocked | Often refused: disposable domains sit on shared blocklists | Yes, and on your own domain it’s an ordinary company address |
| The next login code or password reset | Lands with you | Address abandoned, messages auto-deleted | Same inbox, mail kept until you delete it |
| Who can take over the account | Anyone who steers the agent while it holds your mailbox | Anyone who requests a reset to the public address | Whoever 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.commatches the address parsed out of the From header, sonoreply@github.com.evil.netdoesn’t pass. A substring check would let it through. - Old codes are skipped.
afterignores 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
unauthenticatedand 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
subscribedacknowledgement 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_idsto 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, resends and codes that never arrive
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.
Can an agent handle magic links as well as codes?
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 startedSee the prompt
Read https://docs.carlyemail.com/llms.txt and set yourself up with an email address.


