Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ https://github.com/cloudflare/agentic-inbox/issues/4#issuecomment-4269118513
- **Full email client** — Send and receive emails via Cloudflare Email Routing with a rich text composer, reply/forward threading, folder organization, search, and attachments
- **Per-mailbox isolation** — Each mailbox runs in its own Durable Object with SQLite storage and R2 for attachments
- **Built-in AI agent** — Side panel with 9 email tools for reading, searching, drafting, and sending
- **Auto-draft on new email** — Agent automatically reads inbound emails and generates draft replies, always requiring explicit confirmation before sending
- **Auto-draft on new email** — Agent automatically reads inbound emails and generates draft replies, requiring explicit confirmation before sending by default
- **Configurable and persistent** — Custom system prompts per mailbox, persistent chat history, streaming markdown responses, and tool call visibility

## Stack
Expand All @@ -62,6 +62,8 @@ npm run dev
1. Set your domain in `wrangler.jsonc`
2. Create an R2 bucket named `agentic-inbox`: `wrangler r2 bucket create agentic-inbox`

By default, inbound email automation creates draft replies only. To allow the new-email automation to send verified replies automatically, set `AUTOMATED_SENDING_ENABLED` to `true` in your Wrangler config.

### Deploy

```bash
Expand Down
108 changes: 83 additions & 25 deletions workers/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
toolSearchEmails,
toolDraftReply,
toolDraftEmail,
toolSendReply,
toolMarkEmailRead,
toolMoveEmail,
toolDiscardDraft,
Expand All @@ -46,6 +47,15 @@ function defineTool(def: {
};
}

function isAutomatedSendingEnabled(env: Env): boolean {
const value = (env as unknown as Record<string, unknown>).AUTOMATED_SENDING_ENABLED;

if (typeof value === "boolean") return value;
if (typeof value !== "string") return false;

return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}

/**
* Default system prompt used when no custom prompt is configured for a mailbox.
* Users can override this on a per-mailbox basis via the Settings UI.
Expand Down Expand Up @@ -107,7 +117,11 @@ async function getSystemPrompt(env: Env, mailboxId: string): Promise<string> {
return DEFAULT_SYSTEM_PROMPT;
}

function createEmailTools(env: Env, mailboxId: string) {
function createEmailTools(
env: Env,
mailboxId: string,
options: { automatedReplySending?: boolean } = {},
) {
return {
list_emails: defineTool({
description:
Expand Down Expand Up @@ -202,7 +216,9 @@ function createEmailTools(env: Env, mailboxId: string) {

draft_reply: defineTool({
description:
"Draft a reply to an existing email and save it to the Drafts folder. This does NOT send — it saves a draft for the operator to review and send from the UI. Write the body as plain text — no HTML tags.",
options.automatedReplySending
? "Create a verified reply to an existing email and send it automatically because automated sending is enabled. Write the body as plain text — no HTML tags."
: "Draft a reply to an existing email and save it to the Drafts folder. This does NOT send — it saves a draft for the operator to review and send from the UI. Write the body as plain text — no HTML tags.",
parameters: z.object({
originalEmailId: z
.string()
Expand All @@ -218,6 +234,15 @@ function createEmailTools(env: Env, mailboxId: string) {
),
}),
execute: async ({ originalEmailId, to, subject, body }): Promise<unknown> => {
if (options.automatedReplySending) {
return toolSendReply(env, mailboxId, {
originalEmailId,
to,
subject,
bodyHtml: textToHtml(body),
});
}

return toolDraftReply(env, mailboxId, {
originalEmailId,
to,
Expand Down Expand Up @@ -335,8 +360,17 @@ export class EmailAgent extends AIChatAgent<any> {
}) {
const env = this.env as Env;
const workersai = createWorkersAI({ binding: env.AI });
const tools = createEmailTools(env, emailData.mailboxId);
const automatedSendingEnabled = isAutomatedSendingEnabled(env);
const tools = createEmailTools(env, emailData.mailboxId, {
automatedReplySending: automatedSendingEnabled,
});
const systemPrompt = await getSystemPrompt(env, emailData.mailboxId);
const automationSystemPrompt = automatedSendingEnabled
? `${systemPrompt}

## Automated Sending
Automated sending is enabled for this new-email workflow. Use draft_reply to create exactly one verified reply. In this workflow, draft_reply sends the reply automatically instead of saving a draft.`
: systemPrompt;

// Pre-read the email and thread so the agent has full context
// without needing to waste tool calls discovering it
Expand Down Expand Up @@ -450,6 +484,12 @@ This is the first message in the thread (no prior conversation).`;

