From 424193b6f3a921eb5074f5f5dbec22e9961eda97 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 8 Jul 2026 01:30:44 +0800 Subject: [PATCH 1/7] Add AI-assisted email composition with rewrite actions Adds an AI compose bar to the email compose panel with preset rewrite actions (polish, formalize, friendly, shorten) and custom instructions. Supports per-mailbox AI model selection via Workers AI or OpenRouter, configurable in mailbox settings. Co-Authored-By: Claude Opus 4.6 --- app/components/ComposeAIBar.tsx | 82 +++++++++++++++++++++++++++++++++ app/components/ComposePanel.tsx | 39 +++++++++++++++- app/hooks/useComposeForm.ts | 25 +++++++++- app/routes/home.tsx | 8 ++++ app/routes/settings.tsx | 61 ++++++++++++++++++++++++ app/services/api.ts | 6 ++- app/types/index.ts | 6 +++ package-lock.json | 14 ++++++ package.json | 8 +++- workers/agent/index.ts | 10 ++-- workers/index.ts | 22 ++++++++- workers/lib/ai-provider.ts | 53 +++++++++++++++++++++ workers/lib/ai-rewrite.ts | 43 +++++++++++++++++ workers/types.ts | 1 + wrangler.jsonc | 8 ++-- 15 files changed, 372 insertions(+), 14 deletions(-) create mode 100644 app/components/ComposeAIBar.tsx create mode 100644 workers/lib/ai-provider.ts create mode 100644 workers/lib/ai-rewrite.ts diff --git a/app/components/ComposeAIBar.tsx b/app/components/ComposeAIBar.tsx new file mode 100644 index 00000000..06f51958 --- /dev/null +++ b/app/components/ComposeAIBar.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { Button, Input, Loader } from "@cloudflare/kumo"; +import { SparkleIcon } from "@phosphor-icons/react"; +import { useState } from "react"; +import api from "~/services/api"; + +interface ComposeAIBarProps { + mailboxId: string; + body: string; + onRewrite: (newBody: string) => void; +} + +const QUICK_ACTIONS = [ + { action: "polish", label: "Polish" }, + { action: "formalize", label: "Formalize" }, + { action: "friendly", label: "Friendly" }, + { action: "shorten", label: "Shorten" }, +] as const; + +/** AI compose bar with custom instruction input and quick-action buttons. */ +export function ComposeAIBar({ mailboxId, body, onRewrite }: ComposeAIBarProps) { + const [instruction, setInstruction] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [activeAction, setActiveAction] = useState(null); + + const handleRewrite = async (action: string, customInstruction?: string) => { + if (!body.trim() || isLoading) return; + setIsLoading(true); + setActiveAction(action); + try { + const result = await api.rewriteEmailBody( + mailboxId, + body, + action, + customInstruction, + ); + onRewrite(result.body); + if (action === "custom") setInstruction(""); + } finally { + setIsLoading(false); + setActiveAction(null); + } + }; + + const handleCustomSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!instruction.trim()) return; + handleRewrite("custom", instruction.trim()); + }; + + return ( +
+ +
+
+ setInstruction(e.target.value)} + disabled={isLoading} + /> +
+ {QUICK_ACTIONS.map(({ action, label }) => ( + + ))} +
+
+ ); +} diff --git a/app/components/ComposePanel.tsx b/app/components/ComposePanel.tsx index c7196a2b..4722eca7 100644 --- a/app/components/ComposePanel.tsx +++ b/app/components/ComposePanel.tsx @@ -3,10 +3,11 @@ // https://opensource.org/licenses/Apache-2.0 import { Banner, Button, Input } from "@cloudflare/kumo"; -import { FloppyDiskIcon, PaperPlaneTiltIcon, XIcon } from "@phosphor-icons/react"; +import { ArrowCounterClockwiseIcon, FloppyDiskIcon, PaperPlaneTiltIcon, XIcon } from "@phosphor-icons/react"; import { useParams } from "react-router"; import { useComposeForm } from "~/hooks/useComposeForm"; import RichTextEditor from "./RichTextEditor"; +import { ComposeAIBar } from "./ComposeAIBar"; export default function ComposePanel() { const { mailboxId, folder } = useParams<{ @@ -35,6 +36,11 @@ export default function ComposePanel() { handleSend, closeCompose, closePanel, + applyAiRewrite, + undoAiEdit, + redoAiEdit, + canUndoAi, + canRedoAi, } = useComposeForm(mailboxId, folder); return ( @@ -145,7 +151,38 @@ export default function ComposePanel() { value={body} onChange={setBody} /> + {mailboxId && ( + + )} + {(canUndoAi || canRedoAi) && ( +
+ + +
+ )} {/* Footer actions */} diff --git a/app/hooks/useComposeForm.ts b/app/hooks/useComposeForm.ts index 192e5bd7..cfbdca9a 100644 --- a/app/hooks/useComposeForm.ts +++ b/app/hooks/useComposeForm.ts @@ -178,6 +178,8 @@ export function useComposeForm(mailboxId?: string, _folder?: string) { const [showCcBcc, setShowCcBcc] = useState(false); const [subject, setSubject] = useState(""); const [body, setBody] = useState(""); + const [bodyHistory, setBodyHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); const [error, setError] = useState(null); const [isSavingDraft, setIsSavingDraft] = useState(false); const [isSending, setIsSending] = useState(false); @@ -262,5 +264,26 @@ export function useComposeForm(mailboxId?: string, _folder?: string) { finally { setIsSending(false); } }; - return { to, setTo, cc, setCc, bcc, setBcc, showCcBcc, setShowCcBcc, subject, setSubject, body, setBody, error, setError, isSavingDraft, isSending, formTitle, handleSaveDraft, handleSend, closeCompose, closePanel }; + const applyAiRewrite = (newBody: string) => { + const newHistory = [...bodyHistory.slice(0, historyIndex + 1), body, newBody]; + setBodyHistory(newHistory); + setHistoryIndex(newHistory.length - 1); + setBody(newBody); + }; + + const undoAiEdit = () => { + if (historyIndex <= 0) return; + const prevIndex = historyIndex - 1; + setHistoryIndex(prevIndex); + setBody(bodyHistory[prevIndex]); + }; + + const redoAiEdit = () => { + if (historyIndex >= bodyHistory.length - 1) return; + const nextIndex = historyIndex + 1; + setHistoryIndex(nextIndex); + setBody(bodyHistory[nextIndex]); + }; + + return { to, setTo, cc, setCc, bcc, setBcc, showCcBcc, setShowCcBcc, subject, setSubject, body, setBody, error, setError, isSavingDraft, isSending, formTitle, handleSaveDraft, handleSend, closeCompose, closePanel, applyAiRewrite, undoAiEdit, redoAiEdit, canUndoAi: historyIndex > 0, canRedoAi: historyIndex < bodyHistory.length - 1 }; } diff --git a/app/routes/home.tsx b/app/routes/home.tsx index fe221d64..e8d56b61 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -3,6 +3,7 @@ // https://opensource.org/licenses/Apache-2.0 import { + Banner, Button, Dialog, Empty, @@ -252,6 +253,13 @@ export default function HomeRoute() { {createError} )} + {selectedDomain && selectedDomain !== domains[0] && ( + Email Routing and add a catch-all or per-address rule that forwards to this Worker, or the mailbox will only be able to send, not receive.`} + /> + )}
Email Address diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index a4634cda..4574cfe3 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -6,7 +6,11 @@ import { Badge, Button, Input, Loader, useKumoToastManager } from "@cloudflare/k import { RobotIcon, ArrowCounterClockwiseIcon } from "@phosphor-icons/react"; import { useEffect, useState } from "react"; import { useParams } from "react-router"; +import { useQuery } from "@tanstack/react-query"; +import api from "~/services/api"; +import { queryKeys } from "~/queries/keys"; import { useMailbox, useUpdateMailbox } from "~/queries/mailboxes"; +import type { AiProviderSetting } from "~/types"; // Placeholder shown in the textarea when no custom prompt is set. // The authoritative default prompt lives in workers/agent/index.ts (DEFAULT_SYSTEM_PROMPT). @@ -20,22 +24,38 @@ export default function SettingsRoute() { const [displayName, setDisplayName] = useState(""); const [agentPrompt, setAgentPrompt] = useState(""); + const [aiProviderType, setAiProviderType] = useState("workers-ai"); + const [aiModel, setAiModel] = useState(""); const [isSaving, setIsSaving] = useState(false); + const { data: configData } = useQuery({ + queryKey: queryKeys.config, + queryFn: () => api.getConfig(), + staleTime: Infinity, + }); + const openRouterConfigured = configData?.openRouterConfigured ?? false; + useEffect(() => { if (mailbox) { setDisplayName(mailbox.settings?.fromName || mailbox.name || ""); setAgentPrompt(mailbox.settings?.agentSystemPrompt || ""); + setAiProviderType(mailbox.settings?.aiProvider?.type || "workers-ai"); + setAiModel(mailbox.settings?.aiProvider?.model || ""); } }, [mailbox]); const handleSave = async () => { if (!mailbox || !mailboxId) return; setIsSaving(true); + const aiProvider: AiProviderSetting | undefined = + aiModel.trim() + ? { type: aiProviderType, model: aiModel.trim() } + : undefined; const settings = { ...mailbox.settings, fromName: displayName, agentSystemPrompt: agentPrompt.trim() || undefined, + aiProvider, }; try { await updateMailboxMutation.mutateAsync({ mailboxId, settings }); @@ -84,6 +104,47 @@ export default function SettingsRoute() {
+ {/* AI Model Provider */} +
+
+ + + AI Model + +
+

+ Choose which AI provider and model to use for this mailbox. +

+
+
+ + +
+ setAiModel(e.target.value)} + placeholder={aiProviderType === "workers-ai" ? "@cf/moonshotai/kimi-k2.5" : "anthropic/claude-sonnet-4"} + /> +

+ {aiProviderType === "workers-ai" + ? "Enter a Workers AI model ID (e.g. @cf/moonshotai/kimi-k2.5). Leave empty for the default." + : "Enter an OpenRouter model ID (e.g. anthropic/claude-sonnet-4, google/gemini-2.5-flash)."} +

+
+
+ {/* Agent System Prompt */}
diff --git a/app/services/api.ts b/app/services/api.ts index 25160673..09a74057 100644 --- a/app/services/api.ts +++ b/app/services/api.ts @@ -97,7 +97,7 @@ interface EmailListResponse { const api = { // Config getConfig: () => - get<{ domains: string[]; emailAddresses: string[] }>("/api/v1/config"), + get<{ domains: string[]; emailAddresses: string[]; openRouterConfigured: boolean }>("/api/v1/config"), // Mailboxes listMailboxes: () => get("/api/v1/mailboxes"), @@ -160,6 +160,10 @@ const api = { // Search searchEmails: (mailboxId: string, params: Record) => get(`/api/v1/mailboxes/${mailboxId}/search`, { params }), + + // AI + rewriteEmailBody: (mailboxId: string, body: string, action: string, instruction?: string) => + post<{ body: string }>(`/api/v1/mailboxes/${mailboxId}/ai/rewrite`, { body, action, instruction }), }; export default api; diff --git a/app/types/index.ts b/app/types/index.ts index 9ee40892..78a5f767 100644 --- a/app/types/index.ts +++ b/app/types/index.ts @@ -8,12 +8,18 @@ export interface SignatureSettings { html?: string; } +export interface AiProviderSetting { + type: "workers-ai" | "openrouter"; + model: string; +} + export interface MailboxSettings { fromName?: string; forwarding?: { enabled: boolean; email: string }; signature?: SignatureSettings; autoReply?: { enabled: boolean; subject: string; message: string }; agentSystemPrompt?: string; + aiProvider?: AiProviderSetting; } export interface Mailbox { diff --git a/package-lock.json b/package-lock.json index 451dd71a..4a4b8dee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "dependencies": { "@cloudflare/ai-chat": "^0.1.8", "@cloudflare/kumo": "^1.13.0", + "@openrouter/ai-sdk-provider": "^2.10.0", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.99.0", "@tiptap/extension-color": "3.20.2", @@ -2009,6 +2010,19 @@ } } }, + "node_modules/@openrouter/ai-sdk-provider": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.10.0.tgz", + "integrity": "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ai": "^6.0.0", + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", diff --git a/package.json b/package.json index 9d6f0272..4429f3cf 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,12 @@ "type": "module", "cloudflare": { "label": "Agentic Inbox", - "products": ["Workers", "Durable Objects", "R2", "Workers AI"], + "products": [ + "Workers", + "Durable Objects", + "R2", + "Workers AI" + ], "bindings": { "DOMAINS": { "description": "Your domain with [Email Routing](https://developers.cloudflare.com/email-routing/) enabled (e.g. `example.com`). After deploying, create a catch-all Email Routing rule pointing to this Worker." @@ -23,6 +28,7 @@ "dependencies": { "@cloudflare/ai-chat": "^0.1.8", "@cloudflare/kumo": "^1.13.0", + "@openrouter/ai-sdk-provider": "^2.10.0", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.99.0", "@tiptap/extension-color": "3.20.2", diff --git a/workers/agent/index.ts b/workers/agent/index.ts index ab6f59b9..254bbefa 100644 --- a/workers/agent/index.ts +++ b/workers/agent/index.ts @@ -9,7 +9,7 @@ import { convertToModelMessages, stepCountIs, } from "ai"; -import { createWorkersAI } from "workers-ai-provider"; +import { getModelForMailbox } from "../lib/ai-provider"; import { z } from "zod"; import type { EmailFull, EmailMetadata } from "../lib/schemas"; import { verifyDraft, isPromptInjection } from "../lib/ai"; @@ -276,12 +276,12 @@ export class EmailAgent extends AIChatAgent { async onChatMessage(onFinish: any) { const env = this.env as Env; const mailboxId = this.name; - const workersai = createWorkersAI({ binding: env.AI }); + const model = await getModelForMailbox(env, mailboxId); const tools = createEmailTools(env, mailboxId); const systemPrompt = await getSystemPrompt(env, mailboxId); const result = streamText({ - model: workersai("@cf/moonshotai/kimi-k2.5"), + model, system: systemPrompt, messages: await convertToModelMessages(this.messages), tools, @@ -334,7 +334,7 @@ export class EmailAgent extends AIChatAgent { threadId: string; }) { const env = this.env as Env; - const workersai = createWorkersAI({ binding: env.AI }); + const model = await getModelForMailbox(env, emailData.mailboxId); const tools = createEmailTools(env, emailData.mailboxId); const systemPrompt = await getSystemPrompt(env, emailData.mailboxId); @@ -463,7 +463,7 @@ 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"), + model, system: systemPrompt, messages: await convertToModelMessages(messages), tools, diff --git a/workers/index.ts b/workers/index.ts index fd3359ce..f1e39aea 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -8,6 +8,7 @@ import PostalMime from "postal-mime"; import { z } from "zod"; import { sendEmail } from "./email-sender"; import { storeAttachments, type StoredAttachment } from "./lib/attachments"; +import { rewriteEmailBody } from "./lib/ai-rewrite"; import { validateSender, SenderValidationError, @@ -89,7 +90,8 @@ app.get("/api/v1/config", (c) => { const domainsRaw = c.env.DOMAINS || ""; const domains = domainsRaw.split(",").map((d) => d.trim()).filter(Boolean); const emailAddresses = c.env.EMAIL_ADDRESSES ?? []; - return c.json({ domains, emailAddresses }); + const openRouterConfigured = Boolean(c.env.OPENROUTER_API_KEY); + return c.json({ domains, emailAddresses, openRouterConfigured }); }); // -- Mailboxes ------------------------------------------------------ @@ -142,6 +144,24 @@ app.delete("/api/v1/mailboxes/:mailboxId", async (c) => { // -- Emails --------------------------------------------------------- +// AI rewrite endpoint +app.post("/api/v1/mailboxes/:mailboxId/ai/rewrite", async (c: AppContext) => { + const mailboxId = c.req.param("mailboxId")!; + const { body, action, instruction } = (await c.req.json()) as { + body: string; + action: "polish" | "formalize" | "friendly" | "shorten" | "custom"; + instruction?: string; + }; + if (!body || !action) return c.json({ error: "body and action are required" }, 400); + try { + const rewritten = await rewriteEmailBody(c.env, mailboxId, body, action, instruction); + return c.json({ body: rewritten }); + } catch (err) { + const message = err instanceof Error ? err.message : "AI rewrite failed"; + return c.json({ error: message }, 500); + } +}); + app.get("/api/v1/mailboxes/:mailboxId/emails", async (c: AppContext) => { const folder = c.req.query("folder"); const thread_id = c.req.query("thread_id"); diff --git a/workers/lib/ai-provider.ts b/workers/lib/ai-provider.ts new file mode 100644 index 00000000..f1f1e02f --- /dev/null +++ b/workers/lib/ai-provider.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { createWorkersAI } from "workers-ai-provider"; +import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import type { LanguageModel } from "ai"; +import type { Env } from "../types"; + +const DEFAULT_WORKERS_AI_MODEL = "@cf/moonshotai/kimi-k2.5"; + +interface AiProviderSetting { + type: "workers-ai" | "openrouter"; + model: string; +} + +/** Read the per-mailbox AI provider setting from R2. */ +export async function getAiProviderSetting( + env: Env, + mailboxId: string, +): Promise { + const obj = await env.BUCKET.get(`mailboxes/${mailboxId}.json`); + if (!obj) return { type: "workers-ai", model: DEFAULT_WORKERS_AI_MODEL }; + + const settings = (await obj.json()) as Record; + const provider = settings.aiProvider as AiProviderSetting | undefined; + + if (!provider?.type || !provider?.model) { + return { type: "workers-ai", model: DEFAULT_WORKERS_AI_MODEL }; + } + + if (provider.type === "openrouter" && !env.OPENROUTER_API_KEY) { + return { type: "workers-ai", model: DEFAULT_WORKERS_AI_MODEL }; + } + + return provider; +} + +/** Resolve a LanguageModel instance for the given mailbox. */ +export async function getModelForMailbox( + env: Env, + mailboxId: string, +): Promise { + const setting = await getAiProviderSetting(env, mailboxId); + + if (setting.type === "openrouter" && env.OPENROUTER_API_KEY) { + const openrouter = createOpenRouter({ apiKey: env.OPENROUTER_API_KEY }); + return openrouter(setting.model); + } + + const workersai = createWorkersAI({ binding: env.AI }); + return workersai(setting.model || DEFAULT_WORKERS_AI_MODEL); +} diff --git a/workers/lib/ai-rewrite.ts b/workers/lib/ai-rewrite.ts new file mode 100644 index 00000000..ccb85642 --- /dev/null +++ b/workers/lib/ai-rewrite.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { generateText } from "ai"; +import { getModelForMailbox } from "./ai-provider"; +import { stripHtmlToText, textToHtml } from "./email-helpers"; +import type { Env } from "../types"; + +type RewriteAction = "polish" | "formalize" | "friendly" | "shorten" | "custom"; + +const ACTION_PROMPTS: Record, string> = { + polish: "Improve the writing quality of this email. Fix grammar, improve clarity, and make it more professional while keeping the same meaning and tone.", + formalize: "Rewrite this email in a more formal, professional tone. Keep the same meaning but use more formal language.", + friendly: "Rewrite this email in a warmer, more friendly and conversational tone. Keep the same meaning but sound more approachable.", + shorten: "Make this email shorter and more concise. Remove unnecessary words and keep only the essential information.", +}; + +/** Rewrite email body using the mailbox's configured AI model. */ +export async function rewriteEmailBody( + env: Env, + mailboxId: string, + body: string, + action: RewriteAction, + customInstruction?: string, +): Promise { + const model = await getModelForMailbox(env, mailboxId); + const plainText = stripHtmlToText(body); + + const systemPrompt = `You are an email writing assistant. You rewrite email drafts according to instructions. Return ONLY the rewritten email body — no greetings like "Here's the rewrite", no explanations, no markdown formatting. Output plain text only.`; + + const userPrompt = action === "custom" && customInstruction + ? `${customInstruction}\n\nEmail to rewrite:\n${plainText}` + : `${ACTION_PROMPTS[action as Exclude]}\n\nEmail to rewrite:\n${plainText}`; + + const result = await generateText({ + model, + system: systemPrompt, + messages: [{ role: "user", content: userPrompt }], + }); + + return textToHtml(result.text.trim()); +} diff --git a/workers/types.ts b/workers/types.ts index c8966727..e3e0d008 100644 --- a/workers/types.ts +++ b/workers/types.ts @@ -5,4 +5,5 @@ export interface Env extends Cloudflare.Env { POLICY_AUD: string; TEAM_DOMAIN: string; + OPENROUTER_API_KEY?: string; } diff --git a/wrangler.jsonc b/wrangler.jsonc index 53a65cd0..5379c738 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,11 +10,11 @@ "nodejs_compat" ], "vars": { - // Production deploys must also define POLICY_AUD and TEAM_DOMAIN. // 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": [] + "DOMAINS": "shor.lol", + "EMAIL_ADDRESSES": [], + "POLICY_AUD": "3b60a59232bccf2b6feb66e37b4c4363493fc4b5c6dd66fd7b3e86fac33c1ec4", + "TEAM_DOMAIN": "https://printact.cloudflareaccess.com" }, "send_email": [ { From 15ebf4178645da63c879e36aefb07ace5445cd6e Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 20 Jul 2026 23:35:09 +0800 Subject: [PATCH 2/7] Make memory evidence-backed for email drafting Turn mailbox memory into a provenance-aware corpus with chunked retrieval, optional semantic search, Google Drive imports, suggested facts, and a shared draft context pack. Keep source evidence visible to the operator while preventing citations and internal metadata from entering email bodies. Constraint: Memory must remain mailbox-scoped and usable when semantic search is unavailable Rejected: Separate Google Drive retrieval path | would bypass source normalization, deletion, and draft eligibility controls Confidence: high Scope-risk: broad Reversibility: clean Directive: Confirm suggested facts before making them draft-eligible; do not expose context-pack metadata in outgoing email Tested: npm run typecheck; npm run build; git diff --check Not-tested: Live Google Drive OAuth/service-account import and browser end-to-end flows --- .dev.vars.example | 3 + README.md | 5 + app/components/ComposeAIBar.tsx | 82 +++- app/components/ComposePanel.tsx | 2 + app/queries/keys.ts | 19 + app/queries/memory.ts | 140 +++++++ app/routes/memory.tsx | 596 ++++++++++++++++++++++++++++ app/services/api.ts | 74 +++- app/types/index.ts | 105 +++++ docs/memory-plan.md | 33 ++ workers/agent/index.ts | 91 ++++- workers/db/schema.ts | 70 ++++ workers/durableObject/index.ts | 386 ++++++++++++++++++ workers/durableObject/migrations.ts | 110 +++++ workers/index.ts | 314 +++++++++++++++ workers/lib/google-drive.ts | 66 +++ workers/lib/memory-chunks.ts | 55 +++ workers/lib/memory-context.ts | 87 ++++ workers/lib/memory-facts.ts | 58 +++ workers/lib/memory-search.ts | 121 ++++++ workers/lib/memory-upload.ts | 97 +++++ workers/lib/tools.ts | 68 +++- workers/types.ts | 4 +- 23 files changed, 2551 insertions(+), 35 deletions(-) create mode 100644 app/queries/memory.ts create mode 100644 app/routes/memory.tsx create mode 100644 docs/memory-plan.md create mode 100644 workers/lib/google-drive.ts create mode 100644 workers/lib/memory-chunks.ts create mode 100644 workers/lib/memory-context.ts create mode 100644 workers/lib/memory-facts.ts create mode 100644 workers/lib/memory-search.ts create mode 100644 workers/lib/memory-upload.ts diff --git a/.dev.vars.example b/.dev.vars.example index f0c881b3..d39a2b4e 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,3 +1,6 @@ # Cloudflare Access (required in production). TEAM_DOMAIN may be the base Access URL or the full /cdn-cgi/access/certs URL. POLICY_AUD=your-access-policy-audience-tag TEAM_DOMAIN=https://your-team.cloudflareaccess.com +# Optional Google Drive import. Share selected files with this service account. +GOOGLE_SERVICE_ACCOUNT_EMAIL=service-account@example.iam.gserviceaccount.com +GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n" diff --git a/README.md b/README.md index e1e2eb72..da96ced2 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ https://github.com/cloudflare/agentic-inbox/issues/4#issuecomment-4269118513 - **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 - **Configurable and persistent** — Custom system prompts per mailbox, persistent chat history, streaming markdown responses, and tool call visibility +- **Evidence-backed memory** — Markdown/file memory with keyword and optional semantic retrieval, source provenance, confirmed facts, Google Drive imports, and operator-visible drafting context ## Stack @@ -62,6 +63,10 @@ npm run dev 1. Set your domain in `wrangler.jsonc` 2. Create an R2 bucket named `agentic-inbox`: `wrangler r2 bucket create agentic-inbox` +### Optional Google Drive memory imports + +Configure `GOOGLE_SERVICE_ACCOUNT_EMAIL` and `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` as Worker secrets, then share selected Drive files with that service account. In Memory, choose **Google Drive** and paste the selected file IDs. Imports are mailbox-scoped, deduplicated by Drive file ID, converted to Markdown-compatible text, chunked, and indexed through the same pipeline as uploaded files. + ### Deploy ```bash diff --git a/app/components/ComposeAIBar.tsx b/app/components/ComposeAIBar.tsx index 06f51958..6630c4dd 100644 --- a/app/components/ComposeAIBar.tsx +++ b/app/components/ComposeAIBar.tsx @@ -2,14 +2,19 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -import { Button, Input, Loader } from "@cloudflare/kumo"; -import { SparkleIcon } from "@phosphor-icons/react"; +import { Badge, Button, Input, Loader, Popover } from "@cloudflare/kumo"; +import { NoteIcon, SparkleIcon } from "@phosphor-icons/react"; import { useState } from "react"; +import { textToHtml } from "~/lib/utils"; +import { useTemplateList } from "~/queries/templates"; +import { useMemoryContext } from "~/queries/memory"; import api from "~/services/api"; interface ComposeAIBarProps { mailboxId: string; body: string; + subject?: string; + to?: string; onRewrite: (newBody: string) => void; } @@ -20,11 +25,20 @@ const QUICK_ACTIONS = [ { action: "shorten", label: "Shorten" }, ] as const; -/** AI compose bar with custom instruction input and quick-action buttons. */ -export function ComposeAIBar({ mailboxId, body, onRewrite }: ComposeAIBarProps) { +/** Whether the current body is empty or just an empty paragraph placeholder. */ +function isBodyEmpty(body: string): boolean { + return !body || body.trim() === "" || body.trim() === "


"; +} + +/** AI compose bar with custom instruction input, quick-action buttons, and a template inserter. */ +export function ComposeAIBar({ mailboxId, body, subject = "", to = "", onRewrite }: ComposeAIBarProps) { const [instruction, setInstruction] = useState(""); const [isLoading, setIsLoading] = useState(false); const [activeAction, setActiveAction] = useState(null); + const [isTemplatesOpen, setIsTemplatesOpen] = useState(false); + const { data: templates = [] } = useTemplateList(mailboxId); + const contextQuery = [subject, to, body.replace(/<[^>]*>/g, " ")].join(" ").trim(); + const { data: context, isFetching: isContextLoading } = useMemoryContext(mailboxId, contextQuery); const handleRewrite = async (action: string, customInstruction?: string) => { if (!body.trim() || isLoading) return; @@ -51,6 +65,12 @@ export function ComposeAIBar({ mailboxId, body, onRewrite }: ComposeAIBarProps) handleRewrite("custom", instruction.trim()); }; + const handleInsertTemplate = (templateBody: string) => { + const html = textToHtml(templateBody); + onRewrite(isBodyEmpty(body) ? html : `${body}${html}`); + setIsTemplatesOpen(false); + }; + return (
@@ -77,6 +97,60 @@ export function ComposeAIBar({ mailboxId, body, onRewrite }: ComposeAIBarProps) ))} + + + + + + {templates.length === 0 ? ( +

No templates yet.

+ ) : ( +
+ {templates.map((template) => ( + + ))} +
+ )} +
+
+ + + + + +
+ Memory context + {isContextLoading && } +
+ {context?.warnings.map((warning) =>

{warning}

)} + {context?.sources.length ? context.sources.map((source) => ( +
+
+ {source.citation} + {source.source} +
+

{source.excerpt}

+
+ )) :

No relevant memory found yet.

} +
+
); } diff --git a/app/components/ComposePanel.tsx b/app/components/ComposePanel.tsx index 4722eca7..ff66ea74 100644 --- a/app/components/ComposePanel.tsx +++ b/app/components/ComposePanel.tsx @@ -155,6 +155,8 @@ export default function ComposePanel() { )} diff --git a/app/queries/keys.ts b/app/queries/keys.ts index 54bcab12..6af1266f 100644 --- a/app/queries/keys.ts +++ b/app/queries/keys.ts @@ -23,5 +23,24 @@ export const queryKeys = { results: (mailboxId: string, query: string, page: number) => ["search", mailboxId, query, page] as const, }, + memory: { + list: (mailboxId: string) => ["memory", mailboxId] as const, + search: (mailboxId: string, query: string) => + ["memory", mailboxId, "search", query] as const, + detail: (mailboxId: string, id: string) => + ["memory", mailboxId, "detail", id] as const, + context: (mailboxId: string, query: string) => + ["memory", mailboxId, "context", query] as const, + facts: (mailboxId: string, status?: string) => + ["memory", mailboxId, "facts", status ?? "all"] as const, + }, + templates: { + list: (mailboxId: string) => ["templates", mailboxId] as const, + }, + rosters: { + list: (mailboxId: string) => ["rosters", mailboxId] as const, + students: (mailboxId: string, rosterId: string) => + ["rosters", mailboxId, rosterId, "students"] as const, + }, config: ["config"] as const, }; diff --git a/app/queries/memory.ts b/app/queries/memory.ts new file mode 100644 index 00000000..cc796d5f --- /dev/null +++ b/app/queries/memory.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import api from "~/services/api"; +import type { DraftContextPack, MemoryEntry, MemoryFileDetail, MemoryFact, MemorySearchResponse } from "~/types"; +import { queryKeys } from "./keys"; + +export function useMemoryList(mailboxId: string | undefined) { + return useQuery({ + queryKey: mailboxId + ? queryKeys.memory.list(mailboxId) + : ["memory", "_disabled"], + queryFn: () => api.listMemory(mailboxId!), + enabled: !!mailboxId, + refetchInterval: (query) => { + const hasProcessing = query.state.data?.some((e) => e.status === "processing"); + return hasProcessing ? 3_000 : false; + }, + }); +} + +export function useAddMemory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + mailboxId, + title, + content, + tags, + }: { mailboxId: string; title: string; content: string; tags?: string }) => + api.addMemory(mailboxId, { title, content, tags }), + onSuccess: (_data, { mailboxId }) => { + qc.invalidateQueries({ queryKey: queryKeys.memory.list(mailboxId) }); + }, + }); +} + +export function useUploadMemory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + mailboxId, + file, + title, + tags, + }: { mailboxId: string; file: File; title?: string; tags?: string }) => + api.uploadMemory(mailboxId, file, title, tags), + onSuccess: (_data, { mailboxId }) => { + qc.invalidateQueries({ queryKey: queryKeys.memory.list(mailboxId) }); + }, + }); +} + +export function useMemoryDetail(mailboxId: string | undefined, id: string | undefined) { + return useQuery({ + queryKey: mailboxId && id + ? queryKeys.memory.detail(mailboxId, id) + : ["memory", "_disabled"], + queryFn: () => api.getMemory(mailboxId!, id!), + enabled: !!mailboxId && !!id, + }); +} + +export function useUpdateMemory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + mailboxId, + id, + title, + tags, + draft_eligible, + }: { mailboxId: string; id: string; title?: string; tags?: string; draft_eligible?: boolean }) => + api.updateMemory(mailboxId, id, { title, tags, draft_eligible }), + onSuccess: (_data, { mailboxId, id }) => { + qc.invalidateQueries({ queryKey: queryKeys.memory.list(mailboxId) }); + qc.invalidateQueries({ queryKey: queryKeys.memory.detail(mailboxId, id) }); + }, + }); +} + +export function useSummarizeMemory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ mailboxId, id }: { mailboxId: string; id: string }) => + api.summarizeMemory(mailboxId, id), + onSuccess: (_data, { mailboxId, id }) => { + qc.invalidateQueries({ queryKey: queryKeys.memory.list(mailboxId) }); + qc.invalidateQueries({ queryKey: queryKeys.memory.detail(mailboxId, id) }); + }, + }); +} + +export function useDeleteMemory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ mailboxId, id }: { mailboxId: string; id: string }) => + api.deleteMemory(mailboxId, id), + onSuccess: (_data, { mailboxId }) => { + qc.invalidateQueries({ queryKey: queryKeys.memory.list(mailboxId) }); + }, + }); +} + +export function useSearchMemory(mailboxId: string | undefined, query: string) { + return useQuery({ + queryKey: mailboxId + ? queryKeys.memory.search(mailboxId, query) + : ["memory", "_disabled"], + queryFn: () => api.searchMemory(mailboxId!, query), + enabled: !!mailboxId && query.trim().length > 0, + }); +} + +export function useMemoryContext(mailboxId: string | undefined, query: string) { + return useQuery({ + queryKey: mailboxId ? queryKeys.memory.context(mailboxId, query) : ["memory", "_disabled"], + queryFn: () => api.getMemoryContext(mailboxId!, query), + enabled: !!mailboxId && query.trim().length > 0, + }); +} + +export function useMemoryFacts(mailboxId: string | undefined, status?: string) { + return useQuery({ + queryKey: mailboxId ? queryKeys.memory.facts(mailboxId, status) : ["memory", "_disabled"], + queryFn: () => api.listMemoryFacts(mailboxId!, status), + enabled: !!mailboxId, + }); +} + +export function useUpdateMemoryFactStatus() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ mailboxId, id, status }: { mailboxId: string; id: string; status: MemoryFact["status"] }) => + api.updateMemoryFactStatus(mailboxId, id, status), + onSuccess: (_data, { mailboxId }) => qc.invalidateQueries({ queryKey: ["memory", mailboxId, "facts"] }), + }); +} diff --git a/app/routes/memory.tsx b/app/routes/memory.tsx new file mode 100644 index 00000000..6d73719f --- /dev/null +++ b/app/routes/memory.tsx @@ -0,0 +1,596 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { Badge, Button, Dialog, Input, Loader, Tabs, Tooltip, useKumoToastManager } from "@cloudflare/kumo"; +import { + BrainIcon, + CloudArrowUpIcon, + FileDocIcon, + FileImageIcon, + FilePdfIcon, + FileTextIcon, + MagnifyingGlassIcon, + PlusIcon, + SparkleIcon, + TrashIcon, + XIcon, +} from "@phosphor-icons/react"; +import { useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { useParams } from "react-router"; +import { formatCount } from "~/lib/text-metrics"; +import api from "~/services/api"; +import { + useAddMemory, + useDeleteMemory, + useMemoryDetail, + useMemoryList, + useSearchMemory, + useSummarizeMemory, + useUpdateMemory, + useUploadMemory, +} from "~/queries/memory"; +import { queryKeys } from "~/queries/keys"; +import type { MemoryEntry, MemoryHit } from "~/types"; + +const SOURCE_TYPE_ICONS: Record = { + text: , + markdown: , + pdf: , + docx: , + image: , +}; + +interface StagedFile { + file: File; + title: string; +} + +export default function MemoryRoute() { + const { mailboxId } = useParams<{ mailboxId: string }>(); + const toastManager = useKumoToastManager(); + const queryClient = useQueryClient(); + const { data: entries = [], isLoading } = useMemoryList(mailboxId); + const addMemoryMutation = useAddMemory(); + const uploadMemoryMutation = useUploadMemory(); + const deleteMemoryMutation = useDeleteMemory(); + const updateMemoryMutation = useUpdateMemory(); + const summarizeMemoryMutation = useSummarizeMemory(); + + const [searchQuery, setSearchQuery] = useState(""); + const { data: searchData } = useSearchMemory(mailboxId, searchQuery); + + const [isAddOpen, setIsAddOpen] = useState(false); + const [addTab, setAddTab] = useState<"text" | "file" | "drive">("text"); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [tags, setTags] = useState(""); + const [stagedFiles, setStagedFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [isUploadingBatch, setIsUploadingBatch] = useState(false); + const [driveFileIds, setDriveFileIds] = useState(""); + const fileInputRef = useRef(null); + + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isBulkTagsOpen, setIsBulkTagsOpen] = useState(false); + const [bulkTags, setBulkTags] = useState(""); + const [isBulkSaving, setIsBulkSaving] = useState(false); + + const [previewId, setPreviewId] = useState(null); + const { data: previewDetail } = useMemoryDetail(mailboxId, previewId ?? undefined); + + const resetAddForm = () => { + setTitle(""); + setContent(""); + setTags(""); + setStagedFiles([]); + setDriveFileIds(""); + setAddTab("text"); + }; + + const addFiles = (files: FileList | File[]) => { + const newEntries = Array.from(files).map((f) => ({ file: f, title: f.name })); + setStagedFiles((prev) => [...prev, ...newEntries]); + }; + + const updateStagedTitle = (index: number, value: string) => { + setStagedFiles((prev) => prev.map((sf, i) => (i === index ? { ...sf, title: value } : sf))); + }; + + const removeStaged = (index: number) => { + setStagedFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleAdd = async (e: React.FormEvent) => { + e.preventDefault(); + if (!mailboxId) return; + + if (addTab === "file") { + if (stagedFiles.length === 0) return; + setIsUploadingBatch(true); + const results = await Promise.allSettled( + stagedFiles.map((sf) => + uploadMemoryMutation.mutateAsync({ + mailboxId, + file: sf.file, + title: sf.title.trim() || undefined, + tags: tags.trim() || undefined, + }), + ), + ); + setIsUploadingBatch(false); + const failed = results.filter((r) => r.status === "rejected").length; + resetAddForm(); + setIsAddOpen(false); + if (failed === 0) { + toastManager.add({ title: `${results.length} file(s) uploaded, processing in background` }); + } else { + toastManager.add({ + title: `${results.length - failed} of ${results.length} file(s) uploaded, ${failed} failed`, + variant: "error", + }); + } + return; + } + + if (addTab === "drive") { + if (!driveFileIds.trim()) return; + try { + await api.importGoogleDrive(mailboxId, driveFileIds.split(/[\s,]+/).filter(Boolean)); + await queryClient.invalidateQueries({ queryKey: queryKeys.memory.list(mailboxId) }); + resetAddForm(); + setIsAddOpen(false); + toastManager.add({ title: "Google Drive import completed" }); + } catch { + toastManager.add({ title: "Google Drive import failed", variant: "error" }); + } + return; + } + + if (!title.trim() || !content.trim()) return; + try { + await addMemoryMutation.mutateAsync({ + mailboxId, + title: title.trim(), + content: content.trim(), + tags: tags.trim() || undefined, + }); + resetAddForm(); + setIsAddOpen(false); + toastManager.add({ title: "Memory note added" }); + } catch { + toastManager.add({ title: "Failed to add memory note", variant: "error" }); + } + }; + + const handleDelete = async (id: string) => { + if (!mailboxId) return; + try { + await deleteMemoryMutation.mutateAsync({ mailboxId, id }); + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + toastManager.add({ title: "Memory note deleted" }); + } catch { + toastManager.add({ title: "Failed to delete memory note", variant: "error" }); + } + }; + + const toggleSelect = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleBulkTagsSave = async () => { + if (!mailboxId) return; + setIsBulkSaving(true); + const ids = [...selectedIds]; + const results = await Promise.allSettled( + ids.map((id) => updateMemoryMutation.mutateAsync({ mailboxId, id, tags: bulkTags.trim() || undefined })), + ); + setIsBulkSaving(false); + setIsBulkTagsOpen(false); + setBulkTags(""); + setSelectedIds(new Set()); + const failed = results.filter((r) => r.status === "rejected").length; + if (failed === 0) { + toastManager.add({ title: `Updated tags for ${ids.length} item(s)` }); + } else { + toastManager.add({ title: `${failed} of ${ids.length} update(s) failed`, variant: "error" }); + } + }; + + const handleGenerateSummary = () => { + if (!mailboxId || !previewId) return; + summarizeMemoryMutation.mutate({ mailboxId, id: previewId }); + }; + + const isSearching = searchQuery.trim().length > 0; + const searchResults: MemoryHit[] = searchData?.results ?? []; + + return ( +
+
+
+ +

Memory

+
+ +
+ +

+ Store notes (policies, reference info) the AI agent can search when drafting replies. +

+ +
+ + setSearchQuery(e.target.value)} + /> +
+ + {!isSearching && selectedIds.size > 0 && ( +
+ {selectedIds.size} selected + + +
+ )} + + {isLoading ? ( +
+ +
+ ) : isSearching ? ( +
+ {searchResults.length === 0 ? ( +
+ No matches found. +
+ ) : ( + searchResults.map((hit) => ( +
+
+ + {hit.title || "Untitled"} + + + {hit.source} + +
+

{hit.snippet}

+
+ )) + )} +
+ ) : ( +
+ {entries.length === 0 ? ( +
+ No memory notes yet. +
+ ) : ( + entries.map((entry: MemoryEntry) => ( +
+ toggleSelect(entry.id)} + onClick={(e) => e.stopPropagation()} + aria-label="Select memory note" + /> + + + +
+ )) + )} +
+ )} + + {/* Add Memory dialog */} + { + setIsAddOpen(open); + if (!open) resetAddForm(); + }} + > + + + Add memory + + setAddTab(v as "text" | "file" | "drive")} + className="mb-4" + /> +
+ {addTab === "text" ? ( + <> + setTitle(e.target.value)} + required + /> +
+ +