From 313984d7b210aa7a848fc101e3363b06f3bcb85d Mon Sep 17 00:00:00 2001 From: Harshil Agrawal Date: Fri, 17 Apr 2026 23:16:07 +0200 Subject: [PATCH 1/3] wip: add onboarding --- app/queries/keys.ts | 1 + app/queries/setup.ts | 11 + app/root.tsx | 62 +++- app/routes.ts | 1 + app/routes/home.tsx | 201 +++++++------ app/routes/setup.tsx | 157 ++++++++++ app/services/api.ts | 17 ++ package-lock.json | 597 -------------------------------------- workers/app.ts | 40 ++- workers/index.ts | 78 +++++ workers/lib/setup-page.ts | 279 ++++++++++++++++++ 11 files changed, 741 insertions(+), 703 deletions(-) create mode 100644 app/queries/setup.ts create mode 100644 app/routes/setup.tsx create mode 100644 workers/lib/setup-page.ts diff --git a/app/queries/keys.ts b/app/queries/keys.ts index 54bcab12..d80a08ff 100644 --- a/app/queries/keys.ts +++ b/app/queries/keys.ts @@ -24,4 +24,5 @@ export const queryKeys = { ["search", mailboxId, query, page] as const, }, config: ["config"] as const, + setupStatus: ["setupStatus"] as const, }; diff --git a/app/queries/setup.ts b/app/queries/setup.ts new file mode 100644 index 00000000..2b568a49 --- /dev/null +++ b/app/queries/setup.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import api from "~/services/api"; +import { queryKeys } from "./keys"; + +export function useSetupStatus() { + return useQuery({ + queryKey: queryKeys.setupStatus, + queryFn: () => api.getSetupStatus(), + staleTime: 0, + }); +} diff --git a/app/root.tsx b/app/root.tsx index 52775738..033a9452 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -21,8 +21,10 @@ import { Link as RouterLink, Scripts, ScrollRestoration, + useLocation, } from "react-router"; import { ApiError } from "~/services/api"; +import { useSetupStatus } from "~/queries/setup"; import "./index.css"; function makeQueryClient() { @@ -109,6 +111,30 @@ export function HydrateFallback() { ); } +function SetupBanner() { + const location = useLocation(); + const { data } = useSetupStatus(); + + if (!data || data.isComplete || location.pathname === "/setup") return null; + + const requiredIncomplete = data.steps.filter((s) => s.required && s.status !== "complete"); + if (requiredIncomplete.length === 0) return null; + + return ( +
+
+ + + Setup incomplete — {requiredIncomplete.length} required step{requiredIncomplete.length > 1 ? "s" : ""} remaining + + + Finish setup + +
+
+ ); +} + export default function App() { // Use useState to ensure each SSR request gets a fresh client while the // browser reuses the same singleton across navigations. @@ -118,6 +144,7 @@ export default function App() { + @@ -130,6 +157,7 @@ export function ErrorBoundary({ error }: { error: unknown }) { let title = "Something went wrong"; let description = "An unexpected error occurred. Please try again."; let status: number | null = null; + let isSetupError = false; if (isRouteErrorResponse(error)) { status = error.status; @@ -141,6 +169,15 @@ export function ErrorBoundary({ error }: { error: unknown }) { title = `Error ${error.status}`; description = error.statusText || description; } + } else if (error instanceof ApiError) { + status = error.status; + title = `Error ${error.status}`; + description = error.message; + if (error.body?.code === "ACCESS_NOT_CONFIGURED" || error.body?.code === "ACCESS_TOKEN_MISSING" || error.body?.code === "ACCESS_TOKEN_INVALID") { + isSetupError = true; + title = "Configuration required"; + description = error.body.error as string || "Cloudflare Access is not configured."; + } } else if (error instanceof Error && import.meta.env.DEV) { description = error.message; } @@ -152,14 +189,23 @@ export function ErrorBoundary({ error }: { error: unknown }) { title={status === 404 ? "404 — Page not found" : title} description={description} contents={ - +
+ {isSetupError && ( + + + + )} + +
} /> diff --git a/app/routes.ts b/app/routes.ts index 5d0ebd61..46375b38 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -10,6 +10,7 @@ import { export default [ index("routes/home.tsx"), + route("setup", "routes/setup.tsx"), route("mailbox/:mailboxId", "routes/mailbox.tsx", [ index("routes/mailbox-index.tsx"), route("emails/:folder", "routes/email-list.tsx"), diff --git a/app/routes/home.tsx b/app/routes/home.tsx index fe221d64..5a3daadf 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -12,7 +12,7 @@ import { Text, useKumoToastManager, } from "@cloudflare/kumo"; -import { EnvelopeIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react"; +import { EnvelopeIcon, GearIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { type FormEvent, useEffect, useRef, useState } from "react"; import { Link as RouterLink } from "react-router"; @@ -145,15 +145,22 @@ export default function HomeRoute() {

Mailboxes

- {!isConfigured && ( - - )} +
+ + + + {!isConfigured && ( + + )} +
{domains.length > 0 && (

@@ -222,19 +229,30 @@ export default function HomeRoute() { No mailboxes yet

- {isConfigured - ? "Your email routing is configured but no mailboxes have been created yet. They will appear here automatically." - : "Create a mailbox to start sending and receiving emails with your domain."} + {domains.length === 0 + ? "No domains are configured. Set up your domain to start sending and receiving emails." + : isConfigured + ? "Your email routing is configured but no mailboxes have been created yet. They will appear here automatically." + : "Create a mailbox to start sending and receiving emails with your domain."}

- {!isConfigured && ( - - )} +
+ {domains.length === 0 && ( + + + + )} + {!isConfigured && domains.length > 0 && ( + + )} +
)} @@ -252,70 +270,85 @@ export default function HomeRoute() { {createError} )} -
- - Email Address - -
-
- setNewPrefix(e.target.value)} - required - /> -
- @ - {domains.length > 1 ? ( -
- -
- ) : ( - - {selectedDomain || "no domain"} - - )} + {domains.length === 0 ? ( +
+

+ No domains configured. Set up your domain first. +

+ + +
-
- setNewName(e.target.value)} - /> -
- ( - + )} + /> + - )} - /> - -
+
+ + )} diff --git a/app/routes/setup.tsx b/app/routes/setup.tsx new file mode 100644 index 00000000..757e644f --- /dev/null +++ b/app/routes/setup.tsx @@ -0,0 +1,157 @@ +import { + Button, + Empty, + Loader, +} from "@cloudflare/kumo"; +import { + CheckCircleIcon, + GearIcon, + WarningCircleIcon, +} from "@phosphor-icons/react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Link as RouterLink } from "react-router"; +import { useSetupStatus } from "~/queries/setup"; +import { queryKeys } from "~/queries/keys"; + +export function meta() { + return [{ title: "Setup — Agentic Inbox" }]; +} + +function StepIcon({ status }: { status: "complete" | "incomplete" | "error" }) { + if (status === "complete") { + return ; + } + if (status === "error") { + return ; + } + return ; +} + +export default function SetupRoute() { + const { data, isLoading, refetch, isRefetching } = useSetupStatus(); + const queryClient = useQueryClient(); + + const handleRecheck = async () => { + await refetch(); + queryClient.invalidateQueries({ queryKey: queryKeys.config }); + queryClient.invalidateQueries({ queryKey: queryKeys.mailboxes.all }); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const steps = data?.steps ?? []; + const isComplete = data?.isComplete ?? false; + const requiredIncomplete = steps.filter((s) => s.required && s.status !== "complete"); + const optionalIncomplete = steps.filter((s) => !s.required && s.status !== "complete"); + + return ( +
+
+
+
+ +

