From d796383b8b77db577be140a62c9114a3f189f134 Mon Sep 17 00:00:00 2001 From: Thomas Bechtold Date: Tue, 8 Sep 2026 14:04:27 +0200 Subject: [PATCH 1/3] feat: add scoped agent access for email tools --- AGENT_ACCESS.md | 149 +++++++++++++++ app/components/AgentAccessSettings.tsx | 110 +++++++++++ app/routes/settings.tsx | 2 + app/services/api.ts | 5 + package-lock.json | 26 +++ package.json | 3 + shared/agent-access.ts | 34 ++++ shared/signature.ts | 135 +++++++++++++ tests/agent-access.test.mjs | 251 +++++++++++++++++++++++++ tests/agent-auth.test.mjs | 49 +++++ workers/agent-access/actions.ts | 140 ++++++++++++++ workers/agent-access/credentials.ts | 67 +++++++ workers/agent-access/definitions.ts | 25 +++ workers/agent-access/operations.ts | 68 +++++++ workers/agent-access/routes.ts | 124 ++++++++++++ workers/app.ts | 7 +- workers/durableObject/index.ts | 11 ++ workers/lib/signature.ts | 62 ++++++ 18 files changed, 1267 insertions(+), 1 deletion(-) create mode 100644 AGENT_ACCESS.md create mode 100644 app/components/AgentAccessSettings.tsx create mode 100644 shared/agent-access.ts create mode 100644 shared/signature.ts create mode 100644 tests/agent-access.test.mjs create mode 100644 tests/agent-auth.test.mjs create mode 100644 workers/agent-access/actions.ts create mode 100644 workers/agent-access/credentials.ts create mode 100644 workers/agent-access/definitions.ts create mode 100644 workers/agent-access/operations.ts create mode 100644 workers/agent-access/routes.ts create mode 100644 workers/lib/signature.ts diff --git a/AGENT_ACCESS.md b/AGENT_ACCESS.md new file mode 100644 index 000000000..be5502dc7 --- /dev/null +++ b/AGENT_ACCESS.md @@ -0,0 +1,149 @@ +# 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, with a Bypass policy. This +only bypasses the interactive edge login; 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/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)} />} +