You've built an AI agent that can browse the web, fill out forms, and complete tasks autonomously. It works perfectly in dev. Then it hits a login page with two-factor authentication, and everything falls apart.

This is the single most common failure mode for autonomous agents in production. Not hallucinations, not context window limits — 2FA. The security mechanism designed to prove a human is present is, by definition, hostile to non-human actors.

In this tutorial, we'll walk through the architecture for solving email-based 2FA for AI agents, with complete code examples in both Node.js and Python. We'll cover the different types of email verification (OTP codes, magic links, confirmation emails), the pitfalls that trip up most implementations, and how to build a robust system that handles all of them.

The Problem: Why 2FA Breaks Agents

Let's be precise about what happens. Your agent needs to log into a SaaS tool — say, a CRM or project management app. The flow looks like this:

  1. Agent navigates to login page
  2. Agent enters username and password (stored in a vault or passed as config)
  3. The service sends a 6-digit code to the account's email address
  4. Agent needs to read that email, extract the code, and enter it — within 60 seconds

Step 4 is where it breaks. The agent has no email inbox. Or worse, the account uses a shared team inbox that the agent can't programmatically access. Even if you give the agent IMAP credentials, you're now managing IMAP connections, polling intervals, and parsing arbitrary HTML email templates — all under a tight time constraint.

The fundamental issue is that email was never designed as an API. It's a protocol built for humans reading messages in a client. When you need programmatic, real-time, reliable access to incoming email — particularly for time-sensitive operations like OTP extraction — IMAP falls over.

The Architecture: Dedicated Inboxes + OTP Extraction

The solution has three parts:

  1. Dedicated inbox per agent session. Each agent (or agent session) gets its own email address. This eliminates the problem of filtering through a shared inbox and ensures isolation between concurrent agent runs.
  2. Webhook or polling-based message receipt. Instead of maintaining an IMAP connection, use an API that either pushes new messages to you or lets you poll with minimal latency.
  3. Structured OTP extraction. Parse incoming emails to extract verification codes, magic links, or confirmation URLs automatically — don't make the LLM do regex on raw HTML.

Here's what this looks like with Lumbox:

// 1. Create a dedicated inbox for this agent session
const inbox = await client.inboxes.create();
// inbox.email => "ax7k2m@lumbox.dev"

// 2. Use this email when signing up or logging in
await agent.fillForm({ email: inbox.email });

// 3. Wait for the OTP to arrive and extract it
const otp = await client.messages.waitForOTP({
  inboxId: inbox.id,
  timeout: 60_000, // 60 seconds
});
// otp.code => "847291"

// 4. Enter the code
await agent.fillField("#otp-input", otp.code);

That's it. No IMAP. No email parsing. No race conditions. The `waitForOTP` method handles the polling, the HTML parsing, and the code extraction internally.

Complete Node.js Example

Here's a full, production-ready flow for an agent that logs into a service with email-based 2FA using Playwright for browser automation and Lumbox for email:

import { Lumbox } from "lumbox";
import { chromium } from "playwright";

const lumbox = new Lumbox({ apiKey: "ak_your_key" });

