Loading...
Loading...
Deploy and manage a self-hosted email client with AI agent on Cloudflare Workers
npx skill4agent add aradotso/ai-agent-skills cloudflare-agentic-inboxSkill by ara.so — AI Agent Skills collection.
# Visit: https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/agentic-inbox
# When prompted, enter your domain: yourdomain.comPOLICY_AUDTEAM_DOMAINwrangler secret put POLICY_AUD
wrangler secret put TEAM_DOMAINsend_emailwrangler.jsonc{
"send_email": [
{
"name": "SEB",
"destination_address": "you@example.com"
}
]
}# Clone repository
git clone https://github.com/cloudflare/agentic-inbox.git
cd agentic-inbox
# Install dependencies
npm install
# Create R2 bucket
wrangler r2 bucket create agentic-inbox
# Configure domain in wrangler.jsonc
# Set DOMAINS variable to your domain
# Deploy
npm run deploy{
"name": "agentic-inbox",
"main": "worker/index.ts",
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
"vars": {
"DOMAINS": "yourdomain.com"
},
"durable_objects": {
"bindings": [
{
"name": "MAILBOX",
"class_name": "MailboxDurableObject",
"script_name": "agentic-inbox"
},
{
"name": "EMAIL_AGENT",
"class_name": "EmailAgentDurableObject",
"script_name": "agentic-inbox"
}
]
},
"r2_buckets": [
{
"binding": "R2",
"bucket_name": "agentic-inbox"
}
],
"ai": {
"binding": "AI"
},
"send_email": [
{
"name": "SEB",
"destination_address": "fallback@yourdomain.com"
}
]
}# Required for Cloudflare Access authentication
wrangler secret put POLICY_AUD
wrangler secret put TEAM_DOMAIN
# TEAM_DOMAIN can be either:
# - Your Access team URL: yourteam.cloudflareaccess.com
# - Full certs URL: yourteam.cloudflareaccess.com/cdn-cgi/access/certs# Start dev server with hot reload
npm run dev
# Access at http://localhost:8787
# Note: Cloudflare Access is disabled in local developmentagentic-inbox/
├── app/ # React frontend
│ ├── routes/ # React Router v7 routes
│ ├── components/ # UI components
│ └── lib/ # Utilities, stores (Zustand)
├── worker/ # Cloudflare Worker backend
│ ├── index.ts # Hono router, email handler
│ ├── mailbox-do.ts # Mailbox Durable Object
│ ├── email-agent-do.ts # AI Agent Durable Object
│ └── auth.ts # Access JWT validation
└── wrangler.jsonc # Cloudflare configuration// POST /api/mailboxes
const response = await fetch('/api/mailboxes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address: 'hello@yourdomain.com'
})
});
const mailbox = await response.json();
// { id: "uuid", address: "hello@yourdomain.com", createdAt: "..." }// POST /api/mailboxes/:id/send
const response = await fetch(`/api/mailboxes/${mailboxId}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: ['recipient@example.com'],
subject: 'Hello',
body: '<p>Email content</p>',
cc: [],
bcc: [],
inReplyTo: null,
references: []
})
});// WebSocket connection to agent
const ws = new WebSocket(`wss://yourapp.workers.dev/agents/${mailboxId}`);
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'message',
content: 'Summarize my unread emails'
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Stream: { type: 'text-delta', text: '...' }
// Tools: { type: 'tool-call', toolName: 'read_inbox', args: {...} }
// Result: { type: 'tool-result', result: {...} }
};import { DurableObject } from 'cloudflare:workers';
export class MailboxDurableObject extends DurableObject {
async fetch(request: Request) {
const url = new URL(request.url);
if (url.pathname === '/emails' && request.method === 'GET') {
const stmt = this.ctx.storage.sql.exec(
'SELECT * FROM emails ORDER BY receivedAt DESC LIMIT 50'
);
return Response.json(stmt.toArray());
}
if (url.pathname === '/emails' && request.method === 'POST') {
const email = await request.json();
const result = this.ctx.storage.sql.exec(
`INSERT INTO emails (id, subject, from_address, to_address, body, receivedAt)
VALUES (?, ?, ?, ?, ?, ?)`,
email.id, email.subject, email.from, email.to, email.body, Date.now()
);
return Response.json({ success: true });
}
return new Response('Not found', { status: 404 });
}
}import { AIChatAgent } from '@cloudflare/agents-sdk';
import { DurableObject } from 'cloudflare:workers';
export class EmailAgentDurableObject extends DurableObject {
private agent?: AIChatAgent;
async fetch(request: Request) {
if (!this.agent) {
this.agent = new AIChatAgent({
model: '@cf/moonshotai/kimi-k2.5',
binding: this.env.AI,
tools: [
{
name: 'read_inbox',
description: 'Read emails from the inbox',
parameters: {
type: 'object',
properties: {
limit: { type: 'number', default: 10 }
}
},
handler: async ({ limit }) => {
// Fetch from mailbox DO
const mailboxId = this.ctx.id.toString();
const emails = await this.fetchMailboxEmails(mailboxId, limit);
return { emails };
}
},
{
name: 'send_email',
description: 'Send an email',
parameters: {
type: 'object',
properties: {
to: { type: 'array', items: { type: 'string' } },
subject: { type: 'string' },
body: { type: 'string' }
},
required: ['to', 'subject', 'body']
},
handler: async ({ to, subject, body }) => {
// Send via Email Service
await this.env.SEB.send({
from: this.getMailboxAddress(),
to,
subject,
content: [{ type: 'text/html', value: body }]
});
return { success: true };
}
}
],
systemPrompt: 'You are an email assistant...'
});
}
// Handle WebSocket upgrade for streaming
const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader === 'websocket') {
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
return new Response('Expected WebSocket', { status: 400 });
}
async webSocketMessage(ws: WebSocket, message: string) {
const { content } = JSON.parse(message);
for await (const chunk of this.agent.stream(content)) {
ws.send(JSON.stringify(chunk));
}
}
}// worker/index.ts
import { EmailMessage } from 'cloudflare:email';
export default {
async email(message: EmailMessage, env: Env) {
const to = message.to;
const mailboxId = await env.KV.get(`address:${to}`);
if (!mailboxId) {
message.setReject('Mailbox not found');
return;
}
// Forward to Mailbox DO
const id = env.MAILBOX.idFromString(mailboxId);
const stub = env.MAILBOX.get(id);
const emailData = {
id: crypto.randomUUID(),
from: message.from,
to: message.to,
subject: message.headers.get('subject'),
body: await message.text(),
receivedAt: Date.now()
};
await stub.fetch('https://mailbox/emails', {
method: 'POST',
body: JSON.stringify(emailData)
});
}
};// worker/auth.ts
import * as jose from 'jose';
export async function validateAccessToken(request: Request, env: Env) {
if (!env.POLICY_AUD || !env.TEAM_DOMAIN) {
throw new Error('Cloudflare Access must be configured in production');
}
const token = request.headers.get('Cf-Access-Jwt-Assertion');
if (!token) {
throw new Error('Missing Access token');
}
const certsUrl = env.TEAM_DOMAIN.includes('/cdn-cgi/access/certs')
? env.TEAM_DOMAIN
: `https://${env.TEAM_DOMAIN}/cdn-cgi/access/certs`;
const jwks = jose.createRemoteJWKSet(new URL(certsUrl));
const { payload } = await jose.jwtVerify(token, jwks, {
audience: env.POLICY_AUD,
issuer: env.TEAM_DOMAIN
});
return payload;
}// Update system prompt per mailbox
const response = await fetch(`/api/mailboxes/${mailboxId}/agent/system-prompt`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
systemPrompt: `You are a professional email assistant for sales@company.com.
Always be polite and concise. When drafting replies, maintain a friendly tone.`
})
});// Store attachment in R2
async function storeAttachment(env: Env, emailId: string, file: File) {
const key = `attachments/${emailId}/${file.name}`;
await env.R2.put(key, file.stream(), {
httpMetadata: {
contentType: file.type
}
});
return key;
}
// Retrieve attachment
async function getAttachment(env: Env, key: string) {
const object = await env.R2.get(key);
if (!object) return null;
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream'
}
});
}Invalid or expired Access token# 1. Turn Access off and back on in Worker Settings → Domains & Routes
# 2. Copy new POLICY_AUD and TEAM_DOMAIN values from modal
# 3. Reset secrets
wrangler secret put POLICY_AUD
# Paste new value
wrangler secret put TEAM_DOMAIN
# Paste new value (can be team URL or full certs URL)
# 4. Redeploy
npm run deploy# Check via API
curl https://yourapp.workers.dev/api/mailboxeswrangler tail// Verify Workers AI binding in wrangler.jsonc
{
"ai": {
"binding": "AI"
}
}
// Check agent initialization
const ws = new WebSocket(`wss://yourapp.workers.dev/agents/${mailboxId}`);
ws.onerror = (error) => console.error('WebSocket error:', error);
ws.onclose = (event) => console.log('Closed:', event.code, event.reason);# Create bucket if missing
wrangler r2 bucket create agentic-inbox
# Verify binding in wrangler.jsonc
{
"r2_buckets": [
{
"binding": "R2",
"bucket_name": "agentic-inbox"
}
]
}
# Redeploy
npm run deploysend_emailwrangler.jsonc{
"send_email": [
{
"name": "SEB",
"destination_address": "verified@yourdomain.com"
}
]
}npm run dev// worker/auth.ts - for local testing only
if (env.ENVIRONMENT === 'development') {
return { email: 'dev@localhost' }; // Skip Access validation
}# Development
npm run dev # Start local dev server
npm run build # Build frontend and worker
npm run deploy # Deploy to Cloudflare
# Wrangler commands
wrangler tail # Stream Worker logs
wrangler secret put KEY # Set environment secret
wrangler r2 bucket list # List R2 buckets
wrangler d1 execute DB --command "SELECT * FROM emails" # Query D1 (if using D1)
# Debugging
wrangler dev --remote # Debug against production resources
wrangler kv:key list --binding=KV # List KV keys (if using KV)/mcp// claude_desktop_config.json or similar
{
"mcpServers": {
"agentic-inbox": {
"url": "https://yourapp.workers.dev/mcp",
"headers": {
"Cf-Access-Client-Id": "YOUR_SERVICE_TOKEN_ID",
"Cf-Access-Client-Secret": "YOUR_SERVICE_TOKEN_SECRET"
}
}
}
}mailboxId