Based on the email content and thread context above, draft a reply using draft_reply. If you need more context, use get_thread with thread ID "${emailData.threadId}".`;

if (automatedSendingEnabled) {
autoPrompt += `

Automated sending is enabled for this workflow. Calling draft_reply will send the verified reply automatically instead of saving it as a draft.`;
}

// Fresh context for auto-draft -- don't include prior chat history
// to avoid confusing the model with old messages and tool calls
const messages = [
Expand All @@ -464,14 +504,17 @@ Based on the email content and thread context above, draft a reply using draft_r
try {
const result = await generateText({
model: workersai("@cf/moonshotai/kimi-k2.5"),
system: systemPrompt,
system: automationSystemPrompt,
messages: await convertToModelMessages(messages),
tools,
stopWhen: stepCountIs(5),
});

// Check if draft_reply was called (saves to Drafts as side effect).
// If NOT, save the agent's text response as a draft directly.
const draftReplyToolCalled = result.steps.some((step) =>
step.toolCalls.some((tc) => tc.toolName === "draft_reply"),
);
const draftToolCalled = result.steps.some((step) =>
step.toolCalls.some((tc) => tc.toolName === "draft_reply" || tc.toolName === "draft_email"),
);
Expand All @@ -487,33 +530,45 @@ Based on the email content and thread context above, draft a reply using draft_r
const reSubject = emailData.subject.startsWith("Re:")
? emailData.subject
: `Re: ${emailData.subject}`;
await draftStub.createEmail(
Folders.DRAFT,
{
id: draftId,
const bodyHtml = /<[a-z][\s\S]*>/i.test(sanitizedText)
? sanitizedText
: textToHtml(sanitizedText);

if (automatedSendingEnabled) {
await toolSendReply(env, emailData.mailboxId, {
originalEmailId: emailData.emailId,
to: emailData.sender.toLowerCase(),
subject: reSubject,
sender: emailData.mailboxId.toLowerCase(),
recipient: emailData.sender.toLowerCase(),
date: new Date().toISOString(),
// verifyDraft may return plain text or HTML depending on its
// code path. Only wrap in textToHtml if it's plain text.
body: /<[a-z][\s\S]*>/i.test(sanitizedText)
? sanitizedText
: textToHtml(sanitizedText),
in_reply_to: emailData.emailId,
email_references: null,
thread_id: emailData.threadId,
},
[],
);
// Inline text saved as draft
bodyHtml,
});
// Inline text sent as an automated reply
} else {
await draftStub.createEmail(
Folders.DRAFT,
{
id: draftId,
subject: reSubject,
sender: emailData.mailboxId.toLowerCase(),
recipient: emailData.sender.toLowerCase(),
date: new Date().toISOString(),
body: bodyHtml,
in_reply_to: emailData.emailId,
email_references: null,
thread_id: emailData.threadId,
},
[],
);
// Inline text saved as draft
}
}
}

// Persist the conversation into the agent's chat history
// If it called the tool, we just log a simple success message so the chat isn't cluttered
// with conversational slop.
const assistantText = draftToolCalled
const assistantText = draftReplyToolCalled && automatedSendingEnabled
? `Sent automated reply to ${emailData.sender}.`
: draftToolCalled
? `Created draft reply to ${emailData.sender}.`
: result.text;

Expand Down Expand Up @@ -546,7 +601,10 @@ Based on the email content and thread context above, draft a reply using draft_r

await this.persistMessages([...this.messages, ...newMessages]);

return { status: "draft_generated", text: result.text };
return {
status: automatedSendingEnabled ? "reply_sent" : "draft_generated",
text: result.text,
};
} catch (e) {
console.error("Auto-draft failed:", (e as Error).message);
return { status: "error", error: (e as Error).message };
Expand Down
4 changes: 3 additions & 1 deletion wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
// TEAM_DOMAIN may be the base Access URL or the full /cdn-cgi/access/certs URL.
// The worker now fails closed outside local development if Access is not configured.
"DOMAINS": "example.com",
"EMAIL_ADDRESSES": []
"EMAIL_ADDRESSES": [],
// When true, the new-email automation sends verified replies instead of saving drafts.
"AUTOMATED_SENDING_ENABLED": false
},
"send_email": [
{
Expand Down