async function loginWithEmail2FA(serviceUrl: string, password: string) {
  // Create an isolated inbox for this session
  const inbox = await lumbox.inboxes.create();
  console.log(\`Agent inbox: ${inbox.email}\`);

  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();

  try {
    // Step 1: Navigate to login
    await page.goto(serviceUrl);

    // Step 2: Enter credentials
    await page.fill('input[name="email"]', inbox.email);
    await page.fill('input[name="password"]', password);
    await page.click('button[type="submit"]');

    // Step 3: Wait for 2FA page to load
    await page.waitForSelector('[data-testid="otp-input"]', {
      timeout: 10_000,
    });

    // Step 4: Wait for OTP email and extract code
    const otp = await lumbox.messages.waitForOTP({
      inboxId: inbox.id,
      timeout: 60_000,
    });

    if (!otp || !otp.code) {
      throw new Error("Failed to receive OTP within timeout");
    }

    console.log(\`Extracted OTP: ${otp.code}\`);

    // Step 5: Enter OTP
    await page.fill('[data-testid="otp-input"]', otp.code);
    await page.click('button[type="submit"]');

    // Step 6: Verify login succeeded
    await page.waitForURL("**/dashboard**", { timeout: 15_000 });
    console.log("Login successful");

    return { page, browser, inbox };
  } catch (error) {
    await browser.close();
    throw error;
  }
}

Complete Python Example

The same flow in Python, using the Lumbox Python SDK and Playwright:

from lumbox import Lumbox
from playwright.sync_api import sync_playwright

client = Lumbox(api_key="ak_your_key")

def login_with_email_2fa(service_url: str, password: str):
    # Create an isolated inbox
    inbox = client.inboxes.create()
    print(f"Agent inbox: {inbox.email}")

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()

        # Navigate and enter credentials
        page.goto(service_url)
        page.fill('input[name="email"]', inbox.email)
        page.fill('input[name="password"]', password)
        page.click('button[type="submit"]')

        # Wait for 2FA prompt
        page.wait_for_selector('[data-testid="otp-input"]', timeout=10_000)

        # Wait for OTP
        otp = client.messages.wait_for_otp(
            inbox_id=inbox.id,
            timeout=60_000
        )

        if not otp or not otp.code:
            raise Exception("Failed to receive OTP within timeout")

        print(f"Extracted OTP: {otp.code}")

        # Enter OTP and submit
        page.fill('[data-testid="otp-input"]', otp.code)
        page.click('button[type="submit"]')

        # Verify success
        page.wait_for_url("**/dashboard**", timeout=15_000)
        print("Login successful")

        return page, browser, inbox

The Three Types of Email Verification

Not all email-based authentication works the same way, and conflating the types is a common mistake. Let's break them down:

1. Numeric OTP Codes

The most common type. The email contains a 4-8 digit numeric code (e.g., "847291") that you enter into a form field. These typically expire in 60-300 seconds.

This is the easiest case for agents. The code is short, numeric, and unambiguous. Lumbox's `waitForOTP` extracts these automatically by parsing the email HTML and identifying numeric patterns in the right context (not every 6-digit number in an email is an OTP — it looks at proximity to keywords like "verification," "code," "enter," etc.).

2. Magic Links

Instead of a code, the email contains a unique URL. Clicking it logs you in directly. Examples include Slack's magic link login and many passwordless auth systems.

For agents, magic links require a different handling pattern:

// For magic links, list emails and extract the link
const emails = await lumbox.inboxes.listEmails(inbox.id);
const latestEmail = emails[0];

// The email HTML will contain the magic link
// Parse it from the email body
const magicLinkMatch = latestEmail.html.match(
  /href="(https://[^"]*(?:magic|verify|login|auth)[^"]*)"/i
);

if (magicLinkMatch) {
  await page.goto(magicLinkMatch[1]);
}

Magic links are trickier because the URL patterns vary by service. Some use `/verify?token=xxx`, others use `/magic/xxx`, and some use completely opaque URLs. A robust implementation needs to identify the primary CTA link in the email, which is usually the largest or most prominent link.

3. Confirmation Emails ("Click to confirm")

Common during sign-up flows. The email says "Click here to confirm your email address" and contains a link that activates the account. Unlike magic links, these don't log you in — they just verify the email address.

The agent needs to click the link and then return to the original flow:

// Handle email confirmation in a separate context
const confirmEmails = await lumbox.inboxes.listEmails(inbox.id);
const confirmEmail = confirmEmails[0];

// Open confirmation link in a new page (not the main flow)
const confirmPage = await browser.newPage();
const confirmLink = extractConfirmationLink(confirmEmail.html);
await confirmPage.goto(confirmLink);
await confirmPage.close();

// Back in original page, click "I've confirmed my email"
await page.click('button:has-text("I\'ve confirmed")');

TOTP vs. Email OTP: Know the Difference

A critical distinction that trips up many developers: TOTP (Time-based One-Time Password) and email OTP are completely different mechanisms, even though both produce 6-digit codes.

TOTP (used by Google Authenticator, Authy, 1Password) generates codes using a shared secret and the current time. No email is involved. If a service uses TOTP, you need the TOTP secret key, not an email inbox. Libraries like `otpauth` in Node.js or `pyotp` in Python can generate these codes.

Email OTP is what we've been discussing — the server generates a code and emails it to you. This is what Lumbox solves.

Many services offer both options. When setting up accounts for agent use, prefer TOTP over email OTP when possible — it's faster (no network round trip to receive an email) and more reliable. Use Lumbox for email OTP when TOTP isn't available, or when the service forces email verification regardless of TOTP (some do both).

Common Pitfalls and How to Avoid Them

Pitfall 1: Shared Inboxes

If multiple agent sessions use the same email address, OTP codes get mixed up. Agent A receives Agent B's code. The fix is simple: one inbox per session. Lumbox inboxes are cheap and disposable — create one per flow and delete it when done.

Pitfall 2: Race Conditions with Timing

The agent submits the login form, then immediately starts polling for the OTP email. But the email hasn't arrived yet. A naive implementation fails after one poll attempt. The `waitForOTP` approach with a timeout handles this correctly — it polls repeatedly until the email arrives or the timeout expires.

Pitfall 3: HTML Email Parsing

OTP emails are HTML, and the code might be buried in nested tables, styled spans, or even rendered as an image (thankfully rare). Don't try to parse this yourself with regex on raw HTML. Use a purpose-built extraction method or strip the HTML to text first.

Pitfall 4: Rate Limiting on the Service Side

If your agent fails the OTP step and retries the login, the service might rate-limit OTP sends. Build in backoff logic. Some services only let you request a new code every 30-60 seconds.

Pitfall 5: Email Delivery Delays

SMTP is not instant. Email can take 1-30 seconds to arrive, sometimes longer. Your timeout should account for this. We recommend 60 seconds as a default, with a maximum of 120 seconds before failing and retrying the entire flow.

Production Architecture

For production deployments with multiple agents running concurrently, here's the architecture we recommend:

// Agent session manager
class AgentSessionManager {
  private lumbox: Lumbox;
  private sessions: Map<string, { inbox: any; browser: any }> = new Map();

  constructor(apiKey: string) {
    this.lumbox = new Lumbox({ apiKey });
  }

  async createSession(sessionId: string) {
    const inbox = await this.lumbox.inboxes.create();
    this.sessions.set(sessionId, { inbox, browser: null });
    return inbox;
  }

  async getOTP(sessionId: string, timeout = 60_000) {
    const session = this.sessions.get(sessionId);
    if (!session) throw new Error("Session not found");

    return this.lumbox.messages.waitForOTP({
      inboxId: session.inbox.id,
      timeout,
    });
  }

  async destroySession(sessionId: string) {
    const session = this.sessions.get(sessionId);
    if (session?.browser) await session.browser.close();
    this.sessions.delete(sessionId);
  }
}

Each agent session gets its own inbox. Inboxes are isolated. There's no cross-contamination between concurrent agent runs. When the session ends, clean up the inbox.

Wrapping Up

Email-based 2FA is a solved problem for AI agents — but only if you approach it with the right architecture. The key principles are:

  • One inbox per agent session — never share inboxes between concurrent flows
  • Use purpose-built OTP extraction — don't make your LLM parse HTML emails
  • Handle all three verification types — numeric codes, magic links, and confirmation emails each need different handling
  • Build in timeouts and retries — email delivery is not instant or guaranteed
  • Prefer TOTP when available — it's faster and more reliable than email OTP

The agent ecosystem is maturing fast. The infrastructure layer — browser automation, email, identity — is what separates demo agents from production agents. Email-based 2FA is one of those unsexy-but-critical problems that blocks real deployment, and solving it well unlocks a huge class of automations that were previously impossible.