Mailboxes
- {!isConfigured && (
-
}
- onClick={() => setIsCreateOpen(true)}
- >
- New Mailbox
-
- )}
+
+
+ }>
+ Setup
+
+
+ {!isConfigured && (
+ }
+ onClick={() => setIsCreateOpen(true)}
+ >
+ New Mailbox
+
+ )}
+
{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 && (
-
}
- onClick={() => setIsCreateOpen(true)}
- >
- Create Mailbox
-
- )}
+
+ {domains.length === 0 && (
+
+ }>
+ Go to Setup
+
+
+ )}
+ {!isConfigured && domains.length > 0 && (
+ }
+ onClick={() => setIsCreateOpen(true)}
+ >
+ Create Mailbox
+
+ )}
+
)}
@@ -252,70 +270,85 @@ export default function HomeRoute() {
{createError}
)}
-
-
- Email Address
-
-
-
- setNewPrefix(e.target.value)}
- required
- />
-
-
@
- {domains.length > 1 ? (
-
- {
- if (value) setSelectedDomain(value);
- }}
- >
- {domains.map((d) => (
-
- {d}
-
- ))}
-
-
- ) : (
-
- {selectedDomain || "no domain"}
-
- )}
+ {domains.length === 0 ? (
+
+
+ No domains configured. Set up your domain first.
+
+
+ }>
+ Go to Setup
+
+
-
-
setNewName(e.target.value)}
- />
-
-
(
-
- Cancel
+ ) : (
+ <>
+
+
+ Email Address
+
+
+
+ setNewPrefix(e.target.value)}
+ required
+ />
+
+
@
+ {domains.length > 1 ? (
+
+ {
+ if (value) setSelectedDomain(value);
+ }}
+ >
+ {domains.map((d) => (
+
+ {d}
+
+ ))}
+
+
+ ) : (
+
+ {selectedDomain || "no domain"}
+
+ )}
+
+
+ setNewName(e.target.value)}
+ />
+
+ (
+
+ Cancel
+
+ )}
+ />
+
+ Create
- )}
- />
-
- Create
-
-
+
+ >
+ )}
diff --git a/app/routes/setup.tsx b/app/routes/setup.tsx
new file mode 100644
index 00000000..bc6a8ae9
--- /dev/null
+++ b/app/routes/setup.tsx
@@ -0,0 +1,179 @@
+import {
+ Button,
+ Empty,
+ Loader,
+} from "@cloudflare/kumo";
+import {
+ ArrowSquareOutIcon,
+ CheckCircleIcon,
+ GearIcon,
+ InfoIcon,
+ 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" | "info" }) {
+ if (status === "complete") {
+ return
;
+ }
+ if (status === "error") {
+ return
;
+ }
+ if (status === "info") {
+ 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" && s.status !== "info");
+ 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.status === "info" ? (
+
+ Action needed
+
+ ) : step.required ? (
+
+ Required
+
+ ) : (
+
+ Optional
+
+ )}
+
+ {step.detail && (
+
+ {step.detail}
+
+ )}
+ {step.docsUrl && (
+
+ View docs
+
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ {steps.length === 0 && (
+
}
+ title="Could not load setup status"
+ description="Try refreshing the page."
+ />
+ )}
+
+
+
+ Recheck
+
+ {isComplete && (
+
+
+ Continue to App
+
+
+ )}
+
+
+ {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..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,11 +94,17 @@ interface EmailListResponse {
// ---------- API client ----------
+export type { SetupStep, SetupStatus } from "~/types";
+
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/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/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..795a2b5c 100644
--- a/workers/app.ts
+++ b/workers/app.ts
@@ -44,21 +44,37 @@ 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;
+ const path = c.req.path;
- // Fail closed in production if Access is not configured.
+ // Access not configured — only allow public setup routes
if (!POLICY_AUD || !TEAM_DOMAIN) {
- return c.text(
- "Cloudflare Access must be configured in production. Set POLICY_AUD and TEAM_DOMAIN.",
- 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);
@@ -67,10 +83,7 @@ app.use("*", async (c, next) => {
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);
}
diff --git a/workers/index.ts b/workers/index.ts
index fd3359ce..e97de602 100644
--- a/workers/index.ts
+++ b/workers/index.ts
@@ -26,245 +26,553 @@ 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" | "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);
+ 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,
+ );
+ 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 ------------------------------------------------
@@ -273,140 +581,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 };