diff --git a/app/components/AttachmentUpload.tsx b/app/components/AttachmentUpload.tsx new file mode 100644 index 000000000..f70b7afdf --- /dev/null +++ b/app/components/AttachmentUpload.tsx @@ -0,0 +1,123 @@ +// 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 } from "@cloudflare/kumo"; +import { PaperclipIcon, XIcon } from "@phosphor-icons/react"; +import { useRef } from "react"; +import { formatBytes } from "~/lib/utils"; + +export interface AttachmentFile { + file: File; + id: string; // temporary id for display +} + +interface AttachmentUploadProps { + attachments: AttachmentFile[]; + onAdd: (files: File[]) => void; + onRemove: (id: string) => void; + maxSize?: number; // in bytes, default 25MB + disabled?: boolean; +} + +const DEFAULT_MAX_SIZE = 25 * 1024 * 1024; // 25MB + +export default function AttachmentUpload({ + attachments, + onAdd, + onRemove, + maxSize = DEFAULT_MAX_SIZE, + disabled = false, +}: AttachmentUploadProps) { + const inputRef = useRef(null); + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + + // Validate files + const validFiles: File[] = []; + for (const file of files) { + if (file.size > maxSize) { + console.warn(`File ${file.name} exceeds size limit (${formatBytes(file.size)} > ${formatBytes(maxSize)})`); + continue; + } + validFiles.push(file); + } + + if (validFiles.length > 0) { + onAdd(validFiles); + } + + // Reset input so the same file can be selected again + if (inputRef.current) { + inputRef.current.value = ""; + } + }; + + const handleClick = () => { + inputRef.current?.click(); + }; + + return ( +
+
+ + +
+ + {attachments.length > 0 && ( +
+
+ {attachments.length} attachment{attachments.length !== 1 ? "s" : ""} +
+
+ {attachments.map((att) => ( +
+
+ +
+
+ {att.file.name} +
+
+ {formatBytes(att.file.size)} +
+
+
+ +
+ ))} +
+
+ )} +
+ ); +} diff --git a/app/components/ComposeEmail.tsx b/app/components/ComposeEmail.tsx index e320d9c7b..8f77e2fe9 100644 --- a/app/components/ComposeEmail.tsx +++ b/app/components/ComposeEmail.tsx @@ -3,141 +3,74 @@ // https://opensource.org/licenses/Apache-2.0 import { Banner, Button, Dialog, Input, Text } from "@cloudflare/kumo"; -import { FloppyDiskIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react"; +import { FileIcon, PaperclipIcon, PaperPlaneTiltIcon, XIcon, FloppyDiskIcon } from "@phosphor-icons/react"; import { useParams } from "react-router"; import { useComposeForm } from "~/hooks/useComposeForm"; import RichTextEditor from "./RichTextEditor"; import { useUIStore } from "~/hooks/useUIStore"; +function formatSize(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export default function ComposeEmail() { - const { mailboxId, folder } = useParams<{ - mailboxId: string; - folder: string; - }>(); - + const { mailboxId, folder } = useParams<{ mailboxId: string; folder: string }>(); const { isComposeModalOpen, closeComposeModal } = useUIStore(); - const { - to, - setTo, - cc, - setCc, - bcc, - setBcc, - showCcBcc, - setShowCcBcc, - subject, - setSubject, - body, - setBody, - error, - isSavingDraft, - isSending, - formTitle, - handleSaveDraft, - handleSend, + to, setTo, cc, setCc, bcc, setBcc, showCcBcc, setShowCcBcc, + subject, setSubject, body, setBody, attachments, addAttachments, removeAttachment, + error, isSavingDraft, isSending, formTitle, handleSaveDraft, handleSend, } = useComposeForm(mailboxId, folder); return ( - !open && !isSending && closeComposeModal()} - > + !open && !isSending && closeComposeModal()}> - - {formTitle} - + {formTitle}
handleSend(e, closeComposeModal)} className="space-y-4"> {error && }
-
- setTo(e.target.value)} - required +
setTo(e.target.value)} required />
+ {!showCcBcc && } +
+ {showCcBcc && setCc(e.target.value)} placeholder="Separate multiple addresses with commas" />} + {showCcBcc && setBcc(e.target.value)} placeholder="Separate multiple addresses with commas" />} + setSubject(e.target.value)} required /> +
Message
+ + {/* Attachment UI: the hook already converts files to the base64 format expected by SendEmailRequestSchema. */} +
+
- {!showCcBcc && ( - + + {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => ( +
+ + {attachment.filename} + {formatSize(attachment.size)} + +
+ ))} +
)}
- {showCcBcc && ( - setCc(e.target.value)} - placeholder="Separate multiple addresses with commas" - /> - )} - {showCcBcc && ( - setBcc(e.target.value)} - placeholder="Separate multiple addresses with commas" - /> - )} - setSubject(e.target.value)} - required - /> -
- - Message - - -
+
- +
- - + +
diff --git a/app/components/ComposePanel.tsx b/app/components/ComposePanel.tsx index c7196a2bd..009655baf 100644 --- a/app/components/ComposePanel.tsx +++ b/app/components/ComposePanel.tsx @@ -3,11 +3,17 @@ // https://opensource.org/licenses/Apache-2.0 import { Banner, Button, Input } from "@cloudflare/kumo"; -import { FloppyDiskIcon, PaperPlaneTiltIcon, XIcon } from "@phosphor-icons/react"; +import { FileIcon, FloppyDiskIcon, PaperclipIcon, PaperPlaneTiltIcon, XIcon } from "@phosphor-icons/react"; import { useParams } from "react-router"; import { useComposeForm } from "~/hooks/useComposeForm"; import RichTextEditor from "./RichTextEditor"; +function formatSize(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export default function ComposePanel() { const { mailboxId, folder } = useParams<{ mailboxId: string; @@ -27,6 +33,9 @@ export default function ComposePanel() { setSubject, body, setBody, + attachments, + addAttachments, + removeAttachment, error, isSavingDraft, isSending, @@ -91,89 +100,71 @@ export default function ComposePanel() { {showCcBcc && (
- +
- setCc(e.target.value)} - placeholder="Separate multiple addresses with commas" - /> + setCc(e.target.value)} placeholder="Separate multiple addresses with commas" />
)} {showCcBcc && (
- +
- setBcc(e.target.value)} - placeholder="Separate multiple addresses with commas" - /> + setBcc(e.target.value)} placeholder="Separate multiple addresses with commas" />
)}
- +
- setSubject(e.target.value)} - required - /> + setSubject(e.target.value)} required />
- + +
+ +
+ + {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => ( +
+ + {attachment.filename} + {formatSize(attachment.size)} + +
+ ))} +
+ )}
- {/* Footer actions */}
- +
- -
diff --git a/app/components/EmailIframe.tsx b/app/components/EmailIframe.tsx index 034f0b6ed..e46288dba 100644 --- a/app/components/EmailIframe.tsx +++ b/app/components/EmailIframe.tsx @@ -4,51 +4,108 @@ import DOMPurify from "dompurify"; import { useCallback, useEffect, useRef, useState } from "react"; +import type { Attachment } from "~/types"; interface EmailIframeProps { body: string; - /** When true, iframe auto-sizes to content height instead of filling parent */ + mailboxId?: string; + emailId?: string; + attachments?: Attachment[]; autoSize?: boolean; } +function attachmentUrl(mailboxId: string, emailId: string, attachmentId: string) { + return `/api/v1/mailboxes/${encodeURIComponent(mailboxId)}/emails/${encodeURIComponent(emailId)}/attachments/${encodeURIComponent(attachmentId)}`; +} + +async function blobToDataUrl(blob: Blob): Promise { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} + +async function fetchImageAsDataUrl(url: string): Promise { + try { + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) return null; + const blob = await response.blob(); + if (!blob.type.startsWith("image/")) return null; + return await blobToDataUrl(blob); + } catch { + return null; + } +} + /** - * Renders email HTML inside a sandboxed iframe. - * - * Security model: - * - DOMPurify sanitises the HTML before injection. - * - The iframe sandbox does NOT include `allow-same-origin`, so even if - * DOMPurify has a bypass the attacker's code runs in an opaque origin - * with no access to the parent page's cookies, DOM, or API. - * - Because the iframe is cross-origin we cannot read `contentDocument` - * for auto-sizing. Instead, the injected HTML includes a tiny inline - * script that posts its body height to the parent via `postMessage`. - * The `allow-scripts` flag is required for this, but scripts inside - * the opaque-origin sandbox cannot access anything useful. - * - A strict CSP meta tag blocks external resource loads inside the - * iframe as a defense-in-depth layer. + * Email message components currently rewrite CID references before handing + * the body to this component. The rewritten URL can be relative OR absolute. + * Convert those attachment URLs in the authenticated parent page, then put + * the resulting data URL into the sandboxed iframe. This avoids requiring + * the sandboxed iframe to carry mailbox authentication cookies. */ -export default function EmailIframe({ body, autoSize }: EmailIframeProps) { +async function rewriteInlineImagesForIframe( + body: string, + mailboxId?: string, + emailId?: string, + attachments?: Attachment[], +): Promise { + if (!body) return body; + let result = body; + + // Match both: + // /api/v1/mailboxes/.../attachments/... + // https://mail.example.com/api/v1/mailboxes/.../attachments/... + // The previous implementation only matched the first form, while + // rewriteInlineImages() normally produces the second form in the browser. + const apiImageRe = /(?:src|background)=["']((?:https?:\/\/[^"'\s]+)?\/api\/v1\/mailboxes\/[^"'\s]+\/emails\/[^"'\s]+\/attachments\/[^"'\s]+)["']/gi; + const apiUrls = [...result.matchAll(apiImageRe)].map((m) => m[1]); + for (const url of [...new Set(apiUrls)]) { + const dataUrl = await fetchImageAsDataUrl(url); + if (dataUrl) result = result.split(url).join(dataUrl); + } + + if (!mailboxId || !emailId || !attachments?.length) return result; + + const used = new Set(); + for (const att of attachments) { + if (!att.content_id) continue; + const cid = att.content_id.replace(/^<|>$/g, "").trim(); + if (!cid) continue; + const escapedCid = cid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`cid:\\s*?`, "gi"); + if (!re.test(result)) continue; + const dataUrl = await fetchImageAsDataUrl(attachmentUrl(mailboxId, emailId, att.id)); + if (dataUrl) { + result = result.replace(re, dataUrl); + used.add(att.id); + } + } + + for (const att of attachments) { + if (used.has(att.id) || att.disposition !== "inline" || !att.filename) continue; + const escapedName = att.filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`(]*\\balt=["'])${escapedName}(["'][^>]*\\bsrc=["'])cid:[^"']+(["'])`, "gi"); + if (!re.test(result)) continue; + const dataUrl = await fetchImageAsDataUrl(attachmentUrl(mailboxId, emailId, att.id)); + if (dataUrl) { + result = result.replace(re, `$1${att.filename}$2${dataUrl}$3`); + used.add(att.id); + } + } + + return result; +} + +export default function EmailIframe({ body, mailboxId, emailId, attachments, autoSize }: EmailIframeProps) { const iframeRef = useRef(null); const [height, setHeight] = useState(autoSize ? 100 : 0); - - // Listen for height reports from the sandboxed iframe - const handleMessage = useCallback( - (event: MessageEvent) => { - if (!autoSize) return; - // Only accept messages from our own iframe - if (event.source !== iframeRef.current?.contentWindow) return; - if ( - event.data && - typeof event.data === "object" && - event.data.__emailIframeHeight && - typeof event.data.height === "number" && - event.data.height > 0 - ) { - setHeight(event.data.height); - } - }, - [autoSize], - ); + const handleMessage = useCallback((event: MessageEvent) => { + if (!autoSize || event.source !== iframeRef.current?.contentWindow) return; + if (event.data && typeof event.data === "object" && event.data.__emailIframeHeight && typeof event.data.height === "number" && event.data.height > 0) setHeight(event.data.height); + }, [autoSize]); useEffect(() => { window.addEventListener("message", handleMessage); @@ -58,94 +115,24 @@ export default function EmailIframe({ body, autoSize }: EmailIframeProps) { useEffect(() => { const iframe = iframeRef.current; if (!iframe || !body) return; + let cancelled = false; - const cleanBody = DOMPurify.sanitize(body, { - USE_PROFILES: { html: true }, - FORBID_TAGS: ["style"], - ADD_ATTR: ["target"], - FORCE_BODY: true, - }); - - const padding = autoSize ? "0" : "24px"; - - // Height-reporting script: sends body.scrollHeight to the parent. - // Runs inside the opaque-origin sandbox so it has zero access to - // the parent page — it can only postMessage. - const heightScript = autoSize - ? ``; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/") return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } }); + if (url.pathname.startsWith("/api/")) { + const auth = await requireToken(request, env); + if (auth) return auth; + } + if (request.method === "GET" && url.pathname === "/api/accounts") { + try { + const accounts = await sourceAccounts(env); + return json(accounts.map((a) => ({ account_id: a.account_id, email: a.email, name: a.name, user_id: a.user_id }))); + } catch (error) { + return json({ error: error instanceof Error ? error.message : String(error) }, 500); + } + } + if (request.method === "POST" && url.pathname === "/api/migrate") { + try { + const accountId = Number(url.searchParams.get("accountId")); + const cursor = Math.max(0, Number(url.searchParams.get("cursor") || 0)); + const batch = Math.min(BATCH_MAX, Math.max(1, Number(url.searchParams.get("batch") || BATCH_DEFAULT))); + const includeDeleted = url.searchParams.get("includeDeleted") === "true"; + const dryRun = url.searchParams.get("dryRun") === "true"; + if (!Number.isInteger(accountId) || accountId <= 0) return json({ error: "Invalid accountId" }, 400); + const account = (await sourceAccounts(env)).find((a) => a.account_id === accountId); + if (!account) return json({ error: "Source mailbox not found" }, 404); + return json(await migrateBatch(env, account, cursor, batch, includeDeleted, dryRun)); + } catch (error) { + return json({ error: error instanceof Error ? error.message : String(error) }, 500); + } + } + return new Response("Not found", { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/migration-tool/wrangler.jsonc b/migration-tool/wrangler.jsonc new file mode 100644 index 000000000..763b0477f --- /dev/null +++ b/migration-tool/wrangler.jsonc @@ -0,0 +1,10 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudmail-agentic-migrator", + "main": "src/index.ts", + "compatibility_date": "2026-08-22", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "TARGET_WORKER_NAME": "agentic-inbox" + } +} diff --git a/workers/agent/index.ts b/workers/agent/index.ts index ab6f59b9f..789f16c20 100644 --- a/workers/agent/index.ts +++ b/workers/agent/index.ts @@ -3,553 +3,152 @@ // https://opensource.org/licenses/Apache-2.0 import { AIChatAgent } from "@cloudflare/ai-chat"; -import { - streamText, - generateText, - convertToModelMessages, - stepCountIs, -} from "ai"; +import { streamText, generateText, convertToModelMessages, stepCountIs } from "ai"; import { createWorkersAI } from "workers-ai-provider"; import { z } from "zod"; import type { EmailFull, EmailMetadata } from "../lib/schemas"; import { verifyDraft, isPromptInjection } from "../lib/ai"; -import { - getMailboxStub, - stripHtmlToText, - textToHtml, -} from "../lib/email-helpers"; -import { - toolListEmails, - toolGetEmail, - toolGetThread, - toolSearchEmails, - toolDraftReply, - toolDraftEmail, - toolMarkEmailRead, - toolMoveEmail, - toolDiscardDraft, -} from "../lib/tools"; +import { getMailboxStub, stripHtmlToText, textToHtml } from "../lib/email-helpers"; +import { toolListEmails, toolGetEmail, toolGetThread, toolSearchEmails, toolDraftReply, toolDraftEmail, toolMarkEmailRead, toolMoveEmail, toolDiscardDraft } from "../lib/tools"; import { Folders, FOLDER_TOOL_DESCRIPTION, MOVE_FOLDER_TOOL_DESCRIPTION } from "../../shared/folders"; import type { Env } from "../types"; -// AI SDK v6 changed tool() overloads significantly. We define tools as plain -// objects matching the Tool type to avoid overload resolution issues. -function defineTool(def: { - description: string; - parameters: z.ZodType; - execute: (...args: any[]) => Promise; -}) { - return { - description: def.description, - inputSchema: def.parameters, - execute: def.execute, - }; +function defineTool(def: { description: string; parameters: z.ZodType; execute: (...args: any[]) => Promise }) { + return { description: def.description, inputSchema: def.parameters, execute: def.execute }; } -/** - * 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. - */ const DEFAULT_SYSTEM_PROMPT = `You are an email assistant that helps manage this inbox. You read emails, draft replies, and help organize conversations. -## Writing Style -Write like a real person. Short, direct, flowing prose. Get to the point. Plain text only - no HTML tags in your replies. +Write like a real person. Short, direct, flowing prose. Plain text only. +When asked to draft, use the appropriate draft tool. Never send email directly. +Draft bodies must contain only the email text, without meta-commentary or markdown.`; -**Formatting rules:** -- Write in natural paragraphs. NO bullet points, NO numbered lists, NO dashes, NO markdown formatting in email drafts. -- NO bold (**), NO italic (*), NO headers (#), NO horizontal rules (---), NO code blocks. Plain text only. -- Links go inline in the text, not on separate lines. -- Don't structure replies like a template or form letter. Just talk normally. - -**Agent Behavior Rules (CRITICAL):** -- NEVER output meta-commentary about what you are doing (e.g. do not say "I am drafting a reply to Alex", "I checked the thread", etc). -- When a new email arrives, your ONLY job is to call the \`draft_reply\` tool. -- DO NOT summarize the email. DO NOT explain your actions. -- Output NOTHING except the tool call. If you must output text, it should ONLY be the literal draft text itself if tools fail. -- Before drafting ANY reply, carefully read the full thread history. -- NEVER repeat information that was already shared in a prior message in the thread. -- Your reply should only contain NEW information or directly respond to what the person just said. Move the conversation forward, don't rehash it. - -## Who Are You Replying To? -Use the name the person gives in their email body / signature. That's their name - use it. The "from" address is where you send the reply, but the name in the email is how you greet them. - -## CRITICAL: Draft Only - Never Send -You can ONLY draft emails. You do NOT have the ability to send emails directly. - -- Use draft_reply to draft replies to existing emails -- Use draft_email to draft new outbound emails -- The operator will review and send drafts from the UI - you cannot send them - -**CRITICAL: The draft body must contain ONLY the email text.** Never include agent commentary, status messages, meta-notes, markdown formatting, or anything that isn't part of the actual email in the draft body. No "Draft created.", no "---", no "**bold**", no "Here's the draft:", no separators. The body field is the literal email the recipient will read. Everything else goes in your chat message, not in the draft body. - -**Don't paste draft contents into the chat.** The drafts are saved via tools - the operator can see them in the Drafts folder. In your chat message, just briefly say what you drafted (e.g. "Drafted a reply to Tim"). Don't duplicate the full email body in the chat. - -## Draft Management -Use discard_draft to delete drafts that the operator rejects or that are no longer needed.`; - -/** - * Fetch the custom system prompt for a mailbox from its R2 settings. - * Falls back to DEFAULT_SYSTEM_PROMPT if none is configured. - */ async function getSystemPrompt(env: Env, mailboxId: string): Promise { - try { - const key = `mailboxes/${mailboxId}.json`; - const obj = await env.BUCKET.get(key); - if (obj) { - const settings = await obj.json>(); - if (typeof settings.agentSystemPrompt === "string" && settings.agentSystemPrompt.trim()) { - return settings.agentSystemPrompt; - } - } - } catch { - // Fall through to default - } - return DEFAULT_SYSTEM_PROMPT; + try { + const obj = await env.BUCKET.get(`mailboxes/${mailboxId}.json`); + if (obj) { + const settings = await obj.json>(); + if (typeof settings.agentSystemPrompt === "string" && settings.agentSystemPrompt.trim()) return settings.agentSystemPrompt; + } + } catch {} + return DEFAULT_SYSTEM_PROMPT; } function createEmailTools(env: Env, mailboxId: string) { - return { - list_emails: defineTool({ - description: - "List emails in a folder. Returns email metadata (id, subject, sender, recipient, date, read/starred status, thread_id). Use folder='inbox' for received emails, 'sent' for sent emails.", - parameters: z.object({ - folder: z - .string() - .default(Folders.INBOX) - .describe(FOLDER_TOOL_DESCRIPTION), - limit: z - .number() - .default(20) - .describe("Maximum number of emails to return"), - page: z - .number() - .default(1) - .describe("Page number for pagination"), - }), - execute: async ({ folder, limit, page }): Promise => { - return toolListEmails(env, mailboxId, { folder, limit, page }); - }, - }), - - get_email: defineTool({ - description: - "Get a single email with its full body content and attachments. Use this to read the actual content of an email.", - parameters: z.object({ - emailId: z.string().describe("The email ID to retrieve"), - }), - execute: async ({ emailId }): Promise => { - return toolGetEmail(env, mailboxId, emailId); - }, - }), - - get_thread: defineTool({ - description: - "Get all emails in a conversation thread. This is essential for understanding the full context of a conversation before drafting a response. Returns all messages sorted chronologically.", - parameters: z.object({ - threadId: z - .string() - .describe( - "The thread_id to retrieve all messages for. Get this from an email's thread_id field.", - ), - }), - execute: async ({ threadId }): Promise => { - return toolGetThread(env, mailboxId, threadId); - }, - }), - - search_emails: defineTool({ - description: - "Search for emails matching a query across subject and body fields.", - parameters: z.object({ - query: z - .string() - .describe( - "Search query to match against subject and body", - ), - folder: z - .string() - .optional() - .describe("Optional folder to restrict search to"), - }), - execute: async ({ query, folder }): Promise => { - return toolSearchEmails(env, mailboxId, { query, folder }); - }, - }), - - draft_email: defineTool({ - description: - "Draft a new email (not a reply) and save it to the Drafts folder. This does NOT send — it saves a draft for the operator to review. Use this for composing new outbound emails. Write the body as plain text — no HTML tags.", - parameters: z.object({ - to: z.string().email().describe("Recipient email address"), - subject: z - .string() - .describe("Subject line"), - body: z - .string() - .describe( - "The plain text body of the email. No HTML — just write normally.", - ), - }), - execute: async ({ to, subject, body }): Promise => { - return toolDraftEmail(env, mailboxId, { - to, - subject, - body, - isPlainText: true, - }); - }, - }), - - 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.", - parameters: z.object({ - originalEmailId: z - .string() - .describe("The ID of the email being replied to"), - to: z.string().email().describe("Recipient email address"), - subject: z - .string() - .describe("Subject line (usually 'Re: ...')"), - body: z - .string() - .describe( - "The plain text body of the reply. No HTML — just write normally.", - ), - }), - execute: async ({ originalEmailId, to, subject, body }): Promise => { - return toolDraftReply(env, mailboxId, { - originalEmailId, - to, - subject, - body, - isPlainText: true, - runVerifyDraft: true, - }); - }, - }), - - mark_email_read: defineTool({ - description: "Mark an email as read or unread.", - parameters: z.object({ - emailId: z.string().describe("The email ID"), - read: z - .boolean() - .describe("true to mark as read, false for unread"), - }), - execute: async ({ emailId, read }): Promise => { - return toolMarkEmailRead(env, mailboxId, emailId, read); - }, - }), - - move_email: defineTool({ - description: - "Move an email to a different folder (inbox, sent, draft, archive, trash).", - parameters: z.object({ - emailId: z.string().describe("The email ID"), - folderId: z - .string() - .describe(MOVE_FOLDER_TOOL_DESCRIPTION), - }), - execute: async ({ emailId, folderId }): Promise => { - return toolMoveEmail(env, mailboxId, emailId, folderId); - }, - }), - - discard_draft: defineTool({ - description: - "Delete a draft email. Use this to discard drafts that are no longer needed or were rejected by the operator.", - parameters: z.object({ - draftId: z.string().describe("The ID of the draft to delete"), - }), - execute: async ({ draftId }): Promise => { - return toolDiscardDraft(env, mailboxId, draftId); - }, - }), - }; + return { + list_emails: defineTool({ description: "List emails in a folder.", parameters: z.object({ folder: z.string().default(Folders.INBOX).describe(FOLDER_TOOL_DESCRIPTION), limit: z.number().default(20), page: z.number().default(1) }), execute: async ({ folder, limit, page }) => toolListEmails(env, mailboxId, { folder, limit, page }) }), + get_email: defineTool({ description: "Get a single email with its full body content and attachments.", parameters: z.object({ emailId: z.string() }), execute: async ({ emailId }) => toolGetEmail(env, mailboxId, emailId) }), + get_thread: defineTool({ description: "Get all emails in a conversation thread.", parameters: z.object({ threadId: z.string() }), execute: async ({ threadId }) => toolGetThread(env, mailboxId, threadId) }), + search_emails: defineTool({ description: "Search for emails matching a query.", parameters: z.object({ query: z.string(), folder: z.string().optional() }), execute: async ({ query, folder }) => toolSearchEmails(env, mailboxId, { query, folder }) }), + draft_email: defineTool({ description: "Draft a new email and save it to Drafts. Does not send.", parameters: z.object({ to: z.string().email(), subject: z.string(), body: z.string() }), execute: async ({ to, subject, body }) => toolDraftEmail(env, mailboxId, { to, subject, body, isPlainText: true }) }), + draft_reply: defineTool({ description: "Draft a reply to an existing email and save it to Drafts. Does not send.", parameters: z.object({ originalEmailId: z.string(), to: z.string().email(), subject: z.string(), body: z.string() }), execute: async ({ originalEmailId, to, subject, body }) => toolDraftReply(env, mailboxId, { originalEmailId, to, subject, body, isPlainText: true, runVerifyDraft: false }) }), + mark_email_read: defineTool({ description: "Mark an email as read or unread.", parameters: z.object({ emailId: z.string(), read: z.boolean() }), execute: async ({ emailId, read }) => toolMarkEmailRead(env, mailboxId, emailId, read) }), + move_email: defineTool({ description: "Move an email to a different folder.", parameters: z.object({ emailId: z.string(), folderId: z.string().describe(MOVE_FOLDER_TOOL_DESCRIPTION) }), execute: async ({ emailId, folderId }) => toolMoveEmail(env, mailboxId, emailId, folderId) }), + discard_draft: defineTool({ description: "Delete a draft email.", parameters: z.object({ draftId: z.string() }), execute: async ({ draftId }) => toolDiscardDraft(env, mailboxId, draftId) }), + }; } -// Use `any` for the Env generic to avoid type conflicts between the custom -// SEND_EMAIL binding shape and the AIChatAgent constraint. The actual env -// is fully typed inside the tools via the closure. 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 tools = createEmailTools(env, mailboxId); - const systemPrompt = await getSystemPrompt(env, mailboxId); - - const result = streamText({ - model: workersai("@cf/moonshotai/kimi-k2.5"), - system: systemPrompt, - messages: await convertToModelMessages(this.messages), - tools, - stopWhen: stepCountIs(5), - onFinish, - }); - - return result.toUIMessageStreamResponse(); - } - - /** - * Handle HTTP requests to the agent DO. Intercepts /onNewEmail - * before passing to the default AIChatAgent handler. - */ - async onRequest(request: Request): Promise { - const url = new URL(request.url); - if (url.pathname === "/onNewEmail" && request.method === "POST") { - try { - const emailData = await request.json() as { - mailboxId: string; - emailId: string; - sender: string; - subject: string; - threadId: string; - }; - const result = await this.handleNewEmail(emailData); - return new Response(JSON.stringify(result), { - headers: { "Content-Type": "application/json" }, - }); - } catch (e) { - console.error("onNewEmail handler failed:", (e as Error).message); - return new Response( - JSON.stringify({ error: (e as Error).message }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ); - } - } - return super.onRequest(request); - } - - /** - * Called when a new email arrives. Reads it, loads the thread, - * drafts a response, and saves it to the Drafts folder. - */ - async handleNewEmail(emailData: { - mailboxId: string; - emailId: string; - sender: string; - subject: string; - threadId: string; - }) { - const env = this.env as Env; - const workersai = createWorkersAI({ binding: env.AI }); - const tools = createEmailTools(env, emailData.mailboxId); - const systemPrompt = await getSystemPrompt(env, emailData.mailboxId); - - // Pre-read the email and thread so the agent has full context - // without needing to waste tool calls discovering it - const stub = getMailboxStub(env, emailData.mailboxId); - - let emailBody = ""; - let threadContext = ""; - try { - const email = (await stub.getEmail(emailData.emailId)) as EmailFull | null; - if (email?.body) { - const isInjection = await isPromptInjection(env.AI, email.body); - if (isInjection) { - console.warn("Skipping auto-draft due to detected prompt injection:", emailData.emailId); - - // Log to agent chat so the user knows why it skipped - const newMessages = [ - { - id: crypto.randomUUID(), - role: "user" as const, - content: `[Auto-triggered] New email from ${emailData.sender}: "${emailData.subject}"`, - createdAt: new Date(), - parts: [{ type: "text" as const, text: `[Auto-triggered] New email from ${emailData.sender}: "${emailData.subject}"` }], - }, - { - id: crypto.randomUUID(), - role: "assistant" as const, - content: "⚠️ Blocked auto-draft creation: the email appears to contain prompt injection or malicious instructions.", - createdAt: new Date(), - parts: [{ type: "text" as const, text: "⚠️ Blocked auto-draft creation: the email appears to contain prompt injection or malicious instructions." }], - }, - ]; - await this.persistMessages([...this.messages, ...newMessages]); - - return; - } - - emailBody = stripHtmlToText(email.body); - } - - // Load thread for conversation context - const threadEmails = (await stub.getEmails({ thread_id: emailData.threadId })) as EmailMetadata[]; - if (threadEmails.length > 1) { - const fullThread = await Promise.all( - threadEmails.map(async (e) => { - const full = (await stub.getEmail(e.id)) as EmailFull | null; - const text = full?.body ? stripHtmlToText(full.body) : ""; - return { id: e.id, sender: e.sender, recipient: e.recipient, subject: e.subject, date: e.date, folder_id: e.folder_id, body_text: text }; - }), - ); - fullThread.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); - threadContext = fullThread - .map((e) => `[${e.date}] ${e.sender} → ${e.recipient} (${e.folder_id}): ${e.body_text.substring(0, 500)}`) - .join("\n\n"); - - // Scan thread context for prompt injection too -- an attacker - // could plant an injection in an earlier email in the thread - // that gets included in the agent's prompt. - if (threadContext) { - const threadInjection = await isPromptInjection(env.AI, threadContext); - if (threadInjection) { - console.warn("Skipping auto-draft due to prompt injection in thread context:", emailData.threadId); - const newMessages = [ - { - id: crypto.randomUUID(), - role: "user" as const, - content: `[Auto-triggered] New email from ${emailData.sender}: "${emailData.subject}"`, - createdAt: new Date(), - parts: [{ type: "text" as const, text: `[Auto-triggered] New email from ${emailData.sender}: "${emailData.subject}"` }], - }, - { - id: crypto.randomUUID(), - role: "assistant" as const, - content: "Blocked auto-draft creation: the thread context appears to contain prompt injection or malicious instructions.", - createdAt: new Date(), - parts: [{ type: "text" as const, text: "Blocked auto-draft creation: the thread context appears to contain prompt injection or malicious instructions." }], - }, - ]; - await this.persistMessages([...this.messages, ...newMessages]); - return; - } - } - } - } catch (e) { - console.warn("Pre-read failed, agent will use tools:", (e as Error).message); - } - - let autoPrompt = `A new email just arrived. Draft an appropriate response using draft_reply. - -Email details: -- Mailbox: ${emailData.mailboxId} -- Email ID: ${emailData.emailId} -- From: ${emailData.sender} -- Subject: ${emailData.subject} -- Thread ID: ${emailData.threadId} - -Email body: -${emailBody || "(could not pre-read — use get_email to read it)"}`; - - if (threadContext) { - autoPrompt += ` - -Full thread history (${emailData.threadId}): -${threadContext}`; - } else { - autoPrompt += ` - -This is the first message in the thread (no prior conversation).`; - } - - autoPrompt += ` - -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}".`; - - // Fresh context for auto-draft -- don't include prior chat history - // to avoid confusing the model with old messages and tool calls - const messages = [ - { - role: "user" as const, - content: autoPrompt, - parts: [{ type: "text" as const, text: autoPrompt }], - createdAt: new Date(), - }, - ]; - - try { - const result = await generateText({ - model: workersai("@cf/moonshotai/kimi-k2.5"), - system: systemPrompt, - 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 draftToolCalled = result.steps.some((step) => - step.toolCalls.some((tc) => tc.toolName === "draft_reply" || tc.toolName === "draft_email"), - ); - - if (!draftToolCalled && result.text.trim()) { - // Model generated a draft inline as text -- verify with AI - const sanitizedText = await verifyDraft(env.AI, result.text.trim()); - if (!sanitizedText) { - // Inline text was entirely agent commentary, skip - } else { - const draftId = crypto.randomUUID(); - const draftStub = getMailboxStub(env, emailData.mailboxId); - const reSubject = emailData.subject.startsWith("Re:") - ? emailData.subject - : `Re: ${emailData.subject}`; - await draftStub.createEmail( - Folders.DRAFT, - { - id: draftId, - 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 - } - } - - // 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 - ? `Created draft reply to ${emailData.sender}.` - : result.text; - - const newMessages = [ - { - id: crypto.randomUUID(), - role: "user" as const, - content: `[Auto-triggered] New email from ${emailData.sender}: "${emailData.subject}"`, - createdAt: new Date(), - parts: [ - { - type: "text" as const, - text: `[Auto-triggered] New email from ${emailData.sender}: "${emailData.subject}"`, - }, - ], - }, - { - id: crypto.randomUUID(), - role: "assistant" as const, - content: assistantText, - createdAt: new Date(), - parts: [ - { - type: "text" as const, - text: assistantText, - }, - ], - }, - ]; - - await this.persistMessages([...this.messages, ...newMessages]); - - return { status: "draft_generated", text: result.text }; - } catch (e) { - console.error("Auto-draft failed:", (e as Error).message); - return { status: "error", error: (e as Error).message }; - } - } + async onChatMessage(onFinish: any) { + const env = this.env as Env; + const mailboxId = this.name; + const workersai = createWorkersAI({ binding: env.AI }); + const tools = createEmailTools(env, mailboxId); + const systemPrompt = await getSystemPrompt(env, mailboxId); + + console.log("[AI] chat start", { mailboxId, model: "@cf/zai-org/glm-4.7-flash", messageCount: this.messages.length }); + + try { + const result = streamText({ + model: workersai("@cf/zai-org/glm-4.7-flash"), + system: systemPrompt, + messages: await convertToModelMessages(this.messages), + tools, + stopWhen: stepCountIs(3), + onFinish, + onError: ({ error }) => { + console.error("[AI] STREAM ERROR", { + name: error instanceof Error ? error.name : typeof error, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + cause: error instanceof Error && error.cause ? String(error.cause) : undefined, + serialized: (() => { try { return JSON.stringify(error); } catch { return ""; } })(), + }); + }, + }); + return result.toUIMessageStreamResponse(); + } catch (error) { + console.error("[AI] STREAM SETUP ERROR", error); + throw error; + } + } + + async onRequest(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === "/onNewEmail" && request.method === "POST") { + try { + const emailData = await request.json() as { mailboxId: string; emailId: string; sender: string; subject: string; threadId: string }; + const result = await this.handleNewEmail(emailData); + return new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json" } }); + } catch (e) { + console.error("onNewEmail handler failed:", (e as Error).message); + return new Response(JSON.stringify({ error: (e as Error).message }), { status: 500, headers: { "Content-Type": "application/json" } }); + } + } + return super.onRequest(request); + } + + async handleNewEmail(emailData: { mailboxId: string; emailId: string; sender: string; subject: string; threadId: string }) { + const env = this.env as Env; + const workersai = createWorkersAI({ binding: env.AI }); + const tools = createEmailTools(env, emailData.mailboxId); + const systemPrompt = await getSystemPrompt(env, emailData.mailboxId); + const stub = getMailboxStub(env, emailData.mailboxId); + let emailBody = ""; + let threadContext = ""; + try { + const email = (await stub.getEmail(emailData.emailId)) as EmailFull | null; + if (email?.body) { + if (await isPromptInjection(env.AI, email.body)) { + console.warn("Skipping auto-draft due to detected prompt injection:", emailData.emailId); + return; + } + emailBody = stripHtmlToText(email.body); + } + const threadEmails = (await stub.getEmails({ thread_id: emailData.threadId })) as EmailMetadata[]; + if (threadEmails.length > 1) { + const fullThread = await Promise.all(threadEmails.map(async e => { const full = await stub.getEmail(e.id) as EmailFull | null; return { id: e.id, sender: e.sender, recipient: e.recipient, subject: e.subject, date: e.date, folder_id: e.folder_id, body_text: full?.body ? stripHtmlToText(full.body) : "" }; })); + fullThread.sort((a,b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + threadContext = fullThread.map(e => `[${e.date}] ${e.sender} → ${e.recipient} (${e.folder_id}): ${e.body_text.substring(0,500)}`).join("\n\n"); + if (threadContext && await isPromptInjection(env.AI, threadContext)) { console.warn("Skipping auto-draft due to prompt injection in thread context:", emailData.threadId); return; } + } + } catch (e) { console.warn("Pre-read failed, agent will use tools:", (e as Error).message); } + let autoPrompt = `A new email just arrived. Draft an appropriate response using draft_reply.\n\nEmail details:\n- Mailbox: ${emailData.mailboxId}\n- Email ID: ${emailData.emailId}\n- From: ${emailData.sender}\n- Subject: ${emailData.subject}\n- Thread ID: ${emailData.threadId}\n\nEmail body:\n${emailBody || "(could not pre-read — use get_email to read it)"}`; + autoPrompt += threadContext ? `\n\nFull thread history (${emailData.threadId}):\n${threadContext}` : `\n\nThis is the first message in the thread (no prior conversation).`; + autoPrompt += `\n\nBased 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}".`; + const messages = [{ role: "user" as const, content: autoPrompt, parts: [{ type: "text" as const, text: autoPrompt }], createdAt: new Date() }]; + try { + const result = await generateText({ model: workersai("@cf/zai-org/glm-4.7-flash"), system: systemPrompt, messages: await convertToModelMessages(messages), tools, stopWhen: stepCountIs(3) }); + const draftToolCalled = result.steps.some(step => step.toolCalls.some(tc => tc.toolName === "draft_reply" || tc.toolName === "draft_email")); + if (!draftToolCalled && result.text.trim()) { + const sanitizedText = await verifyDraft(env.AI, result.text.trim()); + if (sanitizedText) { + const draftId = crypto.randomUUID(); + const draftStub = getMailboxStub(env, emailData.mailboxId); + const reSubject = emailData.subject.startsWith("Re:") ? emailData.subject : `Re: ${emailData.subject}`; + await draftStub.createEmail(Folders.DRAFT, { id: draftId, subject: reSubject, sender: emailData.mailboxId.toLowerCase(), recipient: emailData.sender.toLowerCase(), date: new Date().toISOString(), body: /<[a-z][\s\S]*>/i.test(sanitizedText) ? sanitizedText : textToHtml(sanitizedText), in_reply_to: emailData.emailId, email_references: null, thread_id: emailData.threadId }, []); + } + } + const assistantText = draftToolCalled ? `Created draft reply to ${emailData.sender}.` : result.text; + const newMessages = [ + { id: crypto.randomUUID(), role: "user" as const, content: `[Auto-triggered] New email from ${emailData.sender}: "${emailData.subject}"`, createdAt: new Date(), parts: [{ type: "text" as const, text: `[Auto-triggered] New email from ${emailData.sender}: "${emailData.subject}"` }] }, + { id: crypto.randomUUID(), role: "assistant" as const, content: assistantText, createdAt: new Date(), parts: [{ type: "text" as const, text: assistantText }] }, + ]; + await this.persistMessages([...this.messages, ...newMessages]); + return { status: "draft_generated", text: result.text }; + } catch (e) { + console.error("Auto-draft failed:", (e as Error).message); + return { status: "error", error: (e as Error).message }; + } + } } diff --git a/workers/app.ts b/workers/app.ts index 607525f78..cf74b5a8a 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -1,127 +1,119 @@ // Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// Licensed under the Apache 2.0 license found in the LICENSE file and at: // https://opensource.org/licenses/Apache-2.0 import { routeAgentRequest } from "agents"; import { Hono } from "hono"; -import { jwtVerify, createRemoteJWKSet } from "jose"; import { createRequestHandler } from "react-router"; -import { app as apiApp, receiveEmail } from "./index"; +import { app as apiApp } from "./index"; +import { receiveEmailWithNotifications } from "./receive-with-notifications"; import { EmailMCP } from "./mcp"; import type { Env } from "./types"; +import { getSessionUser, sessionCookie, expiredSessionCookie, canAccessMailbox, seedAdmin, type AuthUser } from "./lib/auth"; +import { listMailboxes } from "./lib/email-helpers"; export { MailboxDO } from "./durableObject"; export { EmailAgent } from "./agent"; export { EmailMCP } from "./mcp"; +export { UserAuthDO } from "./userAuth"; declare module "react-router" { - export interface AppLoadContext { - cloudflare: { - env: Env; - ctx: ExecutionContext; - }; - } -} - -const requestHandler = createRequestHandler( - () => import("virtual:react-router/server-build"), - import.meta.env.MODE, -); - -function getAccessUrls(teamDomain: string) { - const certsPath = "/cdn-cgi/access/certs"; - const teamUrl = new URL(teamDomain); - const issuer = teamUrl.origin; - const certsUrl = teamUrl.pathname.endsWith(certsPath) - ? teamUrl - : new URL(certsPath, issuer); - - return { issuer, certsUrl }; + export interface AppLoadContext { cloudflare: { env: Env; ctx: ExecutionContext }; } } -// Main app that wraps the API and adds React Router fallback +const requestHandler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE); +const LOGIN_BACKGROUND_KEY = "system/login-background"; +const DOMAINS_KEY = "system/email-domains.json"; +const DEFAULT_APP_NAME = "Agentic Inbox"; const app = new Hono<{ Bindings: Env }>(); -// Cloudflare Access JWT validation middleware (production only) -app.use("*", async (c, next) => { - // Skip validation in development - if (import.meta.env.DEV) { - return next(); - } - - const { POLICY_AUD, TEAM_DOMAIN } = c.env; - - // Fail closed in production if Access is not configured. - if (!POLICY_AUD || !TEAM_DOMAIN) { - return c.text( - "Cloudflare Access must be configured in production. Set POLICY_AUD and TEAM_DOMAIN.", - 500, - ); - } +async function getEmailDomains(env: Env): Promise { + try { + const object = await env.BUCKET.get(DOMAINS_KEY); + if (object) { + const data = await object.json() as { domains?: unknown }; + if (Array.isArray(data.domains)) { + const domains = data.domains.map((d) => String(d).trim().toLowerCase().replace(/^@/, "")).filter(Boolean); + if (domains.length) return [...new Set(domains)]; + } + } + } catch (error) { console.error("Unable to load stored email domains", error); } + const raw = env.TEAM_DOMAINS || env.TEAM_DOMAIN || ""; + return [...new Set(raw.split(",").map((d) => d.trim().toLowerCase().replace(/^@/, "")).filter(Boolean))]; +} - const token = c.req.header("cf-access-jwt-assertion"); - if (!token) { - return c.text("Missing required CF Access JWT", 403); - } +async function saveEmailDomains(env: Env, domains: string[]) { + await env.BUCKET.put(DOMAINS_KEY, JSON.stringify({ domains: [...new Set(domains.map((d) => d.trim().toLowerCase().replace(/^@/, "")).filter(Boolean))] }), { httpMetadata: { contentType: "application/json" } }); +} - try { - const { issuer, certsUrl } = getAccessUrls(TEAM_DOMAIN); - const JWKS = createRemoteJWKSet(certsUrl); - await jwtVerify(token, JWKS, { - issuer, - audience: POLICY_AUD, - }); - } catch { - return c.text("Invalid or expired Access token", 403); +app.use("/api/*", async (c, next) => { + if (c.req.path.startsWith("/api/v1/auth/")) return next(); + const user = await getSessionUser(c.env, c.req.raw); + if (!user) return c.json({ error: "Authentication required" }, 401); + const path = c.req.path; + if (path === "/api/v1/mailboxes") { + if (c.req.method !== "GET" && user.role !== "admin") return c.json({ error: "Administrator permission required" }, 403); + } else if (path.startsWith("/api/v1/mailboxes/")) { + const remainder = path.slice("/api/v1/mailboxes/".length); const mailboxId = decodeURIComponent(remainder.split("/")[0] || ""); + if (mailboxId && !canAccessMailbox(user, mailboxId)) return c.json({ error: "You do not have permission to access this mailbox" }, 403); } - - // Authorization model note: once a teammate passes the shared Cloudflare - // Access policy, they can access all mailboxes in this app by design. return next(); }); - -// MCP server endpoint — used by AI coding tools (ProtoAgent, Claude Code, Cursor, etc.) -// Must be before API routes and React Router catch-all -const mcpHandler = EmailMCP.serve("/mcp", { binding: "EMAIL_MCP" }); -app.all("/mcp", async (c) => { - return mcpHandler.fetch(c.req.raw, c.env, c.executionCtx as ExecutionContext); +const requireMcpAdmin = async (c: any, next: () => Promise) => { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); if (user.role !== "admin") return c.json({ error: "MCP access is currently restricted to administrators" }, 403); return next(); }; +app.use("/mcp", requireMcpAdmin); app.use("/mcp/*", requireMcpAdmin); +async function requireAdmin(c: any): Promise { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); if (user.role !== "admin") return c.json({ error: "Administrator permission required" }, 403); return user; } + +app.get("/api/v1/auth/config", async (c) => { const domains = await getEmailDomains(c.env); return c.json({ appName: (c.env.APP_NAME || DEFAULT_APP_NAME).trim() || DEFAULT_APP_NAME, teamDomain: domains[0] || "", domains }); }); +app.get("/api/v1/auth/login-background", async (c) => { const object = await c.env.BUCKET.get(LOGIN_BACKGROUND_KEY); if (!object) return c.body(null, 404); const headers = new Headers(); object.writeHttpMetadata(headers); headers.set("Cache-Control", "public, max-age=300"); return new Response(object.body, { headers }); }); +app.post("/api/v1/auth/register", async (c) => { + const body = await c.req.json().catch(() => null) as Record | null; const email = String(body?.email ?? "").trim().toLowerCase(); const name = String(body?.name ?? "").trim(); const password = String(body?.password ?? ""); const domains = await getEmailDomains(c.env); const domain = email.split("@")[1] || ""; const adminEmail = (c.env.ADMIN_EMAIL || "").trim().toLowerCase(); + if (!email || !name || password.length < 8) return c.json({ error: "Name, company email and an 8+ character password are required" }, 400); if (!domains.includes(domain)) return c.json({ error: "Registration is restricted to the company email domain" }, 403); if (adminEmail && email === adminEmail) return c.json({ error: "This address is reserved for the administrator" }, 403); + try { await seedAdmin(c.env); } catch (error) { console.error("Admin bootstrap failed during registration", error); return c.json({ error: error instanceof Error ? error.message : "Admin initialization failed" }, 500); } + const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); const response = await stub.fetch("https://user-auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, name, password }) }); return new Response(response.body, response); }); -app.all("/mcp/*", async (c) => { - return mcpHandler.fetch(c.req.raw, c.env, c.executionCtx as ExecutionContext); +app.post("/api/v1/auth/login", async (c) => { const body = await c.req.json().catch(() => null) as Record | null; const email = String(body?.email ?? "").trim().toLowerCase(); const password = String(body?.password ?? ""); try { await seedAdmin(c.env); } catch (error) { console.error("Admin bootstrap failed during login", error); return c.json({ error: error instanceof Error ? error.message : "Admin initialization failed" }, 500); } const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); const response = await stub.fetch("https://user-auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) }); if (!response.ok) return new Response(response.body, response); const data = await response.json() as { token: string; user: AuthUser }; return new Response(JSON.stringify({ user: data.user }), { status: 200, headers: { "Content-Type": "application/json", "Set-Cookie": sessionCookie(data.token) } }); }); +app.get("/api/v1/auth/me", async (c) => { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Not authenticated" }, 401); return c.json({ user }); }); +app.post("/api/v1/auth/logout", async (c) => { const cookie = c.req.header("Cookie") || ""; const token = cookie.split(";").map((p) => p.trim()).find((p) => p.startsWith("agentic_session="))?.split("=").slice(1).join("="); if (token) { const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); await stub.fetch("https://user-auth/logout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); } return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json", "Set-Cookie": expiredSessionCookie() } }); }); +app.get("/api/v1/admin/users", async (c) => { const admin = await requireAdmin(c); if (admin instanceof Response) return admin; const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); const response = await stub.fetch("https://user-auth/admin/users"); return new Response(response.body, response); }); +app.post("/api/v1/admin/approve", async (c) => { const admin = await requireAdmin(c); if (admin instanceof Response) return admin; const body = await c.req.json().catch(() => null) as Record | null; const email = String(body?.email ?? "").trim().toLowerCase(); const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); const response = await stub.fetch("https://user-auth/admin/approve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }) }); if (!response.ok) return new Response(response.body, response); const data = await response.json() as { user?: { email: string; name: string } }; if (data.user) { const key = `mailboxes/${data.user.email}.json`; if (!(await c.env.BUCKET.head(key))) { const settings = { fromName: data.user.name, forwarding: { enabled: false, email: "" }, signature: { enabled: false, text: "" }, autoReply: { enabled: false, subject: "", message: "" } }; await c.env.BUCKET.put(key, JSON.stringify(settings)); const mailbox = c.env.MAILBOX.get(c.env.MAILBOX.idFromName(data.user.email)); await mailbox.getFolders(); } } return c.json(data); }); +app.post("/api/v1/admin/status", async (c) => { const admin = await requireAdmin(c); if (admin instanceof Response) return admin; const body = await c.req.json().catch(() => null) as Record | null; const email = String(body?.email ?? "").trim().toLowerCase(); const status = String(body?.status ?? ""); const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); const response = await stub.fetch("https://user-auth/admin/status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, status }) }); return new Response(response.body, response); }); +app.post("/api/v1/admin/reset-password", async (c) => { const admin = await requireAdmin(c); if (admin instanceof Response) return admin; const body = await c.req.json().catch(() => null) as Record | null; const email = String(body?.email ?? "").trim().toLowerCase(); const password = String(body?.password ?? ""); const name = String(body?.name ?? "").trim() || email.split("@")[0]; const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); const response = await stub.fetch("https://user-auth/admin/reset-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, name }) }); return new Response(response.body, response); }); +app.get("/api/v1/admin/domains", async (c) => { const admin = await requireAdmin(c); if (admin instanceof Response) return admin; return c.json({ domains: await getEmailDomains(c.env) }); }); +app.post("/api/v1/admin/domains", async (c) => { const admin = await requireAdmin(c); if (admin instanceof Response) return admin; const body = await c.req.json().catch(() => null) as Record | null; const domain = String(body?.domain ?? "").trim().toLowerCase().replace(/^@/, ""); if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/i.test(domain)) return c.json({ error: "Invalid domain" }, 400); const domains = await getEmailDomains(c.env); if (!domains.includes(domain)) domains.push(domain); await saveEmailDomains(c.env, domains); return c.json({ domains }); }); +app.delete("/api/v1/admin/domains", async (c) => { const admin = await requireAdmin(c); if (admin instanceof Response) return admin; const body = await c.req.json().catch(() => null) as Record | null; const domain = String(body?.domain ?? "").trim().toLowerCase().replace(/^@/, ""); const domains = await getEmailDomains(c.env); if (domains.length <= 1 && domains.includes(domain)) return c.json({ error: "At least one email domain must remain" }, 400); const next = domains.filter((d) => d !== domain); await saveEmailDomains(c.env, next); return c.json({ domains: next }); }); +app.post("/api/v1/admin/login-background", async (c) => { const admin = await requireAdmin(c); if (admin instanceof Response) return admin; const form = await c.req.raw.formData().catch(() => null); const file = form?.get("file"); if (!(file instanceof File)) return c.json({ error: "Image file is required" }, 400); if (!file.type.startsWith("image/")) return c.json({ error: "Only image files are allowed" }, 400); if (file.size > 5 * 1024 * 1024) return c.json({ error: "Image must be 5 MB or smaller" }, 400); await c.env.BUCKET.put(LOGIN_BACKGROUND_KEY, file.stream(), { httpMetadata: { contentType: file.type } }); return c.json({ ok: true }); }); +app.delete("/api/v1/admin/login-background", async (c) => { const admin = await requireAdmin(c); if (admin instanceof Response) return admin; await c.env.BUCKET.delete(LOGIN_BACKGROUND_KEY); return c.json({ ok: true }); }); +app.get("/api/v1/mailboxes", async (c) => { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); const allMailboxes = await listMailboxes(c.env.BUCKET); const visible = user.role === "admin" ? allMailboxes : allMailboxes.filter((m) => m.id.toLowerCase() === user.email.toLowerCase()); return c.json(visible.map((m) => ({ ...m, name: m.id }))); }); - -// Mount the API routes +const mcpHandler = EmailMCP.serve("/mcp", { binding: "EMAIL_MCP" }); +app.all("/mcp", async (c) => mcpHandler.fetch(c.req.raw, c.env, c.executionCtx as ExecutionContext)); +app.all("/mcp/*", async (c) => mcpHandler.fetch(c.req.raw, c.env, c.executionCtx as ExecutionContext)); app.route("/", apiApp); -// Agent WebSocket routing - must be before React Router catch-all +// Agent authentication is handled by routeAgentRequest's WebSocket-aware hooks. +// This is important for same-origin browser WebSockets: the Agent SDK performs +// the upgrade after these hooks run, while preserving the request/cookie context. +async function authorizeAgentRequest(request: Request, env: Env): Promise { + const user = await getSessionUser(env, request); + if (!user) return new Response(JSON.stringify({ error: "Authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } }); + + // /agents/EmailAgent/:mailboxId/... — keep the same mailbox authorization + // rules used by the REST API. + const pathname = new URL(request.url).pathname; + const parts = pathname.split("/").filter(Boolean); + const mailboxId = parts.length >= 3 && parts[0] === "agents" ? decodeURIComponent(parts[2] || "") : ""; + if (mailboxId && !canAccessMailbox(user, mailboxId)) { + return new Response(JSON.stringify({ error: "You do not have permission to access this mailbox" }), { status: 403, headers: { "Content-Type": "application/json" } }); + } +} + app.all("/agents/*", async (c) => { - const response = await routeAgentRequest(c.req.raw, c.env); + const response = await routeAgentRequest(c.req.raw, c.env, { + onBeforeConnect: (request) => authorizeAgentRequest(request, c.env), + onBeforeRequest: (request) => authorizeAgentRequest(request, c.env), + }); if (response) return response; return c.text("Agent not found", 404); }); - -// React Router catch-all: serves the SPA for all non-API routes -app.all("*", (c) => { - return requestHandler(c.req.raw, { - cloudflare: { env: c.env, ctx: c.executionCtx as ExecutionContext }, - }); -}); - -// Export the Hono app as the default export with an email handler -export default { - fetch: app.fetch, - async email( - event: { raw: ReadableStream; rawSize: number }, - env: Env, - ctx: ExecutionContext, - ) { - try { - await receiveEmail(event, env, ctx); - } catch (e) { - console.error("Failed to process incoming email:", (e as Error).message, (e as Error).stack); - // Re-throw so Cloudflare's email routing can retry delivery or bounce the message. - // Swallowing the error would silently drop the email. - throw e; - } - }, -}; +app.all("*", (c) => requestHandler(c.req.raw, { cloudflare: { env: c.env, ctx: c.executionCtx as ExecutionContext } })); +export default { fetch: app.fetch, async email(event: { raw: ReadableStream; rawSize: number; forward: (target: string) => Promise }, env: Env, ctx: ExecutionContext) { try { await receiveEmailWithNotifications(event, env, ctx); } catch (e) { console.error("Failed to process incoming email:", (e as Error).message, (e as Error).stack); throw e; } } }; diff --git a/workers/email-sender.ts b/workers/email-sender.ts index b535ab3ab..2506e086c 100644 --- a/workers/email-sender.ts +++ b/workers/email-sender.ts @@ -2,71 +2,12 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -/** - * Email sending via Cloudflare Email Service binding. - * - * Uses the `send_email` Worker binding (`env.EMAIL.send()`) to send emails. - * - * See: https://developers.cloudflare.com/email-service/api/send-emails/workers-api/ - */ - -export interface SendEmailParams { - to: string | string[]; - from: string | { email: string; name: string }; - subject: string; - html?: string; - text?: string; - cc?: string | string[]; - bcc?: string | string[]; - replyTo?: string | { email: string; name: string }; - attachments?: { - content: string; // base64 encoded - filename: string; - type: string; - disposition: "attachment" | "inline"; - contentId?: string; - }[]; - headers?: Record; -} - -/** - * Send an email using the Cloudflare Email Service binding. - * - * @param binding - The `EMAIL` SendEmail binding from env - * @param params - Email parameters (to, from, subject, body, etc.) - * @returns The send result with messageId - * @throws On validation or delivery errors (error has `.code` property) - */ -export async function sendEmail( - binding: SendEmail, - params: SendEmailParams, -): Promise<{ messageId: string }> { - const message: Record = { - to: params.to, - from: params.from, - subject: params.subject, - }; - - if (params.html) message.html = params.html; - if (params.text) message.text = params.text; - if (params.cc) message.cc = params.cc; - if (params.bcc) message.bcc = params.bcc; - if (params.replyTo) message.replyTo = params.replyTo; - - if (params.headers && Object.keys(params.headers).length > 0) { - message.headers = params.headers; - } - - if (params.attachments && params.attachments.length > 0) { - message.attachments = params.attachments.map((att) => ({ - content: att.content, - filename: att.filename, - type: att.type, - disposition: att.disposition, - ...(att.contentId ? { contentId: att.contentId } : {}), - })); - } - - const result = await binding.send(message as any); - return { messageId: result.messageId }; +import { env } from "cloudflare:workers"; +export interface SendEmailParams { to:string|string[]; from:string|{email:string;name:string}; subject:string; html?:string; text?:string; cc?:string|string[]; bcc?:string|string[]; replyTo?:string|{email:string;name:string}; attachments?:{content:string;filename:string;type:string;disposition:"attachment"|"inline";contentId?:string}[]; headers?:Record; } +function formatAddress(address:string|{email:string;name:string}){return typeof address==="string"?address:`${address.name} <${address.email}>`;} +export async function sendEmail(_binding:SendEmail,params:SendEmailParams):Promise<{messageId:string}>{ + const apiKey=(env as unknown as {RESEND_API_KEY?:string}).RESEND_API_KEY;if(!apiKey)throw new Error("RESEND_API_KEY is not configured"); + const payload:Record={to:params.to,from:formatAddress(params.from),subject:params.subject};if(params.html!==undefined)payload.html=params.html;if(params.text!==undefined)payload.text=params.text;if(params.cc)payload.cc=params.cc;if(params.bcc)payload.bcc=params.bcc;if(params.replyTo)payload.reply_to=formatAddress(params.replyTo);if(params.headers&&Object.keys(params.headers).length)payload.headers=params.headers; + if(params.attachments?.length)payload.attachments=params.attachments.map(att=>({content:att.content,filename:att.filename,content_type:att.type,content_id:att.contentId,...(att.disposition==="inline"?{content_disposition:"inline"}:{})})); + const response=await fetch("https://api.resend.com/emails",{method:"POST",headers:{Authorization:`Bearer ${apiKey}`,"Content-Type":"application/json","User-Agent":"agentic-inbox/1.0"},body:JSON.stringify(payload)});const result=await response.json().catch(()=>({})) as {id?:string;message?:string};if(!response.ok)throw new Error(result.message||`Resend API request failed: ${response.status}`);if(!result.id)throw new Error("Resend API returned no message ID");return{messageId:result.id}; } diff --git a/workers/index.ts b/workers/index.ts index fd3359ce7..86bbe6589 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -1,20 +1,10 @@ -// 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 { type Context, Hono } from "hono"; import { cors } from "hono/cors"; import PostalMime from "postal-mime"; import { z } from "zod"; import { sendEmail } from "./email-sender"; import { storeAttachments, type StoredAttachment } from "./lib/attachments"; -import { - validateSender, - SenderValidationError, - generateMessageId, - buildThreadingHeaders, - listMailboxes, -} from "./lib/email-helpers"; +import { validateSender, SenderValidationError, generateMessageId, buildThreadingHeaders, listMailboxes } from "./lib/email-helpers"; import { SendEmailRequestSchema } from "./lib/schemas"; import { handleReplyEmail, handleForwardEmail } from "./routes/reply-forward"; import { Folders } from "../shared/folders"; @@ -22,391 +12,45 @@ import type { Env } from "./types"; import { requireMailbox, type MailboxContext } from "./lib/mailbox"; type AppContext = Context; - -// -- Request body schemas (kept for validation) --------------------- - -const CreateMailboxBody = z.object({ - email: z.string().email(), - name: z.string().min(1), - settings: z.record(z.any()).optional(), // unvalidated — agentSystemPrompt goes straight to AI -}); - -const DraftBody = z.object({ - to: z.string().optional(), - cc: z.string().optional(), - bcc: z.string().optional(), - subject: z.string().optional(), - body: z.string(), - in_reply_to: z.string().optional(), - thread_id: z.string().optional(), - draft_id: z.string().optional(), -}); - -// -- Helpers -------------------------------------------------------- - -function slugify(text: string) { // can return "" for non-alphanumeric input - return text.toString().toLowerCase() - .replace(/\s+/g, "-").replace(/[^\w-]+/g, "") - .replace(/--+/g, "-").replace(/^-+/, "").replace(/-+$/, ""); -} - -function intQuery(c: AppContext, key: string): number | undefined { - const v = c.req.query(key); - if (!v) return undefined; - const n = Number(v); - return Number.isNaN(n) ? undefined : n; -} - -function boolQuery(c: AppContext, key: string): boolean | undefined { - const v = c.req.query(key); - if (v === undefined || v === "") return undefined; - return v === "true" || v === "1"; -} - -// -- App & middleware ----------------------------------------------- - +const CreateMailboxBody = z.object({ email:z.string().email(), name:z.string().min(1), settings:z.record(z.any()).optional() }); +const DraftBody = z.object({ to:z.string().optional(), cc:z.string().optional(), bcc:z.string().optional(), subject:z.string().optional(), body:z.string(), in_reply_to:z.string().optional(), thread_id:z.string().optional(), draft_id:z.string().optional() }); const app = new Hono(); -app.use("/api/*", cors({ - origin: (origin) => { - // Same-origin requests have no Origin header — allow them. - if (!origin) return origin; - // In development, allow localhost for Vite dev server. - try { - const url = new URL(origin); - if (url.hostname === "localhost" || url.hostname === "127.0.0.1") return origin; - } catch { /* invalid origin */ } - // Block all other cross-origin requests. The app is served from the - // same origin as the API, so legitimate browser requests never send - // an Origin header. Returning undefined omits Access-Control-Allow-Origin. - return undefined; - }, -})); -app.use("/api/v1/mailboxes/:mailboxId/*", requireMailbox); - -// -- Config --------------------------------------------------------- - -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 }); -}); - -// -- Mailboxes ------------------------------------------------------ - -app.get("/api/v1/mailboxes", async (c) => { - const allMailboxes = await listMailboxes(c.env.BUCKET); - return c.json(allMailboxes.map((m) => ({ ...m, name: m.id }))); -}); - -app.post("/api/v1/mailboxes", async (c) => { - const { name, settings, email: rawEmail } = CreateMailboxBody.parse(await c.req.json()); - const email = rawEmail.toLowerCase(); - const allowedAddresses = (c.env.EMAIL_ADDRESSES ?? []) as string[]; - if (allowedAddresses.length > 0 && !allowedAddresses.map((a) => a.toLowerCase()).includes(email)) { - return c.json({ error: "Mailbox creation is restricted to configured EMAIL_ADDRESSES" }, 403); - } - const key = `mailboxes/${email}.json`; - if (await c.env.BUCKET.head(key)) return c.json({ error: "Mailbox already exists" }, 409); - const defaultSettings = { fromName: name, forwarding: { enabled: false, email: "" }, signature: { enabled: false, text: "" }, autoReply: { enabled: false, subject: "", message: "" } }; - const finalSettings = { ...defaultSettings, ...settings }; - await c.env.BUCKET.put(key, JSON.stringify(finalSettings)); - const stub = c.env.MAILBOX.get(c.env.MAILBOX.idFromName(email)); - await stub.getFolders(); - return c.json({ id: email, email, name, settings: finalSettings }, 201); -}); - -app.get("/api/v1/mailboxes/:mailboxId", async (c) => { - const mailboxId = c.req.param("mailboxId")!; - const obj = await c.env.BUCKET.get(`mailboxes/${mailboxId}.json`); - if (!obj) return c.json({ error: "Not found" }, 404); - return c.json({ id: mailboxId, name: mailboxId, email: mailboxId, settings: await obj.json() }); -}); - -app.put("/api/v1/mailboxes/:mailboxId", async (c) => { - const mailboxId = c.req.param("mailboxId")!; - const { settings } = (await c.req.json()) as { settings: Record }; - const key = `mailboxes/${mailboxId}.json`; - if (!(await c.env.BUCKET.head(key))) return c.json({ error: "Not found" }, 404); - await c.env.BUCKET.put(key, JSON.stringify(settings)); - return c.json({ id: mailboxId, name: mailboxId, email: mailboxId, settings }); -}); - -app.delete("/api/v1/mailboxes/:mailboxId", async (c) => { - const mailboxId = c.req.param("mailboxId")!; - const key = `mailboxes/${mailboxId}.json`; - if (!(await c.env.BUCKET.head(key))) return c.json({ error: "Not found" }, 404); - await c.env.BUCKET.delete(key); // TODO: also delete DO data and R2 attachment blobs - return c.body(null, 204); -}); - -// -- Emails --------------------------------------------------------- - -app.get("/api/v1/mailboxes/:mailboxId/emails", async (c: AppContext) => { - const folder = c.req.query("folder"); - const thread_id = c.req.query("thread_id"); - const threaded = boolQuery(c, "threaded"); - const page = intQuery(c, "page"); - const limit = intQuery(c, "limit"); - const sortColumn = c.req.query("sortColumn") as any; - const sortDirection = c.req.query("sortDirection") as "ASC" | "DESC" | undefined; - const stub = c.var.mailboxStub; - - if (threaded && folder) { - const emails = await (stub as any).getThreadedEmails({ folder, page, limit }); - const totalCount = await (stub as any).countThreadedEmails(folder); - return c.json({ emails, totalCount }); - } - const emails = await stub.getEmails({ folder, thread_id, page, limit, sortColumn, sortDirection }); - if (folder) { - const totalCount = await stub.countEmails({ folder, thread_id }); - return c.json({ emails, totalCount }); - } - return c.json(emails); -}); - -app.post("/api/v1/mailboxes/:mailboxId/emails", async (c: AppContext) => { - const mailboxId = c.req.param("mailboxId")!; - const body = SendEmailRequestSchema.parse(await c.req.json()); - const { to, cc, bcc, from, subject, html, text, attachments, in_reply_to, references, thread_id } = body; - - let toStr: string, fromEmail: string, fromDomain: string; - try { - ({ toStr, fromEmail, fromDomain } = validateSender(to, from, mailboxId)); - } catch (e) { - if (e instanceof SenderValidationError) return c.json({ error: e.message }, 400); - throw e; - } - - const { messageId, outgoingMessageId } = generateMessageId(fromDomain); - const stub = c.var.mailboxStub; - const rateLimitError = await (stub as any).checkSendRateLimit(); - if (rateLimitError) return c.json({ error: rateLimitError }, 429); - const attachmentData = await storeAttachments(c.env.BUCKET, messageId, attachments); - - await stub.createEmail(Folders.SENT, { - id: messageId, subject, sender: fromEmail, recipient: toStr, - cc: cc ? (Array.isArray(cc) ? cc.join(", ") : cc).toLowerCase() : null, - bcc: bcc ? (Array.isArray(bcc) ? bcc.join(", ") : bcc).toLowerCase() : null, - date: new Date().toISOString(), body: html || text || "", - in_reply_to: in_reply_to || null, email_references: references ? JSON.stringify(references) : null, - thread_id: thread_id || in_reply_to || messageId, message_id: outgoingMessageId, - raw_headers: JSON.stringify([ - { key: "from", value: typeof from === "string" ? from : `${from.name} <${from.email}>` }, - { key: "to", value: Array.isArray(to) ? to.join(", ") : to }, - ...(cc ? [{ key: "cc", value: Array.isArray(cc) ? cc.join(", ") : cc }] : []), - ...(bcc ? [{ key: "bcc", value: Array.isArray(bcc) ? bcc.join(", ") : bcc }] : []), - { key: "subject", value: subject }, { key: "date", value: new Date().toISOString() }, - { key: "message-id", value: `<${outgoingMessageId}>` }, - ]), - }, attachmentData); - - c.executionCtx.waitUntil( - sendEmail(c.env.EMAIL, { - to, cc, bcc, from, subject, html, text, - attachments: attachments?.map((att) => ({ content: att.content, filename: att.filename, type: att.type, disposition: att.disposition || "attachment", contentId: att.contentId })), - ...(in_reply_to ? { headers: buildThreadingHeaders(in_reply_to, references || []) } : {}), - }).catch((e) => console.error("Deferred email delivery failed:", (e as Error).message)), - ); - return c.json({ id: messageId, status: "sent" }, 202); -}); - -app.post("/api/v1/mailboxes/:mailboxId/drafts", async (c: AppContext) => { - const mailboxId = c.req.param("mailboxId")!; - const { to, cc, bcc, subject, body, in_reply_to, thread_id, draft_id } = DraftBody.parse(await c.req.json()); - const stub = c.var.mailboxStub; - if (draft_id) await stub.deleteEmail(draft_id); // not atomic — create-then-delete would be safer - const messageId = crypto.randomUUID(); - const now = new Date().toISOString(); - await stub.createEmail(Folders.DRAFT, { - id: messageId, subject: subject || "", sender: mailboxId.toLowerCase(), - recipient: (to || "").toLowerCase(), cc: cc?.toLowerCase() || null, bcc: bcc?.toLowerCase() || null, - date: now, body, in_reply_to: in_reply_to || null, email_references: null, - thread_id: thread_id || in_reply_to || messageId, - }, []); - return c.json({ id: messageId, status: "draft", subject: subject || "", recipient: to || "", date: now }, 201); -}); - -app.get("/api/v1/mailboxes/:mailboxId/emails/:id", async (c: AppContext) => { - const email = await c.var.mailboxStub.getEmail(c.req.param("id")!); - if (!email) return c.json({ error: "Email not found" }, 404); - return new Response(JSON.stringify(email), { - headers: { "Content-Type": "application/json" }, - }); -}); - -app.put("/api/v1/mailboxes/:mailboxId/emails/:id", async (c: AppContext) => { - const { read, starred } = (await c.req.json()) as { read?: boolean; starred?: boolean }; - const email = await c.var.mailboxStub.updateEmail(c.req.param("id")!, { read, starred }); - return email ? c.json(email) : c.json({ error: "Email not found" }, 404); -}); - -app.delete("/api/v1/mailboxes/:mailboxId/emails/:id", async (c: AppContext) => { - const id = c.req.param("id")!; - const attachments = await c.var.mailboxStub.deleteEmail(id); - if (attachments === null) return c.json({ error: "Not found" }, 404); - if (attachments.length > 0) await c.env.BUCKET.delete(attachments.map((att: any) => `attachments/${id}/${att.id}/${att.filename}`)); - return c.body(null, 204); -}); - -app.post("/api/v1/mailboxes/:mailboxId/emails/:id/move", async (c: AppContext) => { - const { folderId } = (await c.req.json()) as { folderId: string }; - const success = await c.var.mailboxStub.moveEmail(c.req.param("id")!, folderId); - return success ? c.json({ status: "moved" }) : c.json({ error: "Folder not found" }, 400); -}); - -// -- Threads -------------------------------------------------------- - -app.get("/api/v1/mailboxes/:mailboxId/threads/:threadId", async (c: AppContext) => { - return c.json(await (c.var.mailboxStub as any).getThreadEmails(c.req.param("threadId")!)); -}); - -app.post("/api/v1/mailboxes/:mailboxId/threads/:threadId/read", async (c: AppContext) => { - await c.var.mailboxStub.markThreadRead(c.req.param("threadId")!); - return c.json({ status: "marked_read" }); -}); - -// -- Reply / Forward ------------------------------------------------ - -app.post("/api/v1/mailboxes/:mailboxId/emails/:id/reply", handleReplyEmail); -app.post("/api/v1/mailboxes/:mailboxId/emails/:id/forward", handleForwardEmail); - -// -- Folders -------------------------------------------------------- - -app.get("/api/v1/mailboxes/:mailboxId/folders", async (c: AppContext) => c.json(await c.var.mailboxStub.getFolders())); - -app.post("/api/v1/mailboxes/:mailboxId/folders", async (c: AppContext) => { - const { name } = (await c.req.json()) as { name: string }; - const slug = slugify(name); - if (!slug) return c.json({ error: "Folder name must contain alphanumeric characters" }, 400); - const f = await c.var.mailboxStub.createFolder(slug, name); - return f ? c.json(f, 201) : c.json({ error: "Folder with this name already exists" }, 409); -}); - -app.put("/api/v1/mailboxes/:mailboxId/folders/:id", async (c: AppContext) => { - const { name } = (await c.req.json()) as { name: string }; - const f = await c.var.mailboxStub.updateFolder(c.req.param("id")!, name); - return f ? c.json(f) : c.json({ error: "Folder not found" }, 404); -}); - -app.delete("/api/v1/mailboxes/:mailboxId/folders/:id", async (c: AppContext) => { - const ok = await c.var.mailboxStub.deleteFolder(c.req.param("id")!); - return ok ? c.body(null, 204) : c.json({ error: "Folder not found or cannot be deleted" }, 400); -}); - -// -- Search --------------------------------------------------------- - -app.get("/api/v1/mailboxes/:mailboxId/search", async (c: AppContext) => { - const searchOpts: Record = { - query: c.req.query("query") || "", folder: c.req.query("folder"), from: c.req.query("from"), - to: c.req.query("to"), subject: c.req.query("subject"), date_start: c.req.query("date_start"), - date_end: c.req.query("date_end"), is_read: boolQuery(c, "is_read"), - is_starred: boolQuery(c, "is_starred"), has_attachment: boolQuery(c, "has_attachment"), - }; - const stub = c.var.mailboxStub as any; - const emails = await stub.searchEmails({ ...searchOpts, page: intQuery(c, "page"), limit: intQuery(c, "limit") }); - const totalCount = await stub.countSearchResults(searchOpts); - return c.json({ emails, totalCount }); -}); - -// -- Attachments ---------------------------------------------------- - -app.get("/api/v1/mailboxes/:mailboxId/emails/:emailId/attachments/:attachmentId", async (c: AppContext) => { - const emailId = c.req.param("emailId")!; - const attachmentId = c.req.param("attachmentId")!; - const attachment = await c.var.mailboxStub.getAttachment(attachmentId); - if (!attachment) return c.json({ error: "Attachment not found" }, 404); - const obj = await c.env.BUCKET.get(`attachments/${emailId}/${attachmentId}/${attachment.filename}`); - if (!obj) return c.json({ error: "Attachment file not found" }, 404); - const headers = new Headers(); - headers.set("Content-Type", attachment.mimetype); - const sanitized = attachment.filename.replace(/[\x00-\x1f"\\]/g, "_"); - headers.set("Content-Disposition", `attachment; filename="${sanitized}"; filename*=UTF-8''${encodeURIComponent(attachment.filename)}`); - return new Response(obj.body, { headers }); -}); - -// -- Receive inbound email ------------------------------------------ - -const MAX_EMAIL_SIZE = 25 * 1024 * 1024; - -async function streamToArrayBuffer(stream: ReadableStream, streamSize: number) { - if (streamSize > MAX_EMAIL_SIZE) throw new Error(`Email too large: ${streamSize} bytes exceeds ${MAX_EMAIL_SIZE} byte limit`); - if (streamSize <= 0) throw new Error(`Invalid stream size: ${streamSize}`); - const result = new Uint8Array(streamSize); - let bytesRead = 0; - const reader = stream.getReader(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (bytesRead + value.length > streamSize) { reader.cancel(); throw new Error(`Stream exceeds declared size`); } - result.set(value, bytesRead); - bytesRead += value.length; - } - return result; -} - -async function receiveEmail(event: { raw: ReadableStream; rawSize: number }, env: Env, ctx: ExecutionContext) { - const rawEmail = await streamToArrayBuffer(event.raw, event.rawSize); - const parsedEmail = await new PostalMime().parse(rawEmail); - - if (!parsedEmail.to?.length || !parsedEmail.to[0].address) throw new Error("received email with empty to"); - - const allowedAddresses = ((env.EMAIL_ADDRESSES ?? []) as string[]).map((a) => a.toLowerCase()); - const allRecipients = parsedEmail.to.map((t) => t.address?.toLowerCase()).filter(Boolean) as string[]; - const ccRecipients = (parsedEmail.cc || []).map((e) => e.address?.toLowerCase()).filter(Boolean) as string[]; - const bccRecipients = (parsedEmail.bcc || []).map((e) => e.address?.toLowerCase()).filter(Boolean) as string[]; - - let mailboxId: string | undefined; - if (allowedAddresses.length > 0) { - mailboxId = allRecipients.find((addr) => allowedAddresses.includes(addr)); - if (!mailboxId) { console.log(`Ignoring email: no recipient matches EMAIL_ADDRESSES.`); return; } - } else { mailboxId = allRecipients[0]; } - if (!mailboxId) throw new Error("received email with no valid recipient address"); - - const messageId = crypto.randomUUID(); - if (!(await env.BUCKET.head(`mailboxes/${mailboxId}.json`))) { console.log(`Ignoring email for ${mailboxId}: mailbox does not exist`); return; } - - const stub = env.MAILBOX.get(env.MAILBOX.idFromName(mailboxId)); - - const attachmentData: StoredAttachment[] = []; - if (parsedEmail.attachments) { - for (const att of parsedEmail.attachments) { - const attId = crypto.randomUUID(); - const filename = (att.filename || "untitled").replace(/[\/\\:*?"<>|\x00-\x1f]/g, "_"); - await env.BUCKET.put(`attachments/${messageId}/${attId}/${filename}`, att.content); - attachmentData.push({ id: attId, email_id: messageId, filename, mimetype: att.mimeType, - size: typeof att.content === "string" ? att.content.length : att.content.byteLength, - content_id: att.contentId || null, disposition: att.disposition || "attachment" }); - } - } - - const extractMsgId = (s: string) => { const m = s.match(/<([^>]+)>/); return m ? m[1] : s.trim().split(/\s+/)[0]; }; - const inReplyTo = parsedEmail.inReplyTo ? extractMsgId(parsedEmail.inReplyTo) : null; - const emailReferences = parsedEmail.references ? parsedEmail.references.split(/\s+/).filter(Boolean).map(extractMsgId) : []; - let threadId = emailReferences[0] || inReplyTo || messageId; - - if (!inReplyTo && emailReferences.length === 0) { - const subjectThread = await (stub as any).findThreadBySubject(parsedEmail.subject || "", parsedEmail.from?.address || undefined); - if (subjectThread) threadId = subjectThread; - } - - const originalMessageId = parsedEmail.messageId ? extractMsgId(parsedEmail.messageId) : null; - - await stub.createEmail(Folders.INBOX, { - id: messageId, subject: parsedEmail.subject || "", - sender: (parsedEmail.from?.address || "").toLowerCase(), recipient: allRecipients.join(", "), - cc: ccRecipients.join(", ") || null, bcc: bccRecipients.join(", ") || null, - date: new Date().toISOString(), // uses receive time, not the email's Date header - body: parsedEmail.html || parsedEmail.text || "", - in_reply_to: inReplyTo, email_references: emailReferences.length > 0 ? JSON.stringify(emailReferences) : null, - thread_id: threadId, message_id: originalMessageId, raw_headers: JSON.stringify(parsedEmail.headers), - }, attachmentData); - - const agentStub = env.EMAIL_AGENT.get(env.EMAIL_AGENT.idFromName(mailboxId)); - ctx.waitUntil(agentStub.fetch(new Request("https://agents/onNewEmail", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mailboxId, emailId: messageId, sender: (parsedEmail.from?.address || "").toLowerCase(), subject: parsedEmail.subject || "", threadId }), - })).catch((e) => console.error("Auto-draft trigger failed:", (e as Error).message))); -} - +app.use("/api/*",cors({origin:(origin)=>{if(!origin)return origin;try{const u=new URL(origin);if(u.hostname==="localhost"||u.hostname==="127.0.0.1")return origin;}catch{}return undefined;}})); +app.use("/api/v1/mailboxes/:mailboxId/*",requireMailbox); +const intQuery=(c:AppContext,k:string)=>{const v=c.req.query(k);if(!v)return undefined;const n=Number(v);return Number.isNaN(n)?undefined:n;}; +const boolQuery=(c:AppContext,k:string)=>{const v=c.req.query(k);return v===undefined||v===""?undefined:v==="true"||v==="1";}; +const slugify=(s:string)=>s.toLowerCase().replace(/\s+/g,"-").replace(/[^\w-]+/g,"").replace(/--+/g,"-").replace(/^-+|-+$/g,""); + +app.get("/api/v1/config",c=>{const domains=(c.env.DOMAINS||"").split(",").map(d=>d.trim()).filter(Boolean);return c.json({domains,emailAddresses:c.env.EMAIL_ADDRESSES??[]});}); +app.get("/api/v1/mailboxes",async c=>c.json((await listMailboxes(c.env.BUCKET)).map(m=>({...m,name:m.id})))); +app.post("/api/v1/mailboxes",async c=>{const {name,settings,email:raw}=CreateMailboxBody.parse(await c.req.json());const email=raw.toLowerCase();const allowed=(c.env.EMAIL_ADDRESSES??[]) as string[];if(allowed.length&&!allowed.map(a=>a.toLowerCase()).includes(email))return c.json({error:"Mailbox creation is restricted to configured EMAIL_ADDRESSES"},403);const key=`mailboxes/${email}.json`;if(await c.env.BUCKET.head(key))return c.json({error:"Mailbox already exists"},409);const finalSettings={fromName:name,forwarding:{enabled:false,email:""},signature:{enabled:false,text:""},autoReply:{enabled:false,subject:"",message:""},...settings};await c.env.BUCKET.put(key,JSON.stringify(finalSettings));const stub=c.env.MAILBOX.get(c.env.MAILBOX.idFromName(email));await stub.getFolders();return c.json({id:email,email,name,settings:finalSettings},201);}); +app.get("/api/v1/mailboxes/:mailboxId",async c=>{const id=c.req.param("mailboxId")!;const o=await c.env.BUCKET.get(`mailboxes/${id}.json`);if(!o)return c.json({error:"Not found"},404);return c.json({id,name:id,email:id,settings:await o.json()});}); +app.put("/api/v1/mailboxes/:mailboxId",async c=>{const id=c.req.param("mailboxId")!;const body=await c.req.json() as {settings:Record;signatureLogoUpload?:boolean};const key=`mailboxes/${id}.json`;if(!(await c.env.BUCKET.head(key)))return c.json({error:"Not found"},404);const settings=body.settings||{};await c.env.BUCKET.put(key,JSON.stringify(settings));return c.json({id,name:id,email:id,settings});}); + +// Signature assets live in a separate, user-configured R2 binding. Settings store only logoKey. +app.post("/api/v1/mailboxes/:mailboxId/signature-logo",async c=>{const id=c.req.param("mailboxId")!;const form=await c.req.formData();const file=form.get("file");if(!(file instanceof File))return c.json({error:"No logo file supplied"},400);const allowed=new Set(["image/png","image/jpeg","image/gif","image/webp"]);if(!allowed.has(file.type))return c.json({error:"Logo must be PNG, JPEG, GIF or WebP"},400);if(file.size>500*1024)return c.json({error:"Logo must be smaller than 500 KB"},400);const ext=file.type==="image/png"?"png":file.type==="image/jpeg"?"jpg":file.type==="image/gif"?"gif":"webp";const key=`signature-logos/${encodeURIComponent(id)}/logo.${ext}`;await c.env.SIGNATURE_ASSETS.put(key,file.stream(),{httpMetadata:{contentType:file.type,cacheControl:"private, max-age=3600"}});return c.json({key,url:`/api/v1/mailboxes/${encodeURIComponent(id)}/signature-logo`});}); +app.get("/api/v1/mailboxes/:mailboxId/signature-logo",async c=>{const id=c.req.param("mailboxId")!;const o=await c.env.BUCKET.get(`mailboxes/${id}.json`);if(!o)return c.notFound();const s=await o.json() as any;const key=s?.signature?.logoKey as string|undefined;if(!key)return c.notFound();const asset=await c.env.SIGNATURE_ASSETS.get(key);if(!asset)return c.notFound();const h=new Headers();asset.writeHttpMetadata(h);h.set("etag",asset.httpEtag);h.set("cache-control","private, max-age=3600");return new Response(asset.body,{headers:h});}); +app.delete("/api/v1/mailboxes/:mailboxId/signature-logo",async c=>{const id=c.req.param("mailboxId")!;const k=`mailboxes/${id}.json`;const o=await c.env.BUCKET.get(k);if(!o)return c.notFound();const s=await o.json() as any;if(s?.signature?.logoKey)await c.env.SIGNATURE_ASSETS.delete(s.signature.logoKey);const next={...s,signature:{...(s.signature||{}),logoKey:undefined,logoDataUrl:undefined}};await c.env.BUCKET.put(k,JSON.stringify(next));return c.json({ok:true});}); + +app.delete("/api/v1/mailboxes/:mailboxId",async c=>{const id=c.req.param("mailboxId")!;const k=`mailboxes/${id}.json`;if(!(await c.env.BUCKET.head(k)))return c.json({error:"Not found"},404);await c.env.BUCKET.delete(k);return c.body(null,204);}); +app.get("/api/v1/mailboxes/:mailboxId/emails",async(c:AppContext)=>{const folder=c.req.query("folder"),thread_id=c.req.query("thread_id"),threaded=boolQuery(c,"threaded"),page=intQuery(c,"page"),limit=intQuery(c,"limit"),sortColumn=c.req.query("sortColumn") as any,sortDirection=c.req.query("sortDirection") as any,stub=c.var.mailboxStub;if(threaded&&folder){const emails=await(stub as any).getThreadedEmails({folder,page,limit});return c.json({emails,totalCount:await(stub as any).countThreadedEmails(folder)});}const emails=await stub.getEmails({folder,thread_id,page,limit,sortColumn,sortDirection});return folder?c.json({emails,totalCount:await stub.countEmails({folder,thread_id})}):c.json(emails);}); +app.post("/api/v1/mailboxes/:mailboxId/emails",async(c:AppContext)=>{const mailboxId=c.req.param("mailboxId")!;const body=SendEmailRequestSchema.parse(await c.req.json());const {to,cc,bcc,from,subject,html,text,attachments,in_reply_to,references,thread_id}=body;let toStr:string,fromEmail:string,fromDomain:string;try{({toStr,fromEmail,fromDomain}=validateSender(to,from,mailboxId));}catch(e){if(e instanceof SenderValidationError)return c.json({error:e.message},400);throw e;}const {messageId,outgoingMessageId}=generateMessageId(fromDomain);const stub=c.var.mailboxStub;if(await(stub as any).checkSendRateLimit())return c.json({error:"Send rate limit exceeded"},429);const attachmentData=await storeAttachments(c.env.BUCKET,messageId,attachments);await stub.createEmail(Folders.SENT,{id:messageId,subject,sender:fromEmail,recipient:toStr,cc:cc?(Array.isArray(cc)?cc.join(", "):cc).toLowerCase():null,bcc:bcc?(Array.isArray(bcc)?bcc.join(", "):bcc).toLowerCase():null,date:new Date().toISOString(),body:html||text||"",in_reply_to:in_reply_to||null,email_references:references?JSON.stringify(references):null,thread_id:thread_id||in_reply_to||messageId,message_id:outgoingMessageId,raw_headers:JSON.stringify([{key:"from",value:typeof from==="string"?from:`${from.name} <${from.email}>`},{key:"to",value:Array.isArray(to)?to.join(", "):to},{key:"subject",value:subject},{key:"message-id",value:`<${outgoingMessageId}>`}])},attachmentData);c.executionCtx.waitUntil(sendEmail(c.env.EMAIL,{to,cc,bcc,from,subject,html,text,attachments:attachments?.map(a=>({content:a.content,filename:a.filename,type:a.type,disposition:a.disposition||"attachment",contentId:a.contentId})),...(in_reply_to?{headers:buildThreadingHeaders(in_reply_to,references||[])}:{})}).catch(e=>console.error("Deferred email delivery failed:",(e as Error).message)));return c.json({id:messageId,status:"sent"},202);}); +app.post("/api/v1/mailboxes/:mailboxId/drafts",async(c:AppContext)=>{const id=c.req.param("mailboxId")!;const {to,cc,bcc,subject,body,in_reply_to,thread_id,draft_id}=DraftBody.parse(await c.req.json());const stub=c.var.mailboxStub;if(draft_id)await stub.deleteEmail(draft_id);const messageId=crypto.randomUUID(),now=new Date().toISOString();await stub.createEmail(Folders.DRAFT,{id:messageId,subject:subject||"",sender:id.toLowerCase(),recipient:(to||"").toLowerCase(),cc:cc?.toLowerCase()||null,bcc:bcc?.toLowerCase()||null,date:now,body,in_reply_to:in_reply_to||null,email_references:null,thread_id:thread_id||in_reply_to||messageId},[]);return c.json({id:messageId,status:"draft",subject:subject||"",recipient:to||"",date:now},201);}); +app.get("/api/v1/mailboxes/:mailboxId/emails/:id",async(c:AppContext)=>{const e=await c.var.mailboxStub.getEmail(c.req.param("id")!);return e?c.json(e):c.json({error:"Email not found"},404);}); +app.put("/api/v1/mailboxes/:mailboxId/emails/:id",async(c:AppContext)=>{const {read,starred}=await c.req.json() as any;const e=await c.var.mailboxStub.updateEmail(c.req.param("id")!,{read,starred});return e?c.json(e):c.json({error:"Email not found"},404);}); +app.delete("/api/v1/mailboxes/:mailboxId/emails/:id",async(c:AppContext)=>{const id=c.req.param("id")!,a=await c.var.mailboxStub.deleteEmail(id);if(a===null)return c.json({error:"Not found"},404);if(a.length)await c.env.BUCKET.delete(a.map((x:any)=>`attachments/${id}/${x.id}/${x.filename}`));return c.body(null,204);}); +app.post("/api/v1/mailboxes/:mailboxId/emails/:id/move",async(c:AppContext)=>{const {folderId}=await c.req.json() as any;return await c.var.mailboxStub.moveEmail(c.req.param("id")!,folderId)?c.json({status:"moved"}):c.json({error:"Folder not found"},400);}); +app.get("/api/v1/mailboxes/:mailboxId/threads/:threadId",async(c:AppContext)=>c.json(await(c.var.mailboxStub as any).getThreadEmails(c.req.param("threadId")!))); +app.post("/api/v1/mailboxes/:mailboxId/threads/:threadId/read",async(c:AppContext)=>{await c.var.mailboxStub.markThreadRead(c.req.param("threadId")!);return c.json({status:"marked_read"});}); +app.post("/api/v1/mailboxes/:mailboxId/emails/:id/reply",handleReplyEmail);app.post("/api/v1/mailboxes/:mailboxId/emails/:id/forward",handleForwardEmail); +app.get("/api/v1/mailboxes/:mailboxId/folders",async(c:AppContext)=>c.json(await c.var.mailboxStub.getFolders())); +app.post("/api/v1/mailboxes/:mailboxId/folders",async(c:AppContext)=>{const {name}=await c.req.json() as any;const slug=slugify(name);if(!slug)return c.json({error:"Folder name must contain alphanumeric characters"},400);const f=await c.var.mailboxStub.createFolder(slug,name);return f?c.json(f,201):c.json({error:"Folder with this name already exists"},409);}); +app.put("/api/v1/mailboxes/:mailboxId/folders/:id",async(c:AppContext)=>{const {name}=await c.req.json() as any;const f=await c.var.mailboxStub.updateFolder(c.req.param("id")!,name);return f?c.json(f):c.json({error:"Folder not found"},404);}); +app.delete("/api/v1/mailboxes/:mailboxId/folders/:id",async(c:AppContext)=>await c.var.mailboxStub.deleteFolder(c.req.param("id")!)?c.body(null,204):c.json({error:"Folder not found or cannot be deleted"},400)); +app.get("/api/v1/mailboxes/:mailboxId/search",async(c:AppContext)=>{const opts={query:c.req.query("query")||"",folder:c.req.query("folder"),from:c.req.query("from"),to:c.req.query("to"),subject:c.req.query("subject"),date_start:c.req.query("date_start"),date_end:c.req.query("date_end"),is_read:boolQuery(c,"is_read"),is_starred:boolQuery(c,"is_starred"),has_attachment:boolQuery(c,"has_attachment")};const stub=c.var.mailboxStub as any;const emails=await stub.searchEmails({...opts,page:intQuery(c,"page"),limit:intQuery(c,"limit")});return c.json({emails,totalCount:await stub.countSearchResults(opts)});}); +app.get("/api/v1/mailboxes/:mailboxId/emails/:emailId/attachments/:attachmentId",async(c:AppContext)=>{const emailId=c.req.param("emailId")!,aid=c.req.param("attachmentId")!,a=await c.var.mailboxStub.getAttachment(aid);if(!a)return c.json({error:"Attachment not found"},404);const o=await c.env.BUCKET.get(`attachments/${emailId}/${aid}/${a.filename}`);if(!o)return c.json({error:"Attachment file not found"},404);const h=new Headers();h.set("Content-Type",a.mimetype);const fn=a.filename.replace(/[\x00-\x1f"\\]/g,"_");h.set("Content-Disposition",`${a.disposition==="inline"?"inline":"attachment"}; filename="${fn}"; filename*=UTF-8''${encodeURIComponent(a.filename)}`);return new Response(o.body,{headers:h});}); + +const MAX_EMAIL_SIZE=25*1024*1024; +async function streamToArrayBuffer(stream:ReadableStream,size:number){if(size>MAX_EMAIL_SIZE||size<=0)throw new Error(`Invalid email size: ${size}`);const out=new Uint8Array(size);let n=0;const r=stream.getReader();while(true){const {done,value}=await r.read();if(done)break;if(n+value.length>size){r.cancel();throw new Error("Stream exceeds declared size");}out.set(value,n);n+=value.length;}return out;} +async function receiveEmail(event:{raw:ReadableStream;rawSize:number},env:Env,ctx:ExecutionContext){const raw=await streamToArrayBuffer(event.raw,event.rawSize);const parsed=await new PostalMime().parse(raw);if(!parsed.to?.length||!parsed.to[0].address)throw new Error("received email with empty to");const allowed=((env.EMAIL_ADDRESSES??[]) as string[]).map(a=>a.toLowerCase());const recipients=parsed.to.map(t=>t.address?.toLowerCase()).filter(Boolean) as string[];const cc=(parsed.cc||[]).map(e=>e.address?.toLowerCase()).filter(Boolean) as string[];const bcc=(parsed.bcc||[]).map(e=>e.address?.toLowerCase()).filter(Boolean) as string[];const mailboxId=allowed.length?recipients.find(a=>allowed.includes(a)):recipients[0];if(!mailboxId)return;const messageId=crypto.randomUUID();if(!(await env.BUCKET.head(`mailboxes/${mailboxId}.json`)))return;const stub=env.MAILBOX.get(env.MAILBOX.idFromName(mailboxId));const attachmentData:StoredAttachment[]=[];for(const att of parsed.attachments||[]){const id=crypto.randomUUID(),filename=(att.filename||"untitled").replace(/[\/\\:*?"<>|\x00-\x1f]/g,"_");await env.BUCKET.put(`attachments/${messageId}/${id}/${filename}`,att.content);attachmentData.push({id,email_id:messageId,filename,mimetype:att.mimeType,size:typeof att.content==="string"?att.content.length:att.content.byteLength,content_id:att.contentId||null,disposition:att.disposition||"attachment"});}const extract=(s:string)=>{const m=s.match(/<([^>]+)>/);return m?m[1]:s.trim().split(/\s+/)[0];};const inReply=parsed.inReplyTo?extract(parsed.inReplyTo):null;const refs=parsed.references?parsed.references.split(/\s+/).filter(Boolean).map(extract):[];let threadId=refs[0]||inReply||messageId;if(!inReply&&!refs.length){const t=await(stub as any).findThreadBySubject(parsed.subject||"",parsed.from?.address||undefined);if(t)threadId=t;}const original=parsed.messageId?extract(parsed.messageId):null;await stub.createEmail(Folders.INBOX,{id:messageId,subject:parsed.subject||"",sender:(parsed.from?.address||"").toLowerCase(),recipient:recipients.join(", "),cc:cc.join(", ")||null,bcc:bcc.join(", ")||null,date:new Date().toISOString(),body:parsed.html||parsed.text||"",in_reply_to:inReply,email_references:refs.length?JSON.stringify(refs):null,thread_id:threadId,message_id:original,raw_headers:JSON.stringify(parsed.headers)},attachmentData);const agentStub=env.EMAIL_AGENT.get(env.EMAIL_AGENT.idFromName(mailboxId));ctx.waitUntil(agentStub.fetch(new Request("https://agents/onNewEmail",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({mailboxId,emailId:messageId,sender:(parsed.from?.address||"").toLowerCase(),subject:parsed.subject||"",threadId})})).catch(e=>console.error("Auto-draft trigger failed:",(e as Error).message)));} export { app, receiveEmail }; diff --git a/workers/lib/attachments.ts b/workers/lib/attachments.ts index d1bc036a2..34a25b357 100644 --- a/workers/lib/attachments.ts +++ b/workers/lib/attachments.ts @@ -20,6 +20,9 @@ export interface StoredAttachment { /** * Store base64-encoded attachments to R2 and return metadata for the DO. + * Content-ID alone does not mean a file is an inline resource: some senders + * add Content-ID to ordinary attachments. PostalMime's `related` flag is the + * reliable signal for HTML-related inline parts. */ export async function storeAttachments( bucket: Env["BUCKET"], @@ -37,7 +40,6 @@ export async function storeAttachments( const results: StoredAttachment[] = []; for (const att of attachments) { const attachmentId = crypto.randomUUID(); - // Sanitize filename to prevent path traversal in R2 keys const safeFilename = (att.filename || "untitled").replace(/[\/\\:*?"<>|\x00-\x1f]/g, "_"); const key = `attachments/${emailId}/${attachmentId}/${safeFilename}`; const binaryStr = atob(att.content); @@ -50,7 +52,7 @@ export async function storeAttachments( mimetype: att.type, size: bytes.byteLength, content_id: att.contentId || null, - disposition: att.disposition, + disposition: att.disposition || "attachment", }); } return results; diff --git a/workers/lib/auth.ts b/workers/lib/auth.ts new file mode 100644 index 000000000..d4dd9d485 --- /dev/null +++ b/workers/lib/auth.ts @@ -0,0 +1,36 @@ +import type { Context } from "hono"; +import type { Env } from "../types"; + +export interface AuthUser { email: string; name: string; role: "admin" | "employee"; } +export type AuthContext = Context<{ Bindings: Env; Variables: { user: AuthUser } }>; +const SESSION_COOKIE = "agentic_session"; + +export function getSessionToken(request: Request): string | null { + const cookie = request.headers.get("Cookie") || ""; + for (const part of cookie.split(";")) { const [name, ...rest] = part.trim().split("="); if (name === SESSION_COOKIE) return rest.join("=") || null; } + return null; +} + +export function sessionCookie(token: string): string { return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax`; } +export function expiredSessionCookie(): string { return `${SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`; } + +export async function getSessionUser(env: Env, request: Request): Promise { + const token = getSessionToken(request); if (!token) return null; + const stub = env.USER_AUTH.get(env.USER_AUTH.idFromName("global")); + const response = await stub.fetch("https://user-auth/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); + if (!response.ok) return null; return (await response.json() as { user: AuthUser }).user; +} +export async function requireUser(c: AuthContext, next: () => Promise): Promise { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); c.set("user", user); return next(); } +export function canAccessMailbox(user: AuthUser, mailboxId: string): boolean { return user.role === "admin" || user.email.toLowerCase() === mailboxId.toLowerCase(); } +export function isAdmin(user: AuthUser): boolean { return user.role === "admin"; } + +export async function seedAdmin(env: Env): Promise { + if (!env.ADMIN_PASSWORD) throw new Error("ADMIN_PASSWORD is not configured"); + const email = (env.ADMIN_EMAIL || "").trim().toLowerCase(); + if (!email) throw new Error("ADMIN_EMAIL is not configured"); + const stub = env.USER_AUTH.get(env.USER_AUTH.idFromName("global")); + const response = await stub.fetch("https://user-auth/seed-admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password: env.ADMIN_PASSWORD }) }); + if (!response.ok) { const detail = await response.text().catch(() => ""); throw new Error(`Admin initialization failed (${response.status})${detail ? `: ${detail}` : ""}`); } + const key = `mailboxes/${email}.json`; + if (!(await env.BUCKET.head(key))) { const settings = { fromName: "Administrator", forwarding: { enabled: false, email: "" }, signature: { enabled: false, text: "" }, autoReply: { enabled: false, subject: "", message: "" } }; await env.BUCKET.put(key, JSON.stringify(settings)); const mailbox = env.MAILBOX.get(env.MAILBOX.idFromName(email)); await mailbox.getFolders(); } +} diff --git a/workers/lib/post-delivery.ts b/workers/lib/post-delivery.ts new file mode 100644 index 000000000..35716ca0c --- /dev/null +++ b/workers/lib/post-delivery.ts @@ -0,0 +1,186 @@ +import { sendEmail } from "../email-sender"; +import type { Env } from "../types"; + +export interface DeliveredEmail { + mailboxId: string; + emailId: string; + sender: string; + recipient: string; + subject: string; + body: string; + date: string; + messageId?: string | null; + alreadyForwarded?: boolean; +} + +type NotificationSettings = { + forwarding?: { + enabled?: boolean; + email?: string; + includeInternal?: boolean; + }; + telegram?: { + enabled?: boolean; + botToken?: string; + chatId?: string; + includeInternal?: boolean; + }; +}; + +function extractAddresses(value: string): string[] { + return value + .split(/[;,]/) + .map((v) => v.trim().toLowerCase()) + .filter(Boolean); +} + +function isInternal(delivery: DeliveredEmail, env: Env): boolean { + const domains = String(env.DOMAINS || "") + .split(",") + .map((d) => d.trim().toLowerCase()) + .filter(Boolean); + const recipients = extractAddresses(delivery.recipient); + const sender = delivery.sender.toLowerCase(); + const isInternalAddress = (address: string) => + domains.some((domain) => address.endsWith(`@${domain}`)); + return isInternalAddress(sender) && recipients.some(isInternalAddress); +} + +function renderForwardBody(email: DeliveredEmail): string { + return [ + `---------- Forwarded message ----------`, + `From: ${email.sender}`, + `To: ${email.recipient}`, + `Date: ${email.date}`, + `Subject: ${email.subject}`, + ``, + email.body || "", + ].join("\n"); +} + +function telegramText(email: DeliveredEmail): string { + const body = (email.body || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); + const snippet = body.length > 700 ? `${body.slice(0, 700)}…` : body; + return [ + `📧 New email: ${email.mailboxId}`, + `From: ${email.sender}`, + `Subject: ${email.subject || "(no subject)"}`, + snippet ? `\n${snippet}` : "", + ].join("\n"); +} + +async function notifyTelegram(settings: NotificationSettings["telegram"], email: DeliveredEmail) { + if (!settings?.enabled || !settings.botToken || !settings.chatId) return; + const response = await fetch(`https://api.telegram.org/bot${encodeURIComponent(settings.botToken)}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: settings.chatId, + text: telegramText(email), + disable_web_page_preview: true, + }), + }); + if (!response.ok) throw new Error(`Telegram notification failed: HTTP ${response.status}`); +} + +/** Load per-mailbox notification settings without exposing secrets to callers. */ +export async function getPostDeliverySettings(env: Env, mailboxId: string): Promise { + const object = await env.BUCKET.get(`mailboxes/${mailboxId.toLowerCase()}.json`); + if (!object) return {}; + const value = await object.json }>(); + return { + forwarding: value.forwarding + ? { + enabled: value.forwarding.enabled === true, + email: typeof value.forwarding.email === "string" ? value.forwarding.email : "", + includeInternal: value.forwarding.includeInternal !== false, + } + : undefined, + telegram: value.telegram + ? { + enabled: value.telegram.enabled === true, + botToken: typeof value.telegram.botToken === "string" ? value.telegram.botToken : "", + chatId: typeof value.telegram.chatId === "string" ? value.telegram.chatId : "", + includeInternal: value.telegram.includeInternal !== false, + } + : undefined, + }; +} + +/** + * Runs after an email has been durably stored. + * + * Native Cloudflare EmailMessage.forward() is intentionally awaited while the + * email event is still active. Cloudflare's native forward is tied to the + * original EmailMessage event and must not be deferred into waitUntil(). + * Telegram notification is independent and is scheduled separately, so a + * forwarding failure can never suppress Telegram notification. + */ +export async function runPostDelivery( + env: Env, + executionCtx: ExecutionContext, + email: DeliveredEmail, + settings: NotificationSettings, + nativeForward?: (target: string) => Promise, +) { + const internal = isInternal(email, env); + const forwarding = settings.forwarding; + const telegram = settings.telegram; + const tasks: Promise[] = []; + + if (!internal && forwarding?.enabled && forwarding.email) { + const target = forwarding.email.trim().toLowerCase(); + const recipientAddresses = extractAddresses(email.recipient); + if ( + target && + target !== email.mailboxId.toLowerCase() && + !recipientAddresses.includes(target) && + email.alreadyForwarded !== true + ) { + if (nativeForward) { + // IMPORTANT: do not defer EmailMessage.forward() to waitUntil(). + // It must execute while the Cloudflare email event is alive. + try { + await nativeForward(target); + } catch (error) { + console.error( + `Native forwarding to ${target} failed:`, + error instanceof Error ? error.message : error, + ); + } + } else { + tasks.push( + sendEmail(env.EMAIL, { + to: target, + from: email.mailboxId, + subject: `[Forwarded] ${email.subject || "(no subject)"}`, + text: renderForwardBody(email), + headers: { + "X-Agentic-Inbox-Forwarded": "1", + ...(email.messageId ? { "X-Agentic-Inbox-Original-Message-Id": email.messageId } : {}), + }, + }), + ); + } + } + } + + if (!internal && telegram?.enabled && telegram.botToken && telegram.chatId) { + tasks.push(notifyTelegram(telegram, email)); + } + + if (tasks.length === 0) return; + + executionCtx.waitUntil( + Promise.allSettled(tasks).then((results) => { + for (const result of results) { + if (result.status === "rejected") { + console.error( + "Post-delivery notification failed:", + result.reason instanceof Error ? result.reason.message : result.reason, + ); + } + } + }), + ); +} diff --git a/workers/receive-with-notifications.ts b/workers/receive-with-notifications.ts new file mode 100644 index 000000000..2705a490e --- /dev/null +++ b/workers/receive-with-notifications.ts @@ -0,0 +1,115 @@ +import PostalMime from "postal-mime"; +import { receiveEmail } from "./index"; +import type { Env } from "./types"; +import { getPostDeliverySettings, runPostDelivery, type DeliveredEmail } from "./lib/post-delivery"; + +const MAX_EMAIL_SIZE = 25 * 1024 * 1024; + +type ForwardableEvent = { + raw: ReadableStream; + rawSize: number; + forward?: (target: string) => Promise; + canBeForwarded?: boolean; +}; + +async function readRaw(stream: ReadableStream, size: number): Promise { + if (size > MAX_EMAIL_SIZE) throw new Error(`Email too large: ${size} bytes exceeds ${MAX_EMAIL_SIZE} byte limit`); + if (size <= 0) throw new Error(`Invalid stream size: ${size}`); + const result = new Uint8Array(size); + let offset = 0; + const reader = stream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (offset + value.length > size) { + await reader.cancel(); + throw new Error("Incoming email stream exceeds declared size"); + } + result.set(value, offset); + offset += value.length; + } + return result.subarray(0, offset); +} + +function extractMessageId(value: string | undefined | null): string | null { + if (!value) return null; + const match = value.match(/<([^>]+)>/); + return match ? match[1] : value.trim().split(/\s+/)[0] || null; +} + +function findMailbox(parsed: any, env: Env): string | undefined { + const allowed = ((env.EMAIL_ADDRESSES ?? []) as string[]).map((a) => a.toLowerCase()); + const recipients = (parsed.to || []) + .map((entry: any) => entry.address?.toLowerCase()) + .filter(Boolean) as string[]; + if (allowed.length > 0) return recipients.find((address) => allowed.includes(address)); + return recipients[0]; +} + +function hasForwardingMarker(parsed: any): boolean { + return (parsed.headers || []).some((header: any) => + String(header.key || "").toLowerCase() === "x-agentic-inbox-forwarded" && + String(header.value || "").trim() === "1" + ); +} + +/** + * Reads the inbound stream once, feeds the receiver unchanged, and then runs + * the optional forwarding/Telegram pipeline. The original Cloudflare + * ForwardableEmailMessage.forward() is kept as a bound method so that the + * native forwarding call retains its original receiver/context. + */ +export async function receiveEmailWithNotifications( + event: ForwardableEvent, + env: Env, + ctx: ExecutionContext, +) { + const raw = await readRaw(event.raw, event.rawSize); + const parsed = await new PostalMime().parse(raw); + const mailboxId = findMailbox(parsed, env); + if (!mailboxId) { + await receiveEmail({ raw: new Response(raw).body!, rawSize: raw.byteLength }, env, ctx); + return; + } + + if (!(await env.BUCKET.head(`mailboxes/${mailboxId}.json`))) { + await receiveEmail({ raw: new Response(raw).body!, rawSize: raw.byteLength }, env, ctx); + return; + } + + await receiveEmail({ raw: new Response(raw).body!, rawSize: raw.byteLength }, env, ctx); + + const sender = (parsed.from?.address || "").toLowerCase(); + const recipients = (parsed.to || []) + .map((entry: any) => entry.address?.toLowerCase()) + .filter(Boolean) + .join(", "); + const originalMessageId = extractMessageId(parsed.messageId); + const delivered: DeliveredEmail = { + mailboxId, + emailId: originalMessageId || crypto.randomUUID(), + sender, + recipient: recipients, + subject: parsed.subject || "", + body: parsed.html || parsed.text || "", + date: new Date().toISOString(), + messageId: originalMessageId, + alreadyForwarded: hasForwardingMarker(parsed), + }; + + const settings = await getPostDeliverySettings(env, mailboxId); + // Keep the native Cloudflare EmailMessage.forward() call bound to the + // original event object. This is deliberately not put in waitUntil(). + const nativeForward = typeof event.forward === "function" + ? event.forward.bind(event) + : undefined; + + if (settings.forwarding?.enabled && settings.forwarding.email && !nativeForward) { + console.error("Native email forwarding is unavailable for this email event"); + } + if (settings.forwarding?.enabled && settings.forwarding.email && event.canBeForwarded === false) { + console.error("Cloudflare reports this email cannot be forwarded natively"); + } + + await runPostDelivery(env, ctx, delivered, settings, nativeForward); +} diff --git a/workers/types.ts b/workers/types.ts index c89667274..ecbfbc681 100644 --- a/workers/types.ts +++ b/workers/types.ts @@ -1,8 +1,14 @@ // Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// Licensed under the Apache 2.0 license found in the LICENSE file and at: // https://opensource.org/licenses/Apache-2.0 export interface Env extends Cloudflare.Env { POLICY_AUD: string; TEAM_DOMAIN: string; + TEAM_DOMAINS?: string; + APP_NAME?: string; + ADMIN_EMAIL?: string; + ADMIN_PASSWORD?: string; + /** Dedicated R2 bucket for mailbox signature/logo assets. Bind this in Cloudflare. */ + SIGNATURE_ASSETS: R2Bucket; } diff --git a/workers/userAuth.ts b/workers/userAuth.ts new file mode 100644 index 000000000..c1bb694d2 --- /dev/null +++ b/workers/userAuth.ts @@ -0,0 +1,59 @@ +import { DurableObject } from "cloudflare:workers"; +import type { Env } from "./types"; +import { sendEmail } from "./email-sender"; + +const PBKDF2_ITERATIONS = 100_000; +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; +interface UserRecord { email: string; name: string; role: "admin" | "employee"; status: "pending" | "active" | "disabled"; password_hash: string; created_at: string; } +function bytesToBase64(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary); } +function base64ToBytes(value: string): Uint8Array { const binary = atob(value); return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } +async function hashPassword(password: string, salt?: Uint8Array): Promise { const actualSalt = salt ?? crypto.getRandomValues(new Uint8Array(16)); const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: actualSalt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, key, 256); return `${bytesToBase64(actualSalt)}:${bytesToBase64(new Uint8Array(bits))}`; } +async function verifyPassword(password: string, stored: string): Promise { const [saltText, expectedText] = stored.split(":"); if (!saltText || !expectedText) return false; const actual = await hashPassword(password, base64ToBytes(saltText)); return timingSafeEqual(actual.split(":")[1], expectedText); } +function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let result = 0; for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i); return result === 0; } +function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } +function publicUser(row: any) { return { email: row.email, name: row.name, role: row.role, status: row.status, createdAt: row.created_at }; } +function randomToken(): string { const bytes = crypto.getRandomValues(new Uint8Array(32)); return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); } + +export class UserAuthDO extends DurableObject { + private initialized = false; + private init() { if (this.initialized) return; this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS users (email TEXT PRIMARY KEY,name TEXT NOT NULL,role TEXT NOT NULL,status TEXT NOT NULL,password_hash TEXT NOT NULL,created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS sessions (token TEXT PRIMARY KEY,email TEXT NOT NULL,expires_at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);`); this.initialized = true; } + private cleanupSessions() { this.ctx.storage.sql.exec("DELETE FROM sessions WHERE expires_at <= ?", Math.floor(Date.now() / 1000)); } + async fetch(request: Request): Promise { + this.init(); this.cleanupSessions(); const url = new URL(request.url); const body = request.method === "POST" ? await request.json().catch(() => ({})) as Record : {}; + if (url.pathname === "/seed-admin" && request.method === "POST") { + const email = normalizeEmail(String(body.email ?? "")); const password = String(body.password ?? ""); if (!email || password.length < 8) return Response.json({ error: "Invalid admin credentials" }, { status: 400 }); + const existing = this.ctx.storage.sql.exec("SELECT * FROM users WHERE email = ?", email).toArray()[0] as UserRecord | undefined; + const passwordHash = await hashPassword(password); + if (!existing) this.ctx.storage.sql.exec("INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", email, "Administrator", "admin", "active", passwordHash, new Date().toISOString()); + else this.ctx.storage.sql.exec("UPDATE users SET name='Administrator', role='admin', status='active', password_hash=? WHERE email = ?", passwordHash, email); + return Response.json({ ok: true }); + } + if (url.pathname === "/register" && request.method === "POST") { + const email = normalizeEmail(String(body.email ?? "")); const name = String(body.name ?? "").trim(); const password = String(body.password ?? ""); if (!email || !name || password.length < 8) return Response.json({ error: "Name, email and an 8+ character password are required" }, { status: 400 }); if (!email.includes("@")) return Response.json({ error: "Invalid email address" }, { status: 400 }); if (this.ctx.storage.sql.exec("SELECT email FROM users WHERE email = ?", email).toArray().length > 0) return Response.json({ error: "An account with this email already exists" }, { status: 409 }); const passwordHash = await hashPassword(password); this.ctx.storage.sql.exec("INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", email, name, "employee", "pending", passwordHash, new Date().toISOString()); + const adminEmail = normalizeEmail(String(this.env.ADMIN_EMAIL || "")); + if (!adminEmail) return Response.json({ error: "ADMIN_EMAIL is not configured" }, { status: 500 }); + this.ctx.waitUntil(sendEmail(this.env.EMAIL, { to: adminEmail, from: adminEmail, subject: "New mailbox registration pending approval", text: `A new mailbox registration is waiting for your approval.\n\nName: ${name}\nEmail: ${email}\nStatus: pending\n\nPlease sign in to the mailbox administration page and approve this account.` }).catch((error) => { console.error("Failed to send new-registration notification:", error instanceof Error ? error.message : error); })); + return Response.json({ status: "pending" }, { status: 201 }); + } + if (url.pathname === "/login" && request.method === "POST") { + const email = normalizeEmail(String(body.email ?? "")); const password = String(body.password ?? ""); const row = this.ctx.storage.sql.exec("SELECT * FROM users WHERE email = ?", email).toArray()[0] as UserRecord | undefined; if (!row || !(await verifyPassword(password, row.password_hash))) return Response.json({ error: "Invalid email or password" }, { status: 401 }); if (row.status !== "active") return Response.json({ error: row.status === "pending" ? "Your account is awaiting administrator approval" : "Your account is disabled" }, { status: 403 }); const token = randomToken(); const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS; this.ctx.storage.sql.exec("INSERT INTO sessions (token,email,expires_at) VALUES (?,?,?)", token, email, expiresAt); return Response.json({ token, user: { email: row.email, name: row.name, role: row.role } }); + } + if (url.pathname === "/session" && request.method === "POST") { const token = String(body.token ?? ""); const row = this.ctx.storage.sql.exec("SELECT u.email,u.name,u.role,u.status,s.expires_at FROM sessions s JOIN users u ON u.email=s.email WHERE s.token=?", token).toArray()[0] as (UserRecord & { expires_at: number }) | undefined; if (!row || row.expires_at <= Math.floor(Date.now() / 1000) || row.status !== "active") return Response.json({ error: "Invalid session" }, { status: 401 }); return Response.json({ user: { email: row.email, name: row.name, role: row.role } }); } + if (url.pathname === "/logout" && request.method === "POST") { const token = String(body.token ?? ""); this.ctx.storage.sql.exec("DELETE FROM sessions WHERE token = ?", token); return Response.json({ ok: true }); } + if (url.pathname === "/admin/users" && request.method === "GET") { const users = this.ctx.storage.sql.exec("SELECT email,name,role,status,created_at FROM users ORDER BY created_at ASC").toArray(); return Response.json({ users: users.map(publicUser) }); } + if (url.pathname === "/admin/approve" && request.method === "POST") { const email = normalizeEmail(String(body.email ?? "")); this.ctx.storage.sql.exec("UPDATE users SET status='active' WHERE email=? AND role='employee'", email); const row = this.ctx.storage.sql.exec("SELECT email,name,role,status,created_at FROM users WHERE email=?", email).toArray()[0]; return row ? Response.json({ user: publicUser(row) }) : Response.json({ error: "User not found" }, { status: 404 }); } + if (url.pathname === "/admin/status" && request.method === "POST") { const email = normalizeEmail(String(body.email ?? "")); const status = String(body.status ?? ""); if (!["active", "disabled", "pending"].includes(status)) return Response.json({ error: "Invalid status" }, { status: 400 }); this.ctx.storage.sql.exec("UPDATE users SET status=? WHERE email=? AND role='employee'", status, email); return Response.json({ ok: true }); } + if (url.pathname === "/admin/reset-password" && request.method === "POST") { + const email = normalizeEmail(String(body.email ?? "")); const password = String(body.password ?? ""); const name = String(body.name ?? "").trim() || email.split("@")[0]; + if (!email.includes("@")) return Response.json({ error: "Invalid email address" }, { status: 400 }); + if (password.length < 8) return Response.json({ error: "Password must be at least 8 characters" }, { status: 400 }); + const passwordHash = await hashPassword(password); + const existing = this.ctx.storage.sql.exec("SELECT email FROM users WHERE email=?", email).toArray()[0]; + if (existing) this.ctx.storage.sql.exec("UPDATE users SET password_hash=?, status='active', role='employee' WHERE email=?", passwordHash, email); + else this.ctx.storage.sql.exec("INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", email, name, "employee", "active", passwordHash, new Date().toISOString()); + this.ctx.storage.sql.exec("DELETE FROM sessions WHERE email=?", email); + return Response.json({ ok: true }); + } + return Response.json({ error: "Not found" }, { status: 404 }); + } +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 53a65cd0d..4e824e6ad 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,69 +3,31 @@ "name": "agentic-inbox", "compatibility_date": "2025-11-28", "main": "./workers/app.ts", - "observability": { - "enabled": true - }, - "compatibility_flags": [ - "nodejs_compat" - ], + "observability": { "enabled": true }, + "compatibility_flags": ["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": [] }, - "send_email": [ - { - "name": "EMAIL", - "remote": true - } - ], - "r2_buckets": [ - { - "binding": "BUCKET", - "bucket_name": "agentic-inbox", - "preview_bucket_name": "agentic-inbox" - } - ], - "ai": { - "binding": "AI" - }, + "send_email": [{ "name": "EMAIL", "remote": true }], + "r2_buckets": [{ + "binding": "BUCKET", + "bucket_name": "agentic-inbox", + "preview_bucket_name": "agentic-inbox" + }], + "ai": { "binding": "AI" }, "durable_objects": { "bindings": [ - { - "name": "MAILBOX", - "class_name": "MailboxDO" - }, - { - "name": "EMAIL_AGENT", - "class_name": "EmailAgent" - }, - { - "name": "EMAIL_MCP", - "class_name": "EmailMCP" - } + { "name": "MAILBOX", "class_name": "MailboxDO" }, + { "name": "EMAIL_AGENT", "class_name": "EmailAgent" }, + { "name": "EMAIL_MCP", "class_name": "EmailMCP" }, + { "name": "USER_AUTH", "class_name": "UserAuthDO" } ] }, "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": [ - "MailboxDO" - ] - }, - { - "tag": "v2", - "new_sqlite_classes": [ - "EmailAgent" - ] - }, - { - "tag": "v3", - "new_sqlite_classes": [ - "EmailMCP" - ] - } + { "tag": "v1", "new_sqlite_classes": ["MailboxDO"] }, + { "tag": "v2", "new_sqlite_classes": ["EmailAgent"] }, + { "tag": "v3", "new_sqlite_classes": ["EmailMCP"] }, + { "tag": "v4", "new_sqlite_classes": ["UserAuthDO"] } ] }