Setup

+
+

+ Complete these steps to get your Agentic Inbox running. +

+
+ + {isComplete && ( +
+ +
+

All set!

+

Your Agentic Inbox is fully configured and ready to use.

+
+
+ )} + + {requiredIncomplete.length > 0 && ( +
+ +
+

Setup incomplete

+

{requiredIncomplete.length} required step{requiredIncomplete.length > 1 ? "s" : ""} still need{requiredIncomplete.length === 1 ? "s" : ""} attention.

+
+
+ )} + + {steps.length > 0 && ( +
+ {steps.map((step, idx) => ( +
0 ? "border-t border-kumo-line" : ""}`} + > +
+ +
+
+ + {step.label} + + {step.required ? ( + + Required + + ) : ( + + Optional + + )} +
+ {step.detail && ( +

+ {step.detail} +

+ )} +
+
+
+ ))} +
+ )} + + {steps.length === 0 && ( + } + title="Could not load setup status" + description="Try refreshing the page." + /> + )} + +
+ + + + +
+ + {optionalIncomplete.length > 0 && isComplete && ( +
+

Optional steps

+

+ {optionalIncomplete.length} optional step{optionalIncomplete.length > 1 ? "s" : ""} {optionalIncomplete.length > 1 ? "remain" : "remains"}. + These are not required but will enhance your experience. +

+
+ )} +
+
+ ); +} diff --git a/app/services/api.ts b/app/services/api.ts index 25160673..3a15515d 100644 --- a/app/services/api.ts +++ b/app/services/api.ts @@ -94,11 +94,28 @@ interface EmailListResponse { // ---------- API client ---------- +export interface SetupStep { + id: string; + label: string; + status: "complete" | "incomplete" | "error"; + detail?: string; + required: boolean; +} + +export interface SetupStatus { + isComplete: boolean; + steps: SetupStep[]; +} + const api = { // Config getConfig: () => get<{ domains: string[]; emailAddresses: string[] }>("/api/v1/config"), + // Setup + getSetupStatus: () => + get("/api/v1/setup-status"), + // Mailboxes listMailboxes: () => get("/api/v1/mailboxes"), createMailbox: (email: string, name: string, settings?: unknown) => diff --git a/package-lock.json b/package-lock.json index 451dd71a..e59e8ee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,24 +70,6 @@ "zod": "^3.25.76 || ^4.1.8" } }, - "node_modules/@ai-sdk/openai": { - "version": "3.0.41", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.41.tgz", - "integrity": "sha512-IZ42A+FO+vuEQCVNqlnAPYQnnUpUfdJIwn1BEDOBywiEHa23fw7PahxVtlX9zm3/zMvTW4JKPzWyvAgDu+SQ2A==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.19" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4.1.8" - } - }, "node_modules/@ai-sdk/provider": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", @@ -5069,21 +5051,6 @@ "node": ">= 0.4" } }, - "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/globrex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", @@ -7652,18 +7619,6 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -8248,540 +8203,6 @@ "license": "0BSD", "peer": true }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" - } - }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -9820,24 +9241,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", diff --git a/workers/app.ts b/workers/app.ts index 607525f7..75fc2cc7 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -8,6 +8,7 @@ import { jwtVerify, createRemoteJWKSet } from "jose"; import { createRequestHandler } from "react-router"; import { app as apiApp, receiveEmail } from "./index"; import { EmailMCP } from "./mcp"; +import { renderAccessNotConfiguredPage, renderAccessDeniedPage } from "./lib/setup-page"; import type { Env } from "./types"; export { MailboxDO } from "./durableObject"; @@ -44,39 +45,50 @@ 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. + // Access not configured — serve a setup instructions page. if (!POLICY_AUD || !TEAM_DOMAIN) { - return c.text( - "Cloudflare Access must be configured in production. Set POLICY_AUD and TEAM_DOMAIN.", - 500, - ); + return c.html(renderAccessNotConfiguredPage(), 500); } + const isApiRequest = c.req.path.startsWith("/api/"); + const isBrowserRequest = !isApiRequest && ( + c.req.header("sec-fetch-mode") === "navigate" || + c.req.header("accept")?.includes("text/html") === true + ); + const token = c.req.header("cf-access-jwt-assertion"); if (!token) { - return c.text("Missing required CF Access JWT", 403); + if (isBrowserRequest) { + const returnUrl = c.req.url; + const { issuer } = getAccessUrls(TEAM_DOMAIN); + return c.html(renderAccessDeniedPage(issuer, returnUrl), 401); + } + return c.json({ error: "Missing required CF Access JWT", code: "ACCESS_TOKEN_MISSING" }, 401, { + "WWW-Authenticate": `Bearer realm="Cloudflare Access", resource="${c.req.url}"`, + }); } try { const { issuer, certsUrl } = getAccessUrls(TEAM_DOMAIN); const JWKS = createRemoteJWKSet(certsUrl); - await jwtVerify(token, JWKS, { - issuer, - audience: POLICY_AUD, - }); + await jwtVerify(token, JWKS, { issuer, audience: POLICY_AUD }); } catch { - return c.text("Invalid or expired Access token", 403); + if (isBrowserRequest) { + const returnUrl = c.req.url; + const { issuer } = getAccessUrls(TEAM_DOMAIN); + return c.html(renderAccessDeniedPage(issuer, returnUrl), 401); + } + return c.json({ error: "Invalid or expired Access token", code: "ACCESS_TOKEN_INVALID" }, 401, { + "WWW-Authenticate": `Bearer realm="Cloudflare Access", resource="${c.req.url}"`, + }); } - // Authorization model note: once a teammate passes the shared Cloudflare - // Access policy, they can access all mailboxes in this app by design. return next(); }); diff --git a/workers/index.ts b/workers/index.ts index fd3359ce..080dd918 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -83,6 +83,84 @@ app.use("/api/*", cors({ })); app.use("/api/v1/mailboxes/:mailboxId/*", requireMailbox); +// -- Setup Status --------------------------------------------------- + +app.get("/api/v1/setup-status", async (c) => { + const isDev = import.meta.env.DEV; + const steps: Array<{ + id: string; + label: string; + status: "complete" | "incomplete" | "error"; + detail?: string; + required: boolean; + }> = []; + + // 1. R2 Bucket + try { + await c.env.BUCKET.list({ prefix: "mailboxes/", limit: 1 }); + steps.push({ id: "r2", label: "R2 Bucket", status: "complete", required: true }); + } catch (e) { + steps.push({ + id: "r2", label: "R2 Bucket", status: "error", + detail: "R2 bucket is not accessible. Create it with: wrangler r2 bucket create agentic-inbox", + required: true, + }); + } + + // 2. Domains + const domainsRaw = c.env.DOMAINS || ""; + const domains = domainsRaw.split(",").map((d) => d.trim()).filter(Boolean); + if (domains.length > 0 && !domains.includes("example.com")) { + steps.push({ id: "domains", label: "Domain Configuration", status: "complete", required: true }); + } else { + steps.push({ + id: "domains", label: "Domain Configuration", status: "incomplete", + detail: 'Set DOMAINS in wrangler.jsonc to your domain (e.g. "mydomain.com"). Currently set to: ' + (domainsRaw || "(empty)"), + required: true, + }); + } + + // 3. Cloudflare Access (production only) + if (isDev) { + steps.push({ id: "access", label: "Cloudflare Access", status: "complete", detail: "Skipped in development mode", required: false }); + } else if (c.env.POLICY_AUD && c.env.TEAM_DOMAIN) { + steps.push({ id: "access", label: "Cloudflare Access", status: "complete", required: true }); + } else { + steps.push({ + id: "access", label: "Cloudflare Access", status: "incomplete", + detail: "Set POLICY_AUD and TEAM_DOMAIN as worker secrets: wrangler secret put POLICY_AUD && wrangler secret put TEAM_DOMAIN", + required: true, + }); + } + + // 4. Email Routing (manual — cannot check programmatically) + const emailAddresses = (c.env.EMAIL_ADDRESSES ?? []) as string[]; + if (emailAddresses.length > 0) { + steps.push({ id: "emailRouting", label: "Email Routing", status: "complete", detail: "EMAIL_ADDRESSES is configured, email routing should be active", required: false }); + } else { + steps.push({ + id: "emailRouting", label: "Email Routing", status: "incomplete", + detail: "In the Cloudflare dashboard, go to your domain > Email Routing and create a catch-all rule that forwards to this Worker. Optionally set EMAIL_ADDRESSES in wrangler.jsonc.", + required: false, + }); + } + + // 5. Mailbox exists + const allMailboxes = await listMailboxes(c.env.BUCKET); + if (allMailboxes.length > 0) { + steps.push({ id: "mailbox", label: "First Mailbox", status: "complete", required: false }); + } else { + steps.push({ + id: "mailbox", label: "First Mailbox", status: "incomplete", + detail: "Create a mailbox from the app home page or set EMAIL_ADDRESSES in wrangler.jsonc to auto-create one.", + required: false, + }); + } + + const isComplete = steps.every((s) => s.status === "complete" || !s.required); + return c.json({ isComplete, steps }); +}); + // -- Config --------------------------------------------------------- app.get("/api/v1/config", (c) => { diff --git a/workers/lib/setup-page.ts b/workers/lib/setup-page.ts new file mode 100644 index 00000000..7ca0ae3e --- /dev/null +++ b/workers/lib/setup-page.ts @@ -0,0 +1,279 @@ +export function renderAccessNotConfiguredPage(): string { + return ` + + + + + Setup Required — Agentic Inbox + + + +
+
+
+ + + + + +

Cloudflare Access is not configured

+
+

+ This app requires Cloudflare Access for authentication in production. + Follow the steps below to complete setup. +

+
    +
  1. + Enable Cloudflare Access — go to the Workers & Pages dashboard, select your Worker, then navigate to Settings → Domains & Routes and click Enable Cloudflare Access. +
  2. +
  3. + Configure the Access credentials — after enabling Access, a modal will appear with your POLICY_AUD (Application Audience Tag) and TEAM_DOMAIN values. Copy both values, then go to your Worker's Settings → Variables and Secrets and add them as encrypted secrets: +
      +
    • POLICY_AUD — the Application Audience Tag shown in the modal
    • +
    • TEAM_DOMAIN — your Access team URL (e.g. https://myteam.cloudflareaccess.com) or the full certs URL
    • +
    +
  4. +
  5. + Reload this page after the secrets are saved. +
  6. +
+ +
+

Agentic Inbox

+
+ +`; +} + +export function renderAccessDeniedPage( + teamDomain: string, + returnUrl: string, +): string { + const loginUrl = `${teamDomain}/cdn-cgi/access/login?${encodeURIComponent(returnUrl)}`; + return ` + + + + + Authentication Required — Agentic Inbox + + + +
+
+
+ + + + + +
+

Authentication required

+

+ You need to sign in with Cloudflare Access to use this app. +

+ + Sign in + +
+

Agentic Inbox

+
+ +`; +} From ed08d1931d314cd07c0a400aefc0a5b8328a23ca Mon Sep 17 00:00:00 2001 From: Harshil Agrawal Date: Sat, 18 Apr 2026 00:25:45 +0200 Subject: [PATCH 2/3] add doc links and improve copy --- app/root.tsx | 2 +- app/routes/setup.tsx | 26 +- app/services/api.ts | 3 +- workers/app.ts | 31 +- workers/index.ts | 1077 ++++++++++++++++++++++++------------- workers/lib/setup-page.ts | 124 +---- 6 files changed, 755 insertions(+), 508 deletions(-) diff --git a/app/root.tsx b/app/root.tsx index 033a9452..26c6836a 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -117,7 +117,7 @@ function SetupBanner() { if (!data || data.isComplete || location.pathname === "/setup") return null; - const requiredIncomplete = data.steps.filter((s) => s.required && s.status !== "complete"); + const requiredIncomplete = data.steps.filter((s) => s.required && s.status !== "complete" && s.status !== "info"); if (requiredIncomplete.length === 0) return null; return ( diff --git a/app/routes/setup.tsx b/app/routes/setup.tsx index 757e644f..47944a74 100644 --- a/app/routes/setup.tsx +++ b/app/routes/setup.tsx @@ -4,8 +4,10 @@ import { Loader, } from "@cloudflare/kumo"; import { + ArrowSquareOutIcon, CheckCircleIcon, GearIcon, + InfoIcon, WarningCircleIcon, } from "@phosphor-icons/react"; import { useQueryClient } from "@tanstack/react-query"; @@ -17,13 +19,16 @@ export function meta() { return [{ title: "Setup — Agentic Inbox" }]; } -function StepIcon({ status }: { status: "complete" | "incomplete" | "error" }) { +function StepIcon({ status }: { status: "complete" | "incomplete" | "error" | "info" }) { if (status === "complete") { return ; } if (status === "error") { return ; } + if (status === "info") { + return ; + } return ; } @@ -47,7 +52,7 @@ export default function SetupRoute() { const steps = data?.steps ?? []; const isComplete = data?.isComplete ?? false; - const requiredIncomplete = steps.filter((s) => s.required && s.status !== "complete"); + const requiredIncomplete = steps.filter((s) => s.required && s.status !== "complete" && s.status !== "info"); const optionalIncomplete = steps.filter((s) => !s.required && s.status !== "complete"); return ( @@ -97,7 +102,11 @@ export default function SetupRoute() { {step.label} - {step.required ? ( + {step.status === "info" ? ( + + Action needed + + ) : step.required ? ( Required @@ -112,6 +121,17 @@ export default function SetupRoute() { {step.detail}

)} + {step.docsUrl && ( + + View docs + + + )} diff --git a/app/services/api.ts b/app/services/api.ts index 3a15515d..8fc3e8f3 100644 --- a/app/services/api.ts +++ b/app/services/api.ts @@ -97,8 +97,9 @@ interface EmailListResponse { export interface SetupStep { id: string; label: string; - status: "complete" | "incomplete" | "error"; + status: "complete" | "incomplete" | "error" | "info"; detail?: string; + docsUrl?: string; required: boolean; } diff --git a/workers/app.ts b/workers/app.ts index 75fc2cc7..429ea303 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -8,7 +8,7 @@ import { jwtVerify, createRemoteJWKSet } from "jose"; import { createRequestHandler } from "react-router"; import { app as apiApp, receiveEmail } from "./index"; import { EmailMCP } from "./mcp"; -import { renderAccessNotConfiguredPage, renderAccessDeniedPage } from "./lib/setup-page"; +import { renderAccessNotConfiguredPage } from "./lib/setup-page"; import type { Env } from "./types"; export { MailboxDO } from "./durableObject"; @@ -46,6 +46,9 @@ const app = new Hono<{ Bindings: Env }>(); // Cloudflare Access JWT validation middleware (production only) app.use("*", async (c, next) => { if (import.meta.env.DEV) { + // TODO: remove this preview flag after testing the Access setup page + const preview = c.req.query("__access_error"); + if (preview === "not-configured") return c.html(renderAccessNotConfiguredPage(), 500); return next(); } @@ -56,22 +59,9 @@ app.use("*", async (c, next) => { return c.html(renderAccessNotConfiguredPage(), 500); } - const isApiRequest = c.req.path.startsWith("/api/"); - const isBrowserRequest = !isApiRequest && ( - c.req.header("sec-fetch-mode") === "navigate" || - c.req.header("accept")?.includes("text/html") === true - ); - const token = c.req.header("cf-access-jwt-assertion"); if (!token) { - if (isBrowserRequest) { - const returnUrl = c.req.url; - const { issuer } = getAccessUrls(TEAM_DOMAIN); - return c.html(renderAccessDeniedPage(issuer, returnUrl), 401); - } - return c.json({ error: "Missing required CF Access JWT", code: "ACCESS_TOKEN_MISSING" }, 401, { - "WWW-Authenticate": `Bearer realm="Cloudflare Access", resource="${c.req.url}"`, - }); + return c.text("Missing required CF Access JWT", 403); } try { @@ -79,16 +69,11 @@ app.use("*", async (c, next) => { const JWKS = createRemoteJWKSet(certsUrl); await jwtVerify(token, JWKS, { issuer, audience: POLICY_AUD }); } catch { - if (isBrowserRequest) { - const returnUrl = c.req.url; - const { issuer } = getAccessUrls(TEAM_DOMAIN); - return c.html(renderAccessDeniedPage(issuer, returnUrl), 401); - } - return c.json({ error: "Invalid or expired Access token", code: "ACCESS_TOKEN_INVALID" }, 401, { - "WWW-Authenticate": `Bearer realm="Cloudflare Access", resource="${c.req.url}"`, - }); + return c.text("Invalid or expired Access token", 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(); }); diff --git a/workers/index.ts b/workers/index.ts index 080dd918..71214217 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -9,11 +9,11 @@ import { z } from "zod"; import { sendEmail } from "./email-sender"; import { storeAttachments, type StoredAttachment } from "./lib/attachments"; import { - validateSender, - SenderValidationError, - generateMessageId, - buildThreadingHeaders, - listMailboxes, + validateSender, + SenderValidationError, + generateMessageId, + buildThreadingHeaders, + listMailboxes, } from "./lib/email-helpers"; import { SendEmailRequestSchema } from "./lib/schemas"; import { handleReplyEmail, handleForwardEmail } from "./routes/reply-forward"; @@ -26,323 +26,562 @@ 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 + 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(), + 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 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; + 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"; + const v = c.req.query(key); + if (v === undefined || v === "") return undefined; + return v === "true" || v === "1"; } // -- App & middleware ----------------------------------------------- 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/*", + 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); // -- Setup Status --------------------------------------------------- app.get("/api/v1/setup-status", async (c) => { - const isDev = import.meta.env.DEV; - const steps: Array<{ - id: string; - label: string; - status: "complete" | "incomplete" | "error"; - detail?: string; - required: boolean; - }> = []; - - // 1. R2 Bucket - try { - await c.env.BUCKET.list({ prefix: "mailboxes/", limit: 1 }); - steps.push({ id: "r2", label: "R2 Bucket", status: "complete", required: true }); - } catch (e) { - steps.push({ - id: "r2", label: "R2 Bucket", status: "error", - detail: "R2 bucket is not accessible. Create it with: wrangler r2 bucket create agentic-inbox", - required: true, - }); - } - - // 2. Domains - const domainsRaw = c.env.DOMAINS || ""; - const domains = domainsRaw.split(",").map((d) => d.trim()).filter(Boolean); - if (domains.length > 0 && !domains.includes("example.com")) { - steps.push({ id: "domains", label: "Domain Configuration", status: "complete", required: true }); - } else { - steps.push({ - id: "domains", label: "Domain Configuration", status: "incomplete", - detail: 'Set DOMAINS in wrangler.jsonc to your domain (e.g. "mydomain.com"). Currently set to: ' + (domainsRaw || "(empty)"), - required: true, - }); - } - - // 3. Cloudflare Access (production only) - if (isDev) { - steps.push({ id: "access", label: "Cloudflare Access", status: "complete", detail: "Skipped in development mode", required: false }); - } else if (c.env.POLICY_AUD && c.env.TEAM_DOMAIN) { - steps.push({ id: "access", label: "Cloudflare Access", status: "complete", required: true }); - } else { - steps.push({ - id: "access", label: "Cloudflare Access", status: "incomplete", - detail: "Set POLICY_AUD and TEAM_DOMAIN as worker secrets: wrangler secret put POLICY_AUD && wrangler secret put TEAM_DOMAIN", - required: true, - }); - } - - // 4. Email Routing (manual — cannot check programmatically) - const emailAddresses = (c.env.EMAIL_ADDRESSES ?? []) as string[]; - if (emailAddresses.length > 0) { - steps.push({ id: "emailRouting", label: "Email Routing", status: "complete", detail: "EMAIL_ADDRESSES is configured, email routing should be active", required: false }); - } else { - steps.push({ - id: "emailRouting", label: "Email Routing", status: "incomplete", - detail: "In the Cloudflare dashboard, go to your domain > Email Routing and create a catch-all rule that forwards to this Worker. Optionally set EMAIL_ADDRESSES in wrangler.jsonc.", - required: false, - }); - } - - // 5. Mailbox exists - const allMailboxes = await listMailboxes(c.env.BUCKET); - if (allMailboxes.length > 0) { - steps.push({ id: "mailbox", label: "First Mailbox", status: "complete", required: false }); - } else { - steps.push({ - id: "mailbox", label: "First Mailbox", status: "incomplete", - detail: "Create a mailbox from the app home page or set EMAIL_ADDRESSES in wrangler.jsonc to auto-create one.", - required: false, - }); - } - - const isComplete = steps.every((s) => s.status === "complete" || !s.required); - return c.json({ isComplete, steps }); + const isDev = import.meta.env.DEV; + const steps: Array<{ + id: string; + label: string; + status: "complete" | "incomplete" | "error" | "info"; + detail?: string; + docsUrl?: string; + required: boolean; + }> = []; + + // 1. R2 Bucket + try { + await c.env.BUCKET.list({ prefix: "mailboxes/", limit: 1 }); + steps.push({ + id: "r2", + label: "R2 Bucket", + status: "complete", + required: true, + docsUrl: "https://developers.cloudflare.com/r2/buckets/create-buckets/", + }); + } catch (e) { + steps.push({ + id: "r2", + label: "R2 Bucket", + status: "error", + detail: + 'R2 bucket is not accessible. Go to your Cloudflare dashboard > R2 Object Storage and create a bucket named "agentic-inbox".', + required: true, + docsUrl: "https://developers.cloudflare.com/r2/buckets/create-buckets/", + }); + } + + // 2. Domains + const domainsRaw = c.env.DOMAINS || ""; + const domains = domainsRaw + .split(",") + .map((d) => d.trim()) + .filter(Boolean); + if (domains.length > 0 && !domains.includes("example.com")) { + steps.push({ + id: "domains", + label: "Domain Configuration", + status: "complete", + required: true, + docsUrl: + "https://developers.cloudflare.com/workers/configuration/environment-variables/", + }); + } else { + steps.push({ + id: "domains", + label: "Domain Configuration", + status: "incomplete", + detail: + 'Set the DOMAINS variable to your domain (e.g. "mydomain.com"). Go to your Worker\'s Settings > Variables and Secrets and add or update the DOMAINS variable. Currently set to: ' + + (domainsRaw || "(empty)"), + required: true, + docsUrl: + "https://developers.cloudflare.com/workers/configuration/environment-variables/", + }); + } + + // 3. Cloudflare Access (production only) + const accessDocsUrl = + "https://developers.cloudflare.com/workers/configuration/routing/workers-dev/#manage-access-to-workersdev"; + if (isDev) { + steps.push({ + id: "access", + label: "Cloudflare Access", + status: "complete", + detail: "Skipped in development mode", + required: false, + docsUrl: accessDocsUrl, + }); + } else if (c.env.POLICY_AUD && c.env.TEAM_DOMAIN) { + steps.push({ + id: "access", + label: "Cloudflare Access", + status: "complete", + required: true, + docsUrl: accessDocsUrl, + }); + } else { + steps.push({ + id: "access", + label: "Cloudflare Access", + status: "incomplete", + detail: + "Set POLICY_AUD and TEAM_DOMAIN as encrypted secrets. Go to your Worker's Settings > Variables and Secrets and add both values. You can find them by enabling Cloudflare Access on your Worker's Settings > Domains & Routes page.", + required: true, + docsUrl: accessDocsUrl, + }); + } + + // 4. Email Routing (manual — cannot check programmatically) + const emailRoutingDocsUrl = + "https://developers.cloudflare.com/email-service/get-started/route-emails/"; + const emailAddresses = (c.env.EMAIL_ADDRESSES ?? []) as string[]; + if (emailAddresses.length > 0) { + steps.push({ + id: "emailRouting", + label: "Email Routing", + status: "complete", + detail: "EMAIL_ADDRESSES is configured, email routing should be active.", + required: true, + docsUrl: emailRoutingDocsUrl, + }); + } else { + steps.push({ + id: "emailRouting", + label: "Email Routing", + status: "incomplete", + detail: + "Go to your Cloudflare dashboard > Compute > Email Service > Email Routing and create a catch-all or routing rule that forwards to this Worker.", + required: true, + docsUrl: emailRoutingDocsUrl, + }); + } + + // 5. Email Sending (required but unverifiable — show as info) + steps.push({ + id: "emailSending", + label: "Email Sending", + status: "info", + detail: + "To send outbound email, you must configure a verified sender domain. Go to your Cloudflare dashboard > Compute > Email Service > Email Sending and add your domain as a verified sender address.", + required: true, + docsUrl: + "https://developers.cloudflare.com/email-service/get-started/send-emails/", + }); + + // 6. Mailbox exists + const allMailboxes = await listMailboxes(c.env.BUCKET); + if (allMailboxes.length > 0) { + steps.push({ + id: "mailbox", + label: "First Mailbox", + status: "complete", + required: false, + }); + } else { + steps.push({ + id: "mailbox", + label: "First Mailbox", + status: "incomplete", + detail: + "Create a mailbox from the app home page or set EMAIL_ADDRESSES in your Worker's Settings > Variables and Secrets to auto-create one.", + required: false, + }); + } + + const isComplete = steps.every( + (s) => s.status === "complete" || s.status === "info" || !s.required, + ); + return c.json({ isComplete, steps }); }); // -- 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 }); + 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 }))); + 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); + 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() }); + 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 }); + 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); + 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); + 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); + 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); + 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" }, - }); + 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); + 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); + 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); -}); +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" }); -}); +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 ------------------------------------------------ @@ -351,140 +590,252 @@ 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.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); + 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); + 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); -}); +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 }); + 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 }); -}); +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; + 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))); +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), + ), + ); } export { app, receiveEmail }; diff --git a/workers/lib/setup-page.ts b/workers/lib/setup-page.ts index 7ca0ae3e..9bd4d6d7 100644 --- a/workers/lib/setup-page.ts +++ b/workers/lib/setup-page.ts @@ -53,12 +53,7 @@ export function renderAccessNotConfiguredPage(): string { line-height: 1.5; margin-bottom: 1.5rem; } - .steps { - list-style: none; - counter-reset: step; - } .steps li { - counter-increment: step; padding: 0.875rem 0; border-top: 1px solid #f0f0f0; font-size: 0.875rem; @@ -70,7 +65,6 @@ export function renderAccessNotConfiguredPage(): string { padding-top: 0; } .steps li::before { - content: counter(step); display: inline-flex; align-items: center; justify-content: center; @@ -116,6 +110,7 @@ export function renderAccessNotConfiguredPage(): string { display: flex; align-items: center; gap: 0.75rem; + justify-content: center; } .brand { text-align: center; @@ -128,20 +123,22 @@ export function renderAccessNotConfiguredPage(): string {
+
+

Cloudflare Access is not configured

-
+

This app requires Cloudflare Access for authentication in production. Follow the steps below to complete setup.

    -
  1. +
  2. Enable Cloudflare Access — go to the Workers & Pages dashboard, select your Worker, then navigate to Settings → Domains & Routes and click Enable Cloudflare Access.
  3. @@ -161,116 +158,9 @@ export function renderAccessNotConfiguredPage(): string { Reload
- -

Agentic Inbox

- - -`; -} - -export function renderAccessDeniedPage( - teamDomain: string, - returnUrl: string, -): string { - const loginUrl = `${teamDomain}/cdn-cgi/access/login?${encodeURIComponent(returnUrl)}`; - return ` - - - - - Authentication Required — Agentic Inbox - - - -
-
-
- - - - - -
-

Authentication required

-

- You need to sign in with Cloudflare Access to use this app. +

+ For more help, see the Cloudflare Access documentation.

- - Sign in -

Agentic Inbox

From cc03254f4887051f41dde6de1d04dab2a69e2c37 Mon Sep 17 00:00:00 2001 From: Harshil Agrawal Date: Fri, 24 Apr 2026 16:43:52 +0200 Subject: [PATCH 3/3] address PR feedback for onboarding --- app/components/SetupBanner.tsx | 27 ++++++ app/root.tsx | 110 ++++++++++++--------- app/routes/setup.tsx | 12 ++- app/services/api.ts | 16 +--- app/types/index.ts | 14 +++ workers/app.ts | 28 ++++-- workers/index.ts | 35 +++---- workers/lib/setup-page.ts | 169 --------------------------------- 8 files changed, 148 insertions(+), 263 deletions(-) create mode 100644 app/components/SetupBanner.tsx delete mode 100644 workers/lib/setup-page.ts diff --git a/app/components/SetupBanner.tsx b/app/components/SetupBanner.tsx new file mode 100644 index 00000000..d5a6a670 --- /dev/null +++ b/app/components/SetupBanner.tsx @@ -0,0 +1,27 @@ +import { WarningIcon } from "@phosphor-icons/react"; +import { Link as RouterLink, useLocation } from "react-router"; +import { useSetupStatus } from "~/queries/setup"; + +export function SetupBanner() { + const location = useLocation(); + const { data } = useSetupStatus(); + + if (!data || data.isComplete || location.pathname === "/setup") return null; + + const requiredIncomplete = data.steps.filter((s) => s.required && s.status !== "complete" && s.status !== "info"); + if (requiredIncomplete.length === 0) return null; + + return ( +
+
+ + + Setup incomplete — {requiredIncomplete.length} required step{requiredIncomplete.length > 1 ? "s" : ""} remaining + + + Finish setup + +
+
+ ); +} diff --git a/app/root.tsx b/app/root.tsx index 26c6836a..77596efd 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -12,7 +12,7 @@ import { } from "@cloudflare/kumo"; import { WarningIcon } from "@phosphor-icons/react"; import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { forwardRef, useState } from "react"; +import { forwardRef, useEffect, useState } from "react"; import { isRouteErrorResponse, Links, @@ -22,7 +22,9 @@ import { Scripts, ScrollRestoration, useLocation, + useNavigate, } from "react-router"; +import { SetupBanner } from "~/components/SetupBanner"; import { ApiError } from "~/services/api"; import { useSetupStatus } from "~/queries/setup"; import "./index.css"; @@ -111,41 +113,74 @@ export function HydrateFallback() { ); } -function SetupBanner() { +function AuthGuard({ children }: { children: React.ReactNode }) { const location = useLocation(); - const { data } = useSetupStatus(); + const navigate = useNavigate(); + const { data: setupData, isLoading: setupLoading, isError: setupError } = useSetupStatus(); + const isBrowser = typeof window !== "undefined"; - if (!data || data.isComplete || location.pathname === "/setup") return null; + useEffect(() => { + if (!isBrowser) return; + if (setupLoading) return; + if (location.pathname === "/setup") return; + if (setupError || !setupData) return; - const requiredIncomplete = data.steps.filter((s) => s.required && s.status !== "complete" && s.status !== "info"); - if (requiredIncomplete.length === 0) return null; + const accessStep = setupData.steps.find((s) => s.id === "access"); + if (accessStep?.status !== "complete") { + navigate("/setup", { replace: true }); + } + }, [isBrowser, setupLoading, setupError, setupData, location.pathname, navigate]); - return ( -
-
- - - Setup incomplete — {requiredIncomplete.length} required step{requiredIncomplete.length > 1 ? "s" : ""} remaining - - - Finish setup - + const showLoading = isBrowser && setupLoading && location.pathname !== "/setup"; + const showError = isBrowser && setupError && location.pathname !== "/setup"; + + if (showLoading) { + return ( +
+
-
- ); + ); + } + + if (showError) { + return ( +
+ } + title="Unable to verify access" + description="Could not load setup status. Please refresh the page." + contents={ + + } + /> +
+ ); + } + + return children; } export default function App() { // Use useState to ensure each SSR request gets a fresh client while the // browser reuses the same singleton across navigations. const [queryClient] = useState(getQueryClient); + return ( - - + + + + @@ -157,7 +192,6 @@ export function ErrorBoundary({ error }: { error: unknown }) { let title = "Something went wrong"; let description = "An unexpected error occurred. Please try again."; let status: number | null = null; - let isSetupError = false; if (isRouteErrorResponse(error)) { status = error.status; @@ -169,15 +203,6 @@ export function ErrorBoundary({ error }: { error: unknown }) { title = `Error ${error.status}`; description = error.statusText || description; } - } else if (error instanceof ApiError) { - status = error.status; - title = `Error ${error.status}`; - description = error.message; - if (error.body?.code === "ACCESS_NOT_CONFIGURED" || error.body?.code === "ACCESS_TOKEN_MISSING" || error.body?.code === "ACCESS_TOKEN_INVALID") { - isSetupError = true; - title = "Configuration required"; - description = error.body.error as string || "Cloudflare Access is not configured."; - } } else if (error instanceof Error && import.meta.env.DEV) { description = error.message; } @@ -189,23 +214,14 @@ export function ErrorBoundary({ error }: { error: unknown }) { title={status === 404 ? "404 — Page not found" : title} description={description} contents={ -
- {isSetupError && ( - - - - )} - -
+ } />
diff --git a/app/routes/setup.tsx b/app/routes/setup.tsx index 47944a74..bc6a8ae9 100644 --- a/app/routes/setup.tsx +++ b/app/routes/setup.tsx @@ -155,11 +155,13 @@ export default function SetupRoute() { > Recheck - - - + {isComplete && ( + + + + )} {optionalIncomplete.length > 0 && isComplete && ( diff --git a/app/services/api.ts b/app/services/api.ts index 8fc3e8f3..a05ed4d2 100644 --- a/app/services/api.ts +++ b/app/services/api.ts @@ -2,7 +2,7 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -import type { Email, Folder, Mailbox } from "~/types"; +import type { Email, Folder, Mailbox, SetupStep, SetupStatus } from "~/types"; const REQUEST_TIMEOUT_MS = 30_000; @@ -94,19 +94,7 @@ interface EmailListResponse { // ---------- API client ---------- -export interface SetupStep { - id: string; - label: string; - status: "complete" | "incomplete" | "error" | "info"; - detail?: string; - docsUrl?: string; - required: boolean; -} - -export interface SetupStatus { - isComplete: boolean; - steps: SetupStep[]; -} +export type { SetupStep, SetupStatus } from "~/types"; const api = { // Config diff --git a/app/types/index.ts b/app/types/index.ts index 9ee40892..ebffdc38 100644 --- a/app/types/index.ts +++ b/app/types/index.ts @@ -64,3 +64,17 @@ export interface Folder { name: string; unreadCount: number; } + +export interface SetupStep { + id: string; + label: string; + status: "complete" | "incomplete" | "error" | "info"; + detail?: string; + docsUrl?: string; + required: boolean; +} + +export interface SetupStatus { + isComplete: boolean; + steps: SetupStep[]; +} diff --git a/workers/app.ts b/workers/app.ts index 429ea303..795a2b5c 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -8,7 +8,6 @@ import { jwtVerify, createRemoteJWKSet } from "jose"; import { createRequestHandler } from "react-router"; import { app as apiApp, receiveEmail } from "./index"; import { EmailMCP } from "./mcp"; -import { renderAccessNotConfiguredPage } from "./lib/setup-page"; import type { Env } from "./types"; export { MailboxDO } from "./durableObject"; @@ -46,19 +45,36 @@ const app = new Hono<{ Bindings: Env }>(); // Cloudflare Access JWT validation middleware (production only) app.use("*", async (c, next) => { if (import.meta.env.DEV) { - // TODO: remove this preview flag after testing the Access setup page - const preview = c.req.query("__access_error"); - if (preview === "not-configured") return c.html(renderAccessNotConfiguredPage(), 500); return next(); } const { POLICY_AUD, TEAM_DOMAIN } = c.env; + const path = c.req.path; - // Access not configured — serve a setup instructions page. + // Access not configured — only allow public setup routes if (!POLICY_AUD || !TEAM_DOMAIN) { - return c.html(renderAccessNotConfiguredPage(), 500); + const publicPaths = [ + "/setup", + "/api/v1/setup-status", + "/favicon.ico", + "/favicon.svg", + ]; + if (publicPaths.includes(path)) return next(); + if (path.startsWith("/assets/")) return next(); + + // Block API requests + if (path.startsWith("/api/")) { + return c.json( + { error: "Cloudflare Access must be configured. Visit /setup for instructions." }, + 503, + ); + } + + // Redirect browser navigation to setup page + return c.redirect("/setup"); } + // Access configured — enforce JWT on all routes const token = c.req.header("cf-access-jwt-assertion"); if (!token) { return c.text("Missing required CF Access JWT", 403); diff --git a/workers/index.ts b/workers/index.ts index 71214217..e97de602 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -9,11 +9,11 @@ import { z } from "zod"; import { sendEmail } from "./email-sender"; import { storeAttachments, type StoredAttachment } from "./lib/attachments"; import { - validateSender, - SenderValidationError, - generateMessageId, - buildThreadingHeaders, - listMailboxes, + validateSender, + SenderValidationError, + generateMessageId, + buildThreadingHeaders, + listMailboxes, } from "./lib/email-helpers"; import { SendEmailRequestSchema } from "./lib/schemas"; import { handleReplyEmail, handleForwardEmail } from "./routes/reply-forward"; @@ -230,23 +230,14 @@ app.get("/api/v1/setup-status", async (c) => { // 6. Mailbox exists const allMailboxes = await listMailboxes(c.env.BUCKET); - if (allMailboxes.length > 0) { - steps.push({ - id: "mailbox", - label: "First Mailbox", - status: "complete", - required: false, - }); - } else { - steps.push({ - id: "mailbox", - label: "First Mailbox", - status: "incomplete", - detail: - "Create a mailbox from the app home page or set EMAIL_ADDRESSES in your Worker's Settings > Variables and Secrets to auto-create one.", - required: false, - }); - } + steps.push({ + id: "mailbox", + label: "First Mailbox", + status: "info", + detail: + "Create a mailbox from the app home page or set EMAIL_ADDRESSES in your Worker's Settings > Variables and Secrets to auto-create one.", + required: false, + }); const isComplete = steps.every( (s) => s.status === "complete" || s.status === "info" || !s.required, diff --git a/workers/lib/setup-page.ts b/workers/lib/setup-page.ts deleted file mode 100644 index 9bd4d6d7..00000000 --- a/workers/lib/setup-page.ts +++ /dev/null @@ -1,169 +0,0 @@ -export function renderAccessNotConfiguredPage(): string { - return ` - - - - - Setup Required — Agentic Inbox - - - -
-
-
-
- - - - - -
-

Cloudflare Access is not configured

-
-

- This app requires Cloudflare Access for authentication in production. - Follow the steps below to complete setup. -

-
    -
  1. - Enable Cloudflare Access — go to the Workers & Pages dashboard, select your Worker, then navigate to Settings → Domains & Routes and click Enable Cloudflare Access. -
  2. -
  3. - Configure the Access credentials — after enabling Access, a modal will appear with your POLICY_AUD (Application Audience Tag) and TEAM_DOMAIN values. Copy both values, then go to your Worker's Settings → Variables and Secrets and add them as encrypted secrets: -
      -
    • POLICY_AUD — the Application Audience Tag shown in the modal
    • -
    • TEAM_DOMAIN — your Access team URL (e.g. https://myteam.cloudflareaccess.com) or the full certs URL
    • -
    -
  4. -
  5. - Reload this page after the secrets are saved. -
  6. -
- -

- For more help, see the Cloudflare Access documentation. -

-
-

Agentic Inbox

-
- -`; -}