Tutorials
AI agent email verification without client-side polling
Wait for an OTP email through Postfleet MCP, match the expected sender and subject, parse the code in application code, and handle timeouts safely.
An AI agent can complete email verification without running its own inbox polling loop. Start one bounded wait_for_email call, trigger the verification message, match it by mailbox, sender, and subject, then parse the OTP with deterministic application code. Treat a timeout as a normal result and keep the mailbox key read-only.
In Postfleet, "without polling" means your agent and application do not repeatedly call list_inbox. The current wait_for_email implementation checks the mailbox on the server every two seconds and returns one result to the client. That distinction matters when you estimate latency and API behavior.
Why an agent should not invent the receive loop#
A model can call list_inbox, inspect the result, decide to wait, and call it again. It can also forget the stop condition, change the filter, spend a model turn on every empty response, or process the same message twice.
Verification has a simpler state machine:
- Begin waiting for a message that belongs to this attempt.
- Ask the external service to send its code.
- Return the first matching message or a timeout.
- Parse and submit the code in application code.
The model does not need to reason about steps three and four. An OTP is a short-lived credential, not prose that benefits from interpretation.
Set up a receive-only mailbox#
Create a Postfleet mailbox for the verification workflow. Issue an MCP key bound to that mailbox with can_read enabled and can_send disabled. A verifier does not need permission to create domains, change policy, or send email.
You will need:
POSTFLEET_API_KEY, an MCP key scoped to one mailboxPOSTFLEET_MAILBOX_ID, the ID of that mailbox- the mailbox address, passed to the service that sends the verification email
- a stable sender or subject fragment for the expected message
The server endpoint is https://api.postfleet.ai/api/mcp. See MCP setup for the full tool schema and authentication for mailbox binding and capability flags.
Install the supported v1 Model Context Protocol TypeScript SDK and its schema dependency:
npm install @modelcontextprotocol/sdk zod
Wait for the email and extract a six-digit code#
The following module connects directly to Postfleet MCP. It starts the wait before calling your triggerVerification adapter, rejects risky or partial comprehension states, and returns a six-digit code without sending the whole email to a model.
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from
"@modelcontextprotocol/sdk/client/streamableHttp.js";
type WaitResult =
| { timed_out: true; waited_seconds: number }
| {
id: string;
from_addr: string;
subject: string;
body_clean: string | null;
comprehension: { status?: string } | null;
};
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
function parseSixDigitCode(body: string): string {
const labeled = body.match(
/(?:verification code|one[- ]time code|otp|code)\D{0,24}(\d{6})\b/i,
);
if (labeled) return labeled[1];
const candidates = [...body.matchAll(/\b\d{6}\b/g)].map(
(match) => match[0],
);
if (candidates.length !== 1) {
throw new Error("Expected exactly one six-digit verification code");
}
return candidates[0];
}
function readTextResult(result: Awaited<ReturnType<Client["callTool"]>>): WaitResult {
if (result.isError) throw new Error("Postfleet wait_for_email failed");
const item = result.content.find((entry) => entry.type === "text");
if (!item || item.type !== "text") {
throw new Error("Postfleet returned no text result");
}
return JSON.parse(item.text) as WaitResult;
}
export async function receiveVerificationCode(
triggerVerification: () => Promise<void>,
): Promise<string> {
const key = required("POSTFLEET_API_KEY");
const mailboxId = required("POSTFLEET_MAILBOX_ID");
const client = new Client(
{ name: "verification-worker", version: "1.0.0" },
{ capabilities: {} },
);
const transport = new StreamableHTTPClientTransport(
new URL("https://api.postfleet.ai/api/mcp"),
{
requestInit: {
headers: { Authorization: `Bearer ${key}` },
},
},
);
await client.connect(transport);
try {
const waiting = client.callTool({
name: "wait_for_email",
arguments: {
mailbox_id: mailboxId,
from_contains: "accounts.example.com",
subject_contains: "verification code",
timeout_seconds: 90,
},
});
await triggerVerification();
const email = readTextResult(await waiting);
if ("timed_out" in email) {
throw new Error(
`Verification email did not arrive within ${email.waited_seconds}s`,
);
}
const status = email.comprehension?.status;
if (status === "skipped_injection_risk" || status === "partial") {
throw new Error(`Verification email stopped with status: ${status}`);
}
if (!email.body_clean) {
throw new Error("Verification email had no readable body");
}
return parseSixDigitCode(email.body_clean);
} finally {
await transport.close();
}
}
Replace the sender domain and subject fragment with values from the service you are verifying. Wire triggerVerification to that service's "send code" endpoint. The returned code should go directly to its verification endpoint, not into application logs or a second model prompt.
Start the wait before requesting the code#
wait_for_email ignores messages created before the wait begins. This prevents an old OTP from satisfying a new attempt. It also means order matters.
The example creates the wait promise first, then triggers the verification message. In a typical Node.js client, the MCP request starts when callTool is invoked, while the caller proceeds to triggerVerification. If your provider can deliver faster than the MCP request reaches the server, use a webhook-backed workflow or add an application-level "wait ready" handshake around this sequence.
Do not solve the race by accepting any recent message. A stale code can look valid and still belong to another login attempt.
Match the attempt as well as the inbox#
The tool accepts from_contains and subject_contains. Both comparisons are case-insensitive substrings. They reduce noise, but they are not strong correlation by themselves.
Use the strongest identifier the sending service gives you:
- a unique token in the subject
- a dedicated mailbox or plus-address for one account or tenant
- an attempt ID stored in your application and echoed by the provider
- a narrow expected sender domain plus a stable subject phrase
Do not treat the visible From field as proof of identity. Email sender fields can be spoofed, and substring matching can collide. The verification endpoint remains the authority: submit the code only for the user and attempt that requested it.
Postfleet checks the 20 newest inbound messages during a wait. A high-volume shared mailbox can push the expected email out of that window. Dedicated verification mailboxes or tenant-specific addresses make matching more reliable and reduce cross-account mistakes.
Parse the OTP outside the model#
The parser above prefers a six-digit number near labels such as "verification code" or "OTP." If that fails, it accepts exactly one six-digit number in the cleaned body. Multiple candidates stop the workflow.
This is intentionally strict. A model asked to "find the likely code" may choose an order number, phone suffix, date, or code from quoted history. Deterministic parsing gives you a result you can test against the sender's template.
Adjust the parser when the provider uses a different contract:
- for eight digits, change both
\d{6}expressions to\d{8} - for alphanumeric codes, define the exact alphabet and length
- for a verification link, parse the URL, allowlist its host, and validate the expected path and state parameter before following it
Never use a broad link-following tool on an arbitrary email body. A verification worker should know the only host and URL shape it may contact.
Treat timeout as data#
A timeout returns a structured result:
{ "timed_out": true, "waited_seconds": 90 }
It is not a transport failure. Handle it separately from 401, 403, network errors, and invalid MCP results.
A reasonable retry policy belongs to the application:
- End the current attempt.
- Ask the user or upstream service to request a new code.
- Start a new bounded wait.
- Cap total attempts and respect the sender's rate limits.
Do not keep extending one wait. Postfleet clamps timeout_seconds to 1 through 120 seconds. Your deployment must also permit a request to remain open for the chosen duration.
Keep email out of the agent's instruction lane#
Even a verification email is untrusted input. An attacker can send a lookalike subject containing instructions for the agent. Postfleet's MCP result withholds the pre-sanitization body, but screening is not a proof that the message is benign.
This workflow limits the effect of a missed screen:
- the key reads one mailbox and cannot send
- the worker exposes only one MCP call in application code
- parsing accepts one narrow data shape
- no links or instructions in the message are followed
partialandskipped_injection_riskstop the attempt- the code is submitted only to the verification service that began the attempt
OWASP recommends least privilege and independent approval or policy checks for high-impact agent actions. The full email-specific threat model is in Email prompt injection: how to secure an AI agent that reads email.
When a webhook is the better fit#
Use wait_for_email for a short-lived task whose caller is already waiting, such as account verification in a worker with a two-minute request budget.
Use a signed webhook when delivery may take several minutes, many attempts run concurrently, or the workflow must survive process restarts. Store the attempt before requesting the message, acknowledge the webhook quickly, deduplicate by event_id, and continue from durable state. Postfleet's webhook guide documents its signature and at-least-once delivery behavior.
The agent-facing interface can remain the same in both designs: it receives a verified outcome or a timeout, not an open-ended instruction to watch an inbox.
Production checklist#
- Use a mailbox-bound MCP key with read access only.
- Start the wait before triggering delivery.
- Correlate the message to one user and one verification attempt.
- Keep
from_containsandsubject_containsas narrow as the provider allows. - Parse the provider's documented code shape with deterministic code.
- Reject multiple candidates, missing bodies, partial processing, and injection-risk skips.
- Treat timeout as a normal branch and cap retries.
- Never log the OTP or full message body.
- Close the MCP transport in
finally. - Move to signed webhooks when the workflow needs durable, long-running state.
For an OpenAI agent that needs broader mailbox access after verification, continue with Give an OpenAI agent a secure email inbox with MCP.