diff --git a/AGENT_ACCESS.md b/AGENT_ACCESS.md new file mode 100644 index 000000000..f4d2781d6 --- /dev/null +++ b/AGENT_ACCESS.md @@ -0,0 +1,154 @@ +# Scoped Agent Access + +Create a named access key under a mailbox's **Settings > Agent Access**. Select +the exact mailboxes, permissions, and send mode. The selection belongs to that +key, not to the mailbox currently open in the dashboard. Keys are shown once; +only their SHA-256 hashes are stored. Uncheck **Agent access enabled** and save +to revoke a key. Create a replacement key to rotate credentials. + +## Connections + +- Remote MCP: `https:///agent/mcp` +- REST: `POST https:///agent/api/` +- Header: `Authorization: Bearer ` +- REST request/response format: JSON, with `Content-Type: application/json`. +- MCP transport: stateless Streamable HTTP, supporting clients with custom headers. +- Keys cannot access `/api/v1/*`, the dashboard agent, or the legacy `/mcp` endpoint. +- No deletion, mailbox administration, or folder mutation tools are exposed. + +For clients accepting MCP configuration with HTTP headers: + +```json +{ + "mcpServers": { + "agentic-inbox-agent": { + "url": "https:///agent/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +Keep the actual key in the client's secret store or environment configuration. +Client-specific configuration formats may differ. This is key authentication, +not an OAuth login flow. + +## Permissions and modes + +| Tool | Required permissions | +| --- | --- | +| `list_mailboxes`, `get_mailbox` | Any active key; assigned mailboxes only | +| `list_emails`, `get_email`, `get_thread`, `search_emails` | read | +| `create_draft` | draft | +| `generate_reply_draft` | read + draft | +| `send_email` | send | +| `send_reply`, `send_draft` | read + send | + +`draft_only` means a send request creates a draft and returns +`status: "draft_saved_not_sent", sent: false`. `direct` allows actual delivery. +The send permission is required in both cases. Disabling the send permission +rejects send requests instead of silently converting them. + +All content submissions require `footer: { "enabled": true | false }`. +Optional `footer.text` overrides the saved footer for this message only. +`get_mailbox` returns the saved footer so the agent can inspect it first. +An enabled but empty footer is rejected; it is not silently omitted. + +New keys default to drafts only, test mode off, and no extra AI verification of +agent-supplied text. When test mode is enabled, configure an explicit test +recipient. Optional recipient allowlists apply in addition to test mode. Limits are per key, +mailbox, and UTC day. Attempted submissions count toward limits. + +## Generate a reply with inbox AI + +Call `generate_reply_draft` instead of supplying your own message body: + +```json +{ + "mailboxId": "hello@example.com", + "requestId": "reply-generation-unique-001", + "originalEmailId": "", + "instructions": "Write a concise, friendly answer.", + "footer": { "enabled": true } +} +``` + +This works independently of the automatic-drafts setting. The inbox uses the +mailbox's writing prompt and thread context, checks the incoming context with +the existing injection scanner, generates text with Kimi K2.5, and verifies +the draft with the existing draft verifier. The generating model has **no tools** +and can neither send nor manage emails. The response includes `draftId`, `html`, +`text`, and `sent: false`. Review using the response or `get_email`. + +To submit that draft: + +```json +{ + "mailboxId": "hello@example.com", + "requestId": "draft-submission-unique-001", + "draftId": "", + "footer": { "enabled": true } +} +``` + +Use this body with `send_draft`. Existing drafts are never deleted. In +draft-only mode a new review copy is saved. Drafts with CC/BCC or attachments +must currently be sent from the dashboard; they are rejected, not truncated. + +For `send_email`, supply `mailboxId`, `requestId`, `to`, `subject`, `bodyHtml`, +and `footer`. `send_reply` also requires `originalEmailId`. `create_draft` +accepts supplied content only and cannot read or quote existing emails. + +## Retries and results + +Every write/generation requires a stable `requestId` (8-128 ASCII letters, +digits, underscores or hyphens). Retry with the **same ID and same arguments**. +An ID reused with different arguments is rejected. Replays return the existing +outcome without sending or generating again. Draft content on replay is loaded +from the current stored draft. Idempotency records are retained, with no expiry. + +- `sent`: provider accepted the message and the sent copy was saved. This is + not confirmation of final recipient delivery. +- `sent_unrecorded`: provider accepted it but saving the sent copy failed. + **Do not resend.** +- `outcome_unknown` or a pending-operation conflict: delivery may be uncertain. + Inspect the mailbox/provider before taking further action. **Do not use a new + requestId to retry automatically.** +- `failed` / `rejected`: inspect the returned error. + +The same existing draft cannot be submitted directly twice with different IDs. +If an attempt is uncertain, its submission reservation remains in place for +safety. Activity is available under the agent's settings, including pending +operations. Existing live emails are not rewritten or migrated. + +## Cloudflare Access deployment + +The dashboard and legacy endpoints remain protected by human Cloudflare Access +login. The dedicated `/agent/*` namespace uses the scoped key validator in the +Worker and rejects requests without a valid key even when a browser is logged in. + +When Cloudflare Access covers the entire hostname, add a **more specific** Access +application for the same hostname's `/agent/*` path. For headless clients, +prefer a Service Auth policy with a Cloudflare Service Token and send its +`CF-Access-Client-Id` and `CF-Access-Client-Secret` headers in addition to the +scoped Agent Access `Authorization` header. If Service Auth is not available to +the client, a narrowly scoped Bypass policy for `/agent/*` can be used as a +fallback; the Worker still requires the scoped key on every request. Do not +bypass the hostname root, `/api/*`, or `/mcp`. + +Deploy and verify the key validator before enabling this path exception. + +No new R2 bucket, Durable Object class, or SQL migration is required. Credentials +use `agent-access/credentials/` in the existing bucket. Operation receipts, +counters and activity use new `agent-*` KV keys in the existing mailbox Durable +Objects, without modifying their SQL schema or existing email rows. + +## Verification + +Run the focused checks with: + +```bash +npm run test:agent-access +npm run typecheck +npm run build +``` diff --git a/README.md b/README.md index e1e2eb724..b2cea711d 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,50 @@ 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 +- **Scoped remote agent access** — Per-mailbox MCP and REST access with explicit permissions, send modes, recipient restrictions, and daily limits - **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 +## Scoped Agent Access + +Agentic Inbox can expose a dedicated `/agent/*` namespace for external +automation clients. Access is created per named agent key and can be scoped to +specific mailboxes and permissions. + +Supported permissions: + +- `read` — read emails, threads, and search results +- `draft` — create drafts and generate reply drafts +- `send` — submit new messages, replies, or existing drafts + +Each key can be configured with: + +- allowed mailboxes +- draft-only or direct-send mode +- test-recipient restrictions +- recipient allowlists +- daily send and AI-generation limits + +The remote MCP endpoint is: + +```text +https:///agent/mcp +``` + +Clients authenticate with: + +```http +Authorization: Bearer +``` + +The key is shown only once when created and is stored as a hash. The dashboard +and legacy `/mcp` endpoints remain protected by the existing Cloudflare Access +configuration. The `/agent/*` namespace must be protected separately with a +path-specific Cloudflare Access policy or Service Auth configuration. + +All write operations require a stable `requestId` for idempotent retries. +Send operations explicitly select whether the saved mailbox footer is included. + ## Stack - **Frontend:** React 19, React Router v7, Tailwind CSS, Zustand, TipTap, `@cloudflare/kumo` @@ -76,7 +117,11 @@ npm run deploy - [Workers AI](https://developers.cloudflare.com/workers-ai/) enabled (for the agent) - [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/) configured for deployed/shared environments (required in production) -Any user who passes the shared Cloudflare Access policy can access all mailboxes in this app by design. This includes the MCP server at `/mcp` -- external AI tools (Claude Code, Cursor, etc.) connected via MCP can operate on any mailbox by passing a `mailboxId` parameter. There is no per-mailbox authorization; the Cloudflare Access policy is the single trust boundary. +The dashboard and legacy `/mcp` endpoint continue to use the shared Cloudflare +Access policy. External clients using `/agent/*` must additionally present a +scoped Agent Access key, which is checked against the key's allowed mailboxes +and permissions on every request. The legacy `/mcp` endpoint does not use these +scoped keys and retains its existing shared-access behavior. ## Architecture diff --git a/app/components/AgentAccessSettings.tsx b/app/components/AgentAccessSettings.tsx new file mode 100644 index 000000000..80c6efaff --- /dev/null +++ b/app/components/AgentAccessSettings.tsx @@ -0,0 +1,110 @@ +import { Badge, Button, Input, useKumoToastManager } from "@cloudflare/kumo"; +import { CopyIcon, FloppyDiskIcon, KeyIcon, PlusIcon, ClockCounterClockwiseIcon } from "@phosphor-icons/react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { useMailboxes } from "~/queries/mailboxes"; +import api from "~/services/api"; +import { AgentConfigSchema, defaultAgentConfig, type AgentAccess, type AgentActivity, type AgentConfig, type AgentPermission } from "../../shared/agent-access"; + +const selectClass = "w-full min-w-0 rounded-md border border-kumo-line bg-kumo-base px-3 py-2 text-sm text-kumo-default"; +export default function AgentAccessSettings({ mailboxId }: { mailboxId: string }) { + const qc = useQueryClient(); + const toast = useKumoToastManager(); + const { data: mailboxes = [] } = useMailboxes(); + const { data: entries = [], isPending, error: loadError } = useQuery({ queryKey: ["agent-access"], queryFn: api.listAgentAccess }); + const [selected, setSelected] = useState(null); + const [editing, setEditing] = useState(false); + const [config, setConfig] = useState(() => defaultAgentConfig(mailboxId)); + const [recipients, setRecipients] = useState(""); + const [token, setToken] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [activity, setActivity] = useState(null); + const [activityMailbox, setActivityMailbox] = useState(mailboxId); + const [loadingActivity, setLoadingActivity] = useState(false); + useEffect(() => { setSelected(null); setEditing(false); setToken(""); setActivity(null); setError(""); }, [mailboxId]); + const baseUrl = typeof window === "undefined" ? "" : window.location.origin; + function choose(entry: AgentAccess | null) { + setSelected(entry); setEditing(true); setError(""); setToken(""); setActivity(null); + if (entry) { + const { id: _, revision: __, createdAt: ___, updatedAt: ____, ...value } = entry; + setConfig(value); setRecipients(value.allowedRecipients.join("\n")); setActivityMailbox(value.mailboxIds[0]); + } else { setConfig(defaultAgentConfig(mailboxId)); setRecipients(""); setActivityMailbox(mailboxId); } + } + function update(key: K, value: AgentConfig[K]) { setConfig(previous => ({ ...previous, [key]: value })); } + function togglePermission(permission: AgentPermission, checked: boolean) { update("permissions", checked ? [...config.permissions, permission] : config.permissions.filter(p => p !== permission)); } + async function copy(value: string) { + try { await navigator.clipboard.writeText(value); toast.add({ title: "Copied" }); } + catch { setError("Clipboard unavailable"); } + } + async function save() { + setError(""); + const parsed = AgentConfigSchema.safeParse({ ...config, allowedRecipients: recipients.split(/[\n,]+/).map(s => s.trim()).filter(Boolean) }); + if (!parsed.success) { setError("Enter a name, select at least one mailbox and permission, and check recipient addresses and limits."); return; } + setSaving(true); + try { + if (selected) { + const updated = await api.updateAgentAccess(selected.id, parsed.data, selected.revision); + setSelected(updated); + } else { + const created = await api.createAgentAccess(parsed.data); + setSelected(created.access); setToken(created.token); + } + setConfig(parsed.data); + await qc.invalidateQueries({ queryKey: ["agent-access"] }); + toast.add({ title: "Agent access saved" }); + } catch (e) { setError(e instanceof Error ? e.message : "Could not save agent access"); } + finally { setSaving(false); } + } + async function showActivity() { + if (!selected) return; + setLoadingActivity(true); setError(""); + try { setActivity(await api.getAgentActivity(selected.id, activityMailbox)); } + catch (e) { setError(e instanceof Error ? e.message : "Could not load activity"); } + finally { setLoadingActivity(false); } + } + return
+
+

Agent Access

+ +
+ {loadError &&

Could not load agent access.

} + {isPending ?

Loading...

:
+ {entries.map(entry => )} + {entries.length === 0 &&

No agent access configured.

} +
} + {editing &&
+ {selected ? "Edit agent access" : "New agent access"} + update("name", e.target.value)} /> + +
Allowed mailboxes + {mailboxes.map(mailbox => )} +
+
Permissions + {([['read', 'Read emails'], ['draft', 'Create drafts'], ['send', 'Submit send requests']] as const).map(([key, label]) => )} +
+ + + {config.testMode && update("testRecipient", e.target.value)} />} +