Building a Stateful Email Client on the Edge: Inside Cloudflare's Agentic Inbox
Hook
Most multi-tenant systems isolate users with database rows or schemas. Cloudflare Agentic Inbox gives each mailbox its own stateful actor with a dedicated SQLite instance, making tenant boundaries a runtime primitive instead of a data modeling problem.
Context
Email clients have barely evolved architecturally in decades. Whether you're running Thunderbird, using Gmail, or self-hosting Mailcow, the pattern is the same: a centralized server with SMTP/IMAP protocols, shared database storage, and row-level tenant isolation. AI features, when they exist, are bolted on as separate services that poll your inbox and make API calls back.
Cloudflare Agentic Inbox reimagines this stack for the edge computing era. It's a self-hosted email client that runs entirely on Cloudflare Workers, using Durable Objects for per-mailbox state, R2 for attachments, and the Cloudflare Agents SDK for AI capabilities. But more interestingly, it exposes an MCP (Model Context Protocol) server endpoint, effectively turning your email into programmable infrastructure for agentic workflows. Instead of building yet another centralized email server, this project asks: what if each mailbox were an isolated, stateful actor at the edge, with AI agents as first-class citizens?
Technical Insight
The architecture hinges on Durable Objects as the isolation boundary. Each mailbox gets its own MailboxDO instance, backed by SQLite storage. When an email arrives via Cloudflare Email Routing's catch-all rule, the Worker dispatches it to the correct Durable Object based on the recipient address:
export default {
async email(message, env, ctx) {
const mailboxId = env.MAILBOX_DO.idFromName(message.to);
const mailbox = env.MAILBOX_DO.get(mailboxId);
// Parse email and extract attachments
const parsed = await parseEmail(message.raw);
// Store attachments in R2, get references
const attachmentRefs = await Promise.all(
parsed.attachments.map(async (att) => {
const key = `${mailboxId}/${crypto.randomUUID()}`;
await env.ATTACHMENTS.put(key, att.content);
return { filename: att.filename, key, size: att.size };
})
);
// Dispatch to mailbox DO with attachment references
await mailbox.receiveEmail({
...parsed,
attachments: attachmentRefs
});
}
};
This pattern is genuinely clever. Traditional email systems would write this to a shared PostgreSQL database with a mailbox_id foreign key. Here, the mailbox is the compute unit. Each MailboxDO maintains its own SQLite database with tables for emails, threads, and drafts. Fault isolation is automatic—if one mailbox's Durable Object crashes or hits the 128MB storage limit, other mailboxes are unaffected.
The AI agent lives in a separate EmailAgentDO that wraps Cloudflare's AIChatAgent with nine email-specific tools: read_email, search_emails, create_draft, send_email, list_threads, get_thread, mark_as_read, archive_email, and delete_email. When a new email arrives, the mailbox automatically invokes the agent to generate a draft reply:
class MailboxDO {
async receiveEmail(email: ParsedEmail) {
// Store in SQLite
await this.db.execute(
'INSERT INTO emails (id, from, subject, body, thread_id) VALUES (?, ?, ?, ?, ?)',
[email.id, email.from, email.subject, email.body, email.threadId]
);
// Get agent for this mailbox
const agentId = this.env.EMAIL_AGENT_DO.idFromName(this.mailboxId);
const agent = this.env.EMAIL_AGENT_DO.get(agentId);
// Auto-draft a reply
const draft = await agent.generateReply(email.id, {
context: await this.getThreadContext(email.threadId),
tone: 'professional'
});
// Store draft, don't send yet
await this.db.execute(
'INSERT INTO drafts (email_id, content, created_at) VALUES (?, ?, ?)',
[email.id, draft.content, Date.now()]
);
}
}
This auto-draft pattern reduces response latency significantly. When you open your inbox, replies are already waiting for approval rather than requiring you to wait for the AI to think. The agent streams its reasoning over WebSocket, showing which tools it's calling and why, giving users transparency into the decision-making process.
The MCP server integration is where this gets genuinely interesting for agentic workflows. At the /mcp endpoint, the Worker exposes mailbox operations as tools that external AI systems like Claude Desktop or Cursor can call:
const mcpTools = [
{
name: 'read_inbox',
description: 'Read emails from a mailbox',
inputSchema: {
type: 'object',
properties: {
mailbox: { type: 'string' },
limit: { type: 'number', default: 10 },
unreadOnly: { type: 'boolean', default: false }
}
}
},
// ... other tools
];
app.post('/mcp', async (c) => {
const { tool, args } = await c.req.json();
const mailboxId = c.env.MAILBOX_DO.idFromName(args.mailbox);
const mailbox = c.env.MAILBOX_DO.get(mailboxId);
const result = await mailbox[tool](args);
return c.json({ result });
});
This turns Agentic Inbox into programmable email infrastructure. You can wire Claude into your inbox and say "summarize all unread emails from engineering@company.com and create a task list", and it'll call the MCP tools to read, filter, and process emails. It's not just an AI-assisted email client—it's an email backend designed for agents.
The Hono-based Worker handles routing between the React SSR UI, REST API endpoints, and MCP server. Authentication is delegated entirely to Cloudflare Access, which validates JWTs at the Worker boundary. There's no per-mailbox RBAC—if you pass Access, you can read any mailbox. This is the architectural tradeoff for simplicity: strong isolation between mailbox state, but coarse-grained access control.
Gotcha
The biggest limitation is the authentication model. Cloudflare Access is all-or-nothing at the Worker level. You can't give User A access to mailbox-a@example.com but not mailbox-b@example.com without deploying separate Workers with different Access policies. For a small team where everyone trusts everyone, this is fine. For anything with actual confidentiality boundaries between mailboxes, it's a dealbreaker.
Durable Objects have a 128MB SQLite storage limit per instance. For a typical mailbox with text emails, this is plenty. But if you're on a high-traffic mailing list or receive lots of emails with inline images, you'll hit this ceiling faster than expected. The R2 attachment offloading helps, but email bodies themselves still live in SQLite. There's no automatic archival or migration path—you'd need to build your own logic to move old emails to cold storage or delete them. The project also lacks any documented cleanup for R2 attachments, so they accumulate indefinitely. At scale, you'd need to implement lifecycle policies or risk unbounded storage costs.
Verdict
Use if: You're already on Cloudflare Workers and want self-hosted email with AI capabilities without managing servers, you need email infrastructure that AI agents (via MCP) can programmatically interact with, you're building internal tools for a small trusted team where mailbox isolation matters more than granular access control, or you want to experiment with stateful edge applications using Durable Objects as a reference architecture. Skip if: You need proper multi-user RBAC with per-mailbox permissions, you expect high email volumes (>1000 emails/month per mailbox with attachments), you require full SMTP/IMAP compatibility for external clients, you want to use AI models outside Cloudflare's Workers AI offerings, or you need production-grade email deliverability with bounce handling and reputation management. This is a fascinating architectural experiment and genuinely useful for agentic workflows, but it's not replacing Gmail or Mailcow for traditional email use cases.