From 6c1ba811cab70e77e92fdc0fccf3b15c1ebed729 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:33:25 +0800 Subject: [PATCH 001/140] Switch outbound email delivery to Resend API --- workers/email-sender.ts | 75 ++++++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/workers/email-sender.ts b/workers/email-sender.ts index b535ab3ab..13b273c61 100644 --- a/workers/email-sender.ts +++ b/workers/email-sender.ts @@ -3,13 +3,13 @@ // https://opensource.org/licenses/Apache-2.0 /** - * Email sending via Cloudflare Email Service binding. + * Email sending via the Resend Email API. * - * Uses the `send_email` Worker binding (`env.EMAIL.send()`) to send emails. - * - * See: https://developers.cloudflare.com/email-service/api/send-emails/workers-api/ + * Uses the `RESEND_API_KEY` Worker secret to send emails through Resend. */ +import { env } from "cloudflare:workers"; + export interface SendEmailParams { to: string | string[]; from: string | { email: string; name: string }; @@ -29,44 +29,73 @@ export interface SendEmailParams { headers?: Record; } +function formatAddress(address: string | { email: string; name: string }): string { + if (typeof address === "string") return address; + return `${address.name} <${address.email}>`; +} + /** - * Send an email using the Cloudflare Email Service binding. + * Send an email using the Resend Email API. * - * @param binding - The `EMAIL` SendEmail binding from env - * @param params - Email parameters (to, from, subject, body, etc.) - * @returns The send result with messageId - * @throws On validation or delivery errors (error has `.code` property) + * The first parameter is kept for compatibility with the existing callers; + * outbound delivery now uses the `RESEND_API_KEY` Worker secret instead of + * the Cloudflare Email Service binding. */ export async function sendEmail( - binding: SendEmail, + _binding: SendEmail, params: SendEmailParams, ): Promise<{ messageId: string }> { - const message: Record = { + const apiKey = (env as unknown as { RESEND_API_KEY?: string }).RESEND_API_KEY; + if (!apiKey) { + throw new Error("RESEND_API_KEY is not configured"); + } + + const payload: Record = { to: params.to, - from: params.from, + from: formatAddress(params.from), subject: params.subject, }; - if (params.html) message.html = params.html; - if (params.text) message.text = params.text; - if (params.cc) message.cc = params.cc; - if (params.bcc) message.bcc = params.bcc; - if (params.replyTo) message.replyTo = params.replyTo; + if (params.html !== undefined) payload.html = params.html; + if (params.text !== undefined) payload.text = params.text; + if (params.cc) payload.cc = params.cc; + if (params.bcc) payload.bcc = params.bcc; + if (params.replyTo) payload.reply_to = formatAddress(params.replyTo); if (params.headers && Object.keys(params.headers).length > 0) { - message.headers = params.headers; + payload.headers = params.headers; } if (params.attachments && params.attachments.length > 0) { - message.attachments = params.attachments.map((att) => ({ + payload.attachments = params.attachments.map((att) => ({ content: att.content, filename: att.filename, - type: att.type, - disposition: att.disposition, ...(att.contentId ? { contentId: att.contentId } : {}), })); } - const result = await binding.send(message as any); - return { messageId: result.messageId }; + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "User-Agent": "agentic-inbox/1.0", + }, + body: JSON.stringify(payload), + }); + + const result = (await response.json().catch(() => ({}))) as { + id?: string; + message?: string; + }; + + if (!response.ok) { + throw new Error(result.message || `Resend API request failed: ${response.status}`); + } + + if (!result.id) { + throw new Error("Resend API returned no message ID"); + } + + return { messageId: result.id }; } From 125bd923d5cf95a5d6b955d03e610465b19d57d5 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:48:24 +0800 Subject: [PATCH 002/140] feat(auth): add isolated user auth durable object --- workers/userAuth.ts | 157 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 workers/userAuth.ts diff --git a/workers/userAuth.ts b/workers/userAuth.ts new file mode 100644 index 000000000..3729aeb7a --- /dev/null +++ b/workers/userAuth.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { DurableObject } from "cloudflare:workers"; +import type { Env } from "./types"; + +const PBKDF2_ITERATIONS = 120_000; +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; + +interface UserRecord { + email: string; + name: string; + role: "admin" | "employee"; + status: "pending" | "active" | "disabled"; + passwordHash: string; + createdAt: string; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +async function hashPassword(password: string, salt?: Uint8Array): Promise { + const actualSalt = salt ?? crypto.getRandomValues(new Uint8Array(16)); + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits( + { name: "PBKDF2", salt: actualSalt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, + key, + 256, + ); + return `${bytesToBase64(actualSalt)}:${bytesToBase64(new Uint8Array(bits))}`; +} + +async function verifyPassword(password: string, stored: string): Promise { + const [saltText, expectedText] = stored.split(":"); + if (!saltText || !expectedText) return false; + const actual = await hashPassword(password, base64ToBytes(saltText)); + return timingSafeEqual(actual.split(":")[1], expectedText); +} + +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i); + return result === 0; +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export class UserAuthDO extends DurableObject { + private initialized = false; + + private init() { + if (this.initialized) return; + this.ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS users ( + email TEXT PRIMARY KEY, + name TEXT NOT NULL, + role TEXT NOT NULL, + status TEXT NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + email TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + `); + this.initialized = true; + } + + private cleanupSessions() { + this.ctx.storage.sql.exec("DELETE FROM sessions WHERE expires_at <= ?", Math.floor(Date.now() / 1000)); + } + + async fetch(request: Request): Promise { + this.init(); + this.cleanupSessions(); + const url = new URL(request.url); + const body = request.method === "POST" ? await request.json().catch(() => ({})) as Record : {}; + + if (url.pathname === "/seed-admin" && request.method === "POST") { + const email = normalizeEmail(String(body.email ?? "")); + const password = String(body.password ?? ""); + if (!email || password.length < 8) return Response.json({ error: "Invalid admin credentials" }, { status: 400 }); + const existing = this.ctx.storage.sql.exec("SELECT email FROM users WHERE email = ?", email).toArray(); + if (existing.length === 0) { + const passwordHash = await hashPassword(password); + this.ctx.storage.sql.exec( + "INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", + email, "Administrator", "admin", "active", passwordHash, new Date().toISOString(), + ); + } else { + this.ctx.storage.sql.exec("UPDATE users SET role='admin', status='active' WHERE email = ?", email); + } + return Response.json({ ok: true }); + } + + if (url.pathname === "/register" && request.method === "POST") { + const email = normalizeEmail(String(body.email ?? "")); + const name = String(body.name ?? "").trim(); + const password = String(body.password ?? ""); + if (!email || !name || password.length < 8) return Response.json({ error: "Name, email and an 8+ character password are required" }, { status: 400 }); + if (!email.includes("@")) return Response.json({ error: "Invalid email address" }, { status: 400 }); + const existing = this.ctx.storage.sql.exec("SELECT email FROM users WHERE email = ?", email).toArray(); + if (existing.length > 0) return Response.json({ error: "An account with this email already exists" }, { status: 409 }); + const passwordHash = await hashPassword(password); + this.ctx.storage.sql.exec( + "INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", + email, name, "employee", "pending", passwordHash, new Date().toISOString(), + ); + return Response.json({ status: "pending" }, { status: 201 }); + } + + if (url.pathname === "/login" && request.method === "POST") { + const email = normalizeEmail(String(body.email ?? "")); + const password = String(body.password ?? ""); + const row = this.ctx.storage.sql.exec("SELECT * FROM users WHERE email = ?", email).toArray()[0] as UserRecord | undefined; + if (!row || !(await verifyPassword(password, row.passwordHash))) return Response.json({ error: "Invalid email or password" }, { status: 401 }); + if (row.status !== "active") return Response.json({ error: row.status === "pending" ? "Your account is awaiting administrator approval" : "Your account is disabled" }, { status: 403 }); + const token = bytesToBase64(crypto.getRandomValues(new Uint8Array(32))).replace(/[^A-Za-z0-9_-]/g, ""); + const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS; + this.ctx.storage.sql.exec("INSERT INTO sessions (token,email,expires_at) VALUES (?,?,?)", token, email, expiresAt); + return Response.json({ token, user: { email: row.email, name: row.name, role: row.role } }); + } + + if (url.pathname === "/session" && request.method === "POST") { + const token = String(body.token ?? ""); + const row = this.ctx.storage.sql.exec( + "SELECT u.email,u.name,u.role,u.status,s.expires_at FROM sessions s JOIN users u ON u.email=s.email WHERE s.token=?", + token, + ).toArray()[0] as (UserRecord & { expires_at: number }) | undefined; + if (!row || row.expires_at <= Math.floor(Date.now() / 1000) || row.status !== "active") return Response.json({ error: "Invalid session" }, { status: 401 }); + return Response.json({ user: { email: row.email, name: row.name, role: row.role } }); + } + + if (url.pathname === "/logout" && request.method === "POST") { + const token = String(body.token ?? ""); + this.ctx.storage.sql.exec("DELETE FROM sessions WHERE token = ?", token); + return Response.json({ ok: true }); + } + + return Response.json({ error: "Not found" }, { status: 404 }); + } +} From 7f3a55a4b1ddb3365528474904b67aa6df92e877 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:48:32 +0800 Subject: [PATCH 003/140] feat(auth): add session and mailbox authorization helpers --- workers/lib/auth.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 workers/lib/auth.ts diff --git a/workers/lib/auth.ts b/workers/lib/auth.ts new file mode 100644 index 000000000..19ace4413 --- /dev/null +++ b/workers/lib/auth.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import type { Context } from "hono"; +import type { Env } from "../types"; + +export interface AuthUser { + email: string; + name: string; + role: "admin" | "employee"; +} + +export type AuthContext = Context<{ Bindings: Env; Variables: { user: AuthUser } }>; + +const SESSION_COOKIE = "agentic_session"; + +export function getSessionToken(request: Request): string | null { + const cookie = request.headers.get("Cookie") || ""; + for (const part of cookie.split(";")) { + const [name, ...rest] = part.trim().split("="); + if (name === SESSION_COOKIE) return rest.join("=") || null; + } + return null; +} + +export function sessionCookie(token: string, maxAge = 60 * 60 * 24 * 7): string { + return `${SESSION_COOKIE}=${token}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`; +} + +export function expiredSessionCookie(): string { + return sessionCookie("", 0); +} + +export async function getSessionUser(env: Env, request: Request): Promise { + const token = getSessionToken(request); + if (!token) return null; + const stub = env.USER_AUTH.get(env.USER_AUTH.idFromName("global")); + const response = await stub.fetch("https://user-auth/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (!response.ok) return null; + const data = await response.json() as { user: AuthUser }; + return data.user; +} + +export async function requireUser(c: AuthContext, next: () => Promise): Promise { + const user = await getSessionUser(c.env, c.req.raw); + if (!user) return c.json({ error: "Authentication required" }, 401); + c.set("user", user); + return next(); +} + +export function canAccessMailbox(user: AuthUser, mailboxId: string): boolean { + return user.role === "admin" || user.email.toLowerCase() === mailboxId.toLowerCase(); +} + +export function isAdmin(user: AuthUser): boolean { + return user.role === "admin"; +} + +export async function seedAdmin(env: Env): Promise { + if (!env.ADMIN_PASSWORD) return; + const email = (env.ADMIN_EMAIL || "admin@astratradehk.com").toLowerCase(); + const stub = env.USER_AUTH.get(env.USER_AUTH.idFromName("global")); + await stub.fetch("https://user-auth/seed-admin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password: env.ADMIN_PASSWORD }), + }); +} From a07b7a867cfd8ca2520af20c41c676262fbb3994 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:48:35 +0800 Subject: [PATCH 004/140] feat(auth): add auth bindings to worker types --- workers/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workers/types.ts b/workers/types.ts index c89667274..f35a16edc 100644 --- a/workers/types.ts +++ b/workers/types.ts @@ -5,4 +5,6 @@ export interface Env extends Cloudflare.Env { POLICY_AUD: string; TEAM_DOMAIN: string; + ADMIN_EMAIL?: string; + ADMIN_PASSWORD?: string; } From ea0211a1acddb8874305a5e0ed49821df1e43cdf Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:48:41 +0800 Subject: [PATCH 005/140] feat(auth): add user auth durable object binding --- wrangler.jsonc | 75 +++++++++++++------------------------------------- 1 file changed, 19 insertions(+), 56 deletions(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index 53a65cd0d..b6a983bd4 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -3,69 +3,32 @@ "name": "agentic-inbox", "compatibility_date": "2025-11-28", "main": "./workers/app.ts", - "observability": { - "enabled": true - }, - "compatibility_flags": [ - "nodejs_compat" - ], + "observability": { "enabled": true }, + "compatibility_flags": ["nodejs_compat"], "vars": { - // Production deploys must also define POLICY_AUD and TEAM_DOMAIN. - // TEAM_DOMAIN may be the base Access URL or the full /cdn-cgi/access/certs URL. - // The worker now fails closed outside local development if Access is not configured. "DOMAINS": "example.com", - "EMAIL_ADDRESSES": [] - }, - "send_email": [ - { - "name": "EMAIL", - "remote": true - } - ], - "r2_buckets": [ - { - "binding": "BUCKET", - "bucket_name": "agentic-inbox", - "preview_bucket_name": "agentic-inbox" - } - ], - "ai": { - "binding": "AI" + "EMAIL_ADDRESSES": [], + "ADMIN_EMAIL": "admin@astratradehk.com" }, + "send_email": [{ "name": "EMAIL", "remote": true }], + "r2_buckets": [{ + "binding": "BUCKET", + "bucket_name": "agentic-inbox", + "preview_bucket_name": "agentic-inbox" + }], + "ai": { "binding": "AI" }, "durable_objects": { "bindings": [ - { - "name": "MAILBOX", - "class_name": "MailboxDO" - }, - { - "name": "EMAIL_AGENT", - "class_name": "EmailAgent" - }, - { - "name": "EMAIL_MCP", - "class_name": "EmailMCP" - } + { "name": "MAILBOX", "class_name": "MailboxDO" }, + { "name": "EMAIL_AGENT", "class_name": "EmailAgent" }, + { "name": "EMAIL_MCP", "class_name": "EmailMCP" }, + { "name": "USER_AUTH", "class_name": "UserAuthDO" } ] }, "migrations": [ - { - "tag": "v1", - "new_sqlite_classes": [ - "MailboxDO" - ] - }, - { - "tag": "v2", - "new_sqlite_classes": [ - "EmailAgent" - ] - }, - { - "tag": "v3", - "new_sqlite_classes": [ - "EmailMCP" - ] - } + { "tag": "v1", "new_sqlite_classes": ["MailboxDO"] }, + { "tag": "v2", "new_sqlite_classes": ["EmailAgent"] }, + { "tag": "v3", "new_sqlite_classes": ["EmailMCP"] }, + { "tag": "v4", "new_sqlite_classes": ["UserAuthDO"] } ] } From bd1731cad5e74967b31aea0002a9e2847161b038 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:49:03 +0800 Subject: [PATCH 006/140] feat(auth): add login session and mailbox ACL middleware --- workers/app.ts | 154 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 107 insertions(+), 47 deletions(-) diff --git a/workers/app.ts b/workers/app.ts index 607525f78..f1e31dde0 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -9,10 +9,12 @@ import { createRequestHandler } from "react-router"; import { app as apiApp, receiveEmail } from "./index"; import { EmailMCP } from "./mcp"; import type { Env } from "./types"; +import { getSessionUser, sessionCookie, expiredSessionCookie, canAccessMailbox, seedAdmin, type AuthUser } from "./lib/auth"; export { MailboxDO } from "./durableObject"; export { EmailAgent } from "./agent"; export { EmailMCP } from "./mcp"; +export { UserAuthDO } from "./userAuth"; declare module "react-router" { export interface AppLoadContext { @@ -39,88 +41,146 @@ function getAccessUrls(teamDomain: string) { return { issuer, certsUrl }; } -// Main app that wraps the API and adds React Router fallback const app = new Hono<{ Bindings: Env }>(); -// Cloudflare Access JWT validation middleware (production only) +// Cloudflare Access remains the outer security layer. The application +// authentication below adds the mailbox-level identity and authorization model. app.use("*", async (c, next) => { - // Skip validation in development - if (import.meta.env.DEV) { - return next(); - } - + if (import.meta.env.DEV) return next(); const { POLICY_AUD, TEAM_DOMAIN } = c.env; - - // Fail closed in production if Access is not configured. if (!POLICY_AUD || !TEAM_DOMAIN) { - return c.text( - "Cloudflare Access must be configured in production. Set POLICY_AUD and TEAM_DOMAIN.", - 500, - ); + return c.text("Cloudflare Access must be configured in production. Set POLICY_AUD and TEAM_DOMAIN.", 500); } - const token = c.req.header("cf-access-jwt-assertion"); - if (!token) { - return c.text("Missing required CF Access JWT", 403); - } - + if (!token) return c.text("Missing required CF Access JWT", 403); 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); } + return next(); +}); - // Authorization model note: once a teammate passes the shared Cloudflare - // Access policy, they can access all mailboxes in this app by design. +// Application authentication and authorization. Auth endpoints are public to the +// application layer; all other API endpoints require a valid application session. +app.use("/api/*", async (c, next) => { + if (c.req.path.startsWith("/api/v1/auth/")) return next(); + const user = await getSessionUser(c.env, c.req.raw); + if (!user) return c.json({ error: "Authentication required" }, 401); + + const path = c.req.path; + const mailboxPrefix = "/api/v1/mailboxes/"; + if (path.startsWith(mailboxPrefix)) { + const remainder = path.slice(mailboxPrefix.length); + const mailboxId = decodeURIComponent(remainder.split("/")[0] || ""); + if (mailboxId && !canAccessMailbox(user, mailboxId)) { + return c.json({ error: "You do not have permission to access this mailbox" }, 403); + } + if (c.req.method !== "GET" && path === "/api/v1/mailboxes" && user.role !== "admin") { + return c.json({ error: "Administrator permission required" }, 403); + } + } + c.set("authUser", user); return next(); }); -// MCP server endpoint — used by AI coding tools (ProtoAgent, Claude Code, Cursor, etc.) -// Must be before API routes and React Router catch-all -const mcpHandler = EmailMCP.serve("/mcp", { binding: "EMAIL_MCP" }); -app.all("/mcp", async (c) => { - return mcpHandler.fetch(c.req.raw, c.env, c.executionCtx as ExecutionContext); +// Auth API. The user database and sessions live in an isolated SQLite-backed DO. +app.post("/api/v1/auth/register", async (c) => { + const body = await c.req.json().catch(() => null); + const email = String(body?.email ?? "").trim().toLowerCase(); + const name = String(body?.name ?? "").trim(); + const password = String(body?.password ?? ""); + const domains = (c.env.DOMAINS || "").split(",").map((d) => d.trim().toLowerCase()).filter(Boolean); + const domain = email.split("@")[1] || ""; + if (!email || !name || password.length < 8) return c.json({ error: "Name, company email and an 8+ character password are required" }, 400); + if (!domains.some((d) => domain === d)) return c.json({ error: "Registration is restricted to the company email domain" }, 403); + await seedAdmin(c.env); + const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); + const response = await stub.fetch("https://user-auth/register", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, name, password }), + }); + return new Response(response.body, response); +}); + +app.post("/api/v1/auth/login", async (c) => { + const body = await c.req.json().catch(() => null); + const email = String(body?.email ?? "").trim().toLowerCase(); + const password = String(body?.password ?? ""); + await seedAdmin(c.env); + const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); + const response = await stub.fetch("https://user-auth/login", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (!response.ok) return new Response(response.body, response); + const data = await response.json() as { token: string; user: AuthUser }; + return new Response(JSON.stringify({ user: data.user }), { + status: 200, + headers: { "Content-Type": "application/json", "Set-Cookie": sessionCookie(data.token) }, + }); +}); + +app.get("/api/v1/auth/me", async (c) => { + const user = await getSessionUser(c.env, c.req.raw); + if (!user) return c.json({ error: "Not authenticated" }, 401); + return c.json({ user }); +}); + +app.post("/api/v1/auth/logout", async (c) => { + const cookie = c.req.header("Cookie") || ""; + const token = cookie.split(";").map((p) => p.trim()).find((p) => p.startsWith("agentic_session="))?.split("=").slice(1).join("="); + if (token) { + const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); + await stub.fetch("https://user-auth/logout", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), + }); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json", "Set-Cookie": expiredSessionCookie() }, + }); }); -app.all("/mcp/*", async (c) => { - return mcpHandler.fetch(c.req.raw, c.env, c.executionCtx as ExecutionContext); + +// Filter the mailbox directory itself. Employees only see their own mailbox; +// admins see all existing mailboxes. +app.get("/api/v1/mailboxes", async (c, next) => { + const user = await getSessionUser(c.env, c.req.raw); + if (!user) return c.json({ error: "Authentication required" }, 401); + if (user.role === "admin") return next(); + const response = await next(); + if (response.status >= 400) return response; + const data = await response.json() as Array<{ id: string; email: string; name: string }>; + const filtered = data.filter((mailbox) => mailbox.email.toLowerCase() === user.email.toLowerCase()); + return c.json(filtered); }); -// Mount the API routes +// MCP server endpoint — used by AI coding tools (ProtoAgent, Claude Code, Cursor, etc.) +const mcpHandler = EmailMCP.serve("/mcp", { binding: "EMAIL_MCP" }); +app.all("/mcp", async (c) => mcpHandler.fetch(c.req.raw, c.env, c.executionCtx as ExecutionContext)); +app.all("/mcp/*", async (c) => mcpHandler.fetch(c.req.raw, c.env, c.executionCtx as ExecutionContext)); + app.route("/", apiApp); -// Agent WebSocket routing - must be before React Router catch-all app.all("/agents/*", async (c) => { const response = await routeAgentRequest(c.req.raw, c.env); if (response) return response; return c.text("Agent not found", 404); }); -// React Router catch-all: serves the SPA for all non-API routes -app.all("*", (c) => { - return requestHandler(c.req.raw, { - cloudflare: { env: c.env, ctx: c.executionCtx as ExecutionContext }, - }); -}); +app.all("*", (c) => requestHandler(c.req.raw, { + cloudflare: { env: c.env, ctx: c.executionCtx as ExecutionContext }, +})); -// Export the Hono app as the default export with an email handler export default { fetch: app.fetch, - async email( - event: { raw: ReadableStream; rawSize: number }, - env: Env, - ctx: ExecutionContext, - ) { + async email(event: { raw: ReadableStream; rawSize: number }, env: Env, ctx: ExecutionContext) { try { await receiveEmail(event, env, ctx); } catch (e) { console.error("Failed to process incoming email:", (e as Error).message, (e as Error).stack); - // Re-throw so Cloudflare's email routing can retry delivery or bounce the message. - // Swallowing the error would silently drop the email. throw e; } }, From d14e2b106f239c22517afd07e6fdaf9e234c1b17 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:49:17 +0800 Subject: [PATCH 007/140] fix(auth): enforce mailbox directory permissions at outer router --- workers/app.ts | 71 +++++++++++++++----------------------------------- 1 file changed, 21 insertions(+), 50 deletions(-) diff --git a/workers/app.ts b/workers/app.ts index f1e31dde0..d16a731b4 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -10,6 +10,7 @@ import { app as apiApp, receiveEmail } from "./index"; import { EmailMCP } from "./mcp"; import type { Env } from "./types"; import { getSessionUser, sessionCookie, expiredSessionCookie, canAccessMailbox, seedAdmin, type AuthUser } from "./lib/auth"; +import { listMailboxes } from "./lib/email-helpers"; export { MailboxDO } from "./durableObject"; export { EmailAgent } from "./agent"; @@ -34,10 +35,7 @@ function getAccessUrls(teamDomain: string) { const certsPath = "/cdn-cgi/access/certs"; const teamUrl = new URL(teamDomain); const issuer = teamUrl.origin; - const certsUrl = teamUrl.pathname.endsWith(certsPath) - ? teamUrl - : new URL(certsPath, issuer); - + const certsUrl = teamUrl.pathname.endsWith(certsPath) ? teamUrl : new URL(certsPath, issuer); return { issuer, certsUrl }; } @@ -48,9 +46,7 @@ const app = new Hono<{ Bindings: Env }>(); app.use("*", async (c, next) => { if (import.meta.env.DEV) return next(); const { POLICY_AUD, TEAM_DOMAIN } = c.env; - if (!POLICY_AUD || !TEAM_DOMAIN) { - return c.text("Cloudflare Access must be configured in production. Set POLICY_AUD and TEAM_DOMAIN.", 500); - } + if (!POLICY_AUD || !TEAM_DOMAIN) return c.text("Cloudflare Access must be configured in production. Set POLICY_AUD and TEAM_DOMAIN.", 500); const token = c.req.header("cf-access-jwt-assertion"); if (!token) return c.text("Missing required CF Access JWT", 403); try { @@ -69,18 +65,13 @@ app.use("/api/*", async (c, next) => { if (c.req.path.startsWith("/api/v1/auth/")) return next(); const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); - const path = c.req.path; - const mailboxPrefix = "/api/v1/mailboxes/"; - if (path.startsWith(mailboxPrefix)) { - const remainder = path.slice(mailboxPrefix.length); + if (path === "/api/v1/mailboxes") { + if (c.req.method !== "GET" && user.role !== "admin") return c.json({ error: "Administrator permission required" }, 403); + } else if (path.startsWith("/api/v1/mailboxes/")) { + const remainder = path.slice("/api/v1/mailboxes/".length); const mailboxId = decodeURIComponent(remainder.split("/")[0] || ""); - if (mailboxId && !canAccessMailbox(user, mailboxId)) { - return c.json({ error: "You do not have permission to access this mailbox" }, 403); - } - if (c.req.method !== "GET" && path === "/api/v1/mailboxes" && user.role !== "admin") { - return c.json({ error: "Administrator permission required" }, 403); - } + if (mailboxId && !canAccessMailbox(user, mailboxId)) return c.json({ error: "You do not have permission to access this mailbox" }, 403); } c.set("authUser", user); return next(); @@ -88,7 +79,7 @@ app.use("/api/*", async (c, next) => { // Auth API. The user database and sessions live in an isolated SQLite-backed DO. app.post("/api/v1/auth/register", async (c) => { - const body = await c.req.json().catch(() => null); + const body = await c.req.json().catch(() => null) as Record | null; const email = String(body?.email ?? "").trim().toLowerCase(); const name = String(body?.name ?? "").trim(); const password = String(body?.password ?? ""); @@ -98,29 +89,20 @@ app.post("/api/v1/auth/register", async (c) => { if (!domains.some((d) => domain === d)) return c.json({ error: "Registration is restricted to the company email domain" }, 403); await seedAdmin(c.env); const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); - const response = await stub.fetch("https://user-auth/register", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, name, password }), - }); + const response = await stub.fetch("https://user-auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, name, password }) }); return new Response(response.body, response); }); app.post("/api/v1/auth/login", async (c) => { - const body = await c.req.json().catch(() => null); + const body = await c.req.json().catch(() => null) as Record | null; const email = String(body?.email ?? "").trim().toLowerCase(); const password = String(body?.password ?? ""); await seedAdmin(c.env); const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); - const response = await stub.fetch("https://user-auth/login", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }); + const response = await stub.fetch("https://user-auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) }); if (!response.ok) return new Response(response.body, response); const data = await response.json() as { token: string; user: AuthUser }; - return new Response(JSON.stringify({ user: data.user }), { - status: 200, - headers: { "Content-Type": "application/json", "Set-Cookie": sessionCookie(data.token) }, - }); + return new Response(JSON.stringify({ user: data.user }), { status: 200, headers: { "Content-Type": "application/json", "Set-Cookie": sessionCookie(data.token) } }); }); app.get("/api/v1/auth/me", async (c) => { @@ -134,27 +116,18 @@ app.post("/api/v1/auth/logout", async (c) => { const token = cookie.split(";").map((p) => p.trim()).find((p) => p.startsWith("agentic_session="))?.split("=").slice(1).join("="); if (token) { const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); - await stub.fetch("https://user-auth/logout", { - method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), - }); + await stub.fetch("https://user-auth/logout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); } - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "Content-Type": "application/json", "Set-Cookie": expiredSessionCookie() }, - }); + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json", "Set-Cookie": expiredSessionCookie() } }); }); -// Filter the mailbox directory itself. Employees only see their own mailbox; -// admins see all existing mailboxes. -app.get("/api/v1/mailboxes", async (c, next) => { +// Mailbox directory is handled here so employees never receive the full list. +app.get("/api/v1/mailboxes", async (c) => { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); - if (user.role === "admin") return next(); - const response = await next(); - if (response.status >= 400) return response; - const data = await response.json() as Array<{ id: string; email: string; name: string }>; - const filtered = data.filter((mailbox) => mailbox.email.toLowerCase() === user.email.toLowerCase()); - return c.json(filtered); + const allMailboxes = await listMailboxes(c.env.BUCKET); + const visible = user.role === "admin" ? allMailboxes : allMailboxes.filter((m) => m.id.toLowerCase() === user.email.toLowerCase()); + return c.json(visible.map((m) => ({ ...m, name: m.id }))); }); // MCP server endpoint — used by AI coding tools (ProtoAgent, Claude Code, Cursor, etc.) @@ -170,9 +143,7 @@ app.all("/agents/*", async (c) => { return c.text("Agent not found", 404); }); -app.all("*", (c) => requestHandler(c.req.raw, { - cloudflare: { env: c.env, ctx: c.executionCtx as ExecutionContext }, -})); +app.all("*", (c) => requestHandler(c.req.raw, { cloudflare: { env: c.env, ctx: c.executionCtx as ExecutionContext } })); export default { fetch: app.fetch, From a6fecdb9e8c288897ece0004e0ce27a0e795e8b5 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:49:25 +0800 Subject: [PATCH 008/140] feat(auth): add browser auth client --- app/services/auth.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 app/services/auth.ts diff --git a/app/services/auth.ts b/app/services/auth.ts new file mode 100644 index 000000000..7323548ca --- /dev/null +++ b/app/services/auth.ts @@ -0,0 +1,40 @@ +export interface AuthUser { + email: string; + name: string; + role: "admin" | "employee"; +} + +export async function getCurrentUser(): Promise { + const response = await fetch("/api/v1/auth/me", { credentials: "same-origin" }); + if (response.status === 401) return null; + if (!response.ok) throw new Error("Unable to check authentication"); + const data = await response.json() as { user: AuthUser }; + return data.user; +} + +export async function login(email: string, password: string): Promise { + const response = await fetch("/api/v1/auth/login", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + const data = await response.json().catch(() => ({})) as { user?: AuthUser; error?: string }; + if (!response.ok || !data.user) throw new Error(data.error || "Login failed"); + return data.user; +} + +export async function register(name: string, email: string, password: string): Promise { + const response = await fetch("/api/v1/auth/register", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, email, password }), + }); + const data = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) throw new Error(data.error || "Registration failed"); +} + +export async function logout(): Promise { + await fetch("/api/v1/auth/logout", { method: "POST", credentials: "same-origin" }); +} From c0027499bd39e481064828a9e0a56565aea017e1 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:49:34 +0800 Subject: [PATCH 009/140] feat(auth): add employee login and registration screen --- app/routes/auth.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 app/routes/auth.tsx diff --git a/app/routes/auth.tsx b/app/routes/auth.tsx new file mode 100644 index 000000000..159792a37 --- /dev/null +++ b/app/routes/auth.tsx @@ -0,0 +1,81 @@ +import { Button, Input } from "@cloudflare/kumo"; +import { useState } from "react"; +import { Link as RouterLink, useLocation, useNavigate } from "react-router"; +import { login, register } from "~/services/auth"; + +export function meta() { + return [{ title: "Agentic Inbox — Sign in" }]; +} + +export default function AuthRoute() { + const location = useLocation(); + const navigate = useNavigate(); + const isRegister = location.pathname === "/register"; + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setMessage(null); + if (isRegister && password !== confirm) { + setError("Passwords do not match"); + return; + } + setBusy(true); + try { + if (isRegister) { + await register(name, email, password); + setMessage("Registration submitted. An administrator must approve your account before you can sign in."); + setName(""); + setPassword(""); + setConfirm(""); + } else { + await login(email, password); + window.location.href = "/"; + } + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+
+
+

Agentic Inbox

+

{isRegister ? "Create your company mailbox account" : "Sign in to your company mailbox"}

+
+ +
+ {error &&
{error}
} + {message &&
{message}
} + {isRegister && setName(e.target.value)} placeholder="Your name" required />} + setEmail(e.target.value)} placeholder="name@astratradehk.com" required /> + setPassword(e.target.value)} placeholder="At least 8 characters" minLength={8} required /> + {isRegister && setConfirm(e.target.value)} required />} + +
+ +
+ {isRegister ? ( + <>Already have an account? Sign in + ) : ( + <>Need an account? Employee registration + )} +
+
+
+ ); +} From 51ff13cb99eb35fd9f109e8fa91e2d0aa2991d81 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:49:40 +0800 Subject: [PATCH 010/140] feat(auth): add login and registration routes --- app/routes.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/routes.ts b/app/routes.ts index 5d0ebd615..93bd8ad20 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -2,14 +2,12 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -import { - index, - type RouteConfig, - route, -} from "@react-router/dev/routes"; +import { index, type RouteConfig, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), + route("login", "routes/auth.tsx"), + route("register", "routes/auth.tsx"), route("mailbox/:mailboxId", "routes/mailbox.tsx", [ index("routes/mailbox-index.tsx"), route("emails/:folder", "routes/email-list.tsx"), From 7bd04b2997e27afcca9cbbb0630ec4e5179ffd02 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:49:48 +0800 Subject: [PATCH 011/140] feat(auth): gate application routes behind user session --- app/root.tsx | 110 +++++++++++++++++---------------------------------- 1 file changed, 36 insertions(+), 74 deletions(-) diff --git a/app/root.tsx b/app/root.tsx index 527757389..cde079af9 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -11,7 +11,7 @@ import { TooltipProvider, } from "@cloudflare/kumo"; import { WarningIcon } from "@phosphor-icons/react"; -import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MutationCache, QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; import { forwardRef, useState } from "react"; import { isRouteErrorResponse, @@ -21,8 +21,10 @@ import { Link as RouterLink, Scripts, ScrollRestoration, + useLocation, } from "react-router"; import { ApiError } from "~/services/api"; +import { getCurrentUser } from "~/services/auth"; import "./index.css"; function makeQueryClient() { @@ -32,46 +34,24 @@ function makeQueryClient() { staleTime: 30_000, refetchOnWindowFocus: false, retry: (failureCount, error) => { - // Don't retry 4xx errors (not found, unauthorized, etc.) - if (error instanceof ApiError && error.status >= 400 && error.status < 500) { - return false; - } + if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false; return failureCount < 2; }, }, }, - mutationCache: new MutationCache({ - onError: (error) => { - // Global fallback for mutations that don't handle errors themselves. - // Consumers using mutateAsync + try/catch handle their own errors. - console.error("Mutation failed:", error); - }, - }), + mutationCache: new MutationCache({ onError: (error) => console.error("Mutation failed:", error) }), }); } -// Lazy singleton for the browser — avoids module-scope instantiation that -// leaks cache across SSR requests. let browserQueryClient: QueryClient | undefined; function getQueryClient() { - if (typeof window === "undefined") { - // SSR: always create a fresh client per request to prevent cross-user cache leaks - return makeQueryClient(); - } - // Browser: reuse the same client across navigations + if (typeof window === "undefined") return makeQueryClient(); if (!browserQueryClient) browserQueryClient = makeQueryClient(); return browserQueryClient; } -const KumoLink = forwardRef< - HTMLAnchorElement, - React.AnchorHTMLAttributes & { href?: string } ->(function KumoLink({ href, ...props }, ref) { - if (href && !href.startsWith("http")) { - return ( - )} /> - ); - } +const KumoLink = forwardRef & { href?: string }>(function KumoLink({ href, ...props }, ref) { + if (href && !href.startsWith("http")) return )} />; return ; }); @@ -81,12 +61,7 @@ export function Layout({ children }: { children: React.ReactNode }) { - + Agentic Inbox @@ -102,25 +77,35 @@ export function Layout({ children }: { children: React.ReactNode }) { } export function HydrateFallback() { - return ( -
- -
- ); + return
; +} + +function AuthGate() { + const location = useLocation(); + const publicRoute = location.pathname === "/login" || location.pathname === "/register"; + const { data: user, isLoading } = useQuery({ + queryKey: ["auth", "me"], + queryFn: getCurrentUser, + enabled: !publicRoute, + staleTime: 60_000, + retry: false, + }); + + if (publicRoute) return ; + if (isLoading) return
; + if (!user) { + if (typeof window !== "undefined") window.location.replace("/login"); + return
; + } + return ; } 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 ( - - - - - + ); @@ -130,38 +115,15 @@ 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; - if (isRouteErrorResponse(error)) { status = error.status; - if (error.status === 404) { - title = "Page not found"; - description = - "The page you're looking for doesn't exist or has been moved."; - } else { - title = `Error ${error.status}`; - description = error.statusText || description; - } - } else if (error instanceof Error && import.meta.env.DEV) { - description = error.message; - } - + if (error.status === 404) { title = "Page not found"; description = "The page you're looking for doesn't exist or has been moved."; } + else description = error.statusText || description; + } else if (error instanceof Error && import.meta.env.DEV) description = error.message; return (
- } - title={status === 404 ? "404 — Page not found" : title} - description={description} - contents={ - - } - /> + } title={status === 404 ? "404 — Page not found" : title} description={description} + contents={} />
); } From 8ed87b8955733cb986ecbe7b2a2e7b26fe6cc6da Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:50:38 +0800 Subject: [PATCH 012/140] fix(auth): remove unused Hono context variable --- workers/app.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/workers/app.ts b/workers/app.ts index d16a731b4..0e72ef551 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -26,10 +26,7 @@ declare module "react-router" { } } -const requestHandler = createRequestHandler( - () => import("virtual:react-router/server-build"), - import.meta.env.MODE, -); +const requestHandler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE); function getAccessUrls(teamDomain: string) { const certsPath = "/cdn-cgi/access/certs"; @@ -73,7 +70,6 @@ app.use("/api/*", async (c, next) => { const mailboxId = decodeURIComponent(remainder.split("/")[0] || ""); if (mailboxId && !canAccessMailbox(user, mailboxId)) return c.json({ error: "You do not have permission to access this mailbox" }, 403); } - c.set("authUser", user); return next(); }); From 207d79a67afe4496042ab978e50169d2542cf256 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:50:58 +0800 Subject: [PATCH 013/140] feat(auth): add admin user management operations --- workers/userAuth.ts | 64 +++++++++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/workers/userAuth.ts b/workers/userAuth.ts index 3729aeb7a..c9af72d9d 100644 --- a/workers/userAuth.ts +++ b/workers/userAuth.ts @@ -31,11 +31,7 @@ function base64ToBytes(value: string): Uint8Array { async function hashPassword(password: string, salt?: Uint8Array): Promise { const actualSalt = salt ?? crypto.getRandomValues(new Uint8Array(16)); const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]); - const bits = await crypto.subtle.deriveBits( - { name: "PBKDF2", salt: actualSalt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, - key, - 256, - ); + const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", salt: actualSalt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, key, 256); return `${bytesToBase64(actualSalt)}:${bytesToBase64(new Uint8Array(bits))}`; } @@ -53,8 +49,15 @@ function timingSafeEqual(a: string, b: string): boolean { return result === 0; } -function normalizeEmail(email: string): string { - return email.trim().toLowerCase(); +function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } + +function publicUser(row: any) { + return { email: row.email, name: row.name, role: row.role, status: row.status, createdAt: row.created_at }; +} + +function randomToken(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); } export class UserAuthDO extends DurableObject { @@ -98,10 +101,7 @@ export class UserAuthDO extends DurableObject { const existing = this.ctx.storage.sql.exec("SELECT email FROM users WHERE email = ?", email).toArray(); if (existing.length === 0) { const passwordHash = await hashPassword(password); - this.ctx.storage.sql.exec( - "INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", - email, "Administrator", "admin", "active", passwordHash, new Date().toISOString(), - ); + this.ctx.storage.sql.exec("INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", email, "Administrator", "admin", "active", passwordHash, new Date().toISOString()); } else { this.ctx.storage.sql.exec("UPDATE users SET role='admin', status='active' WHERE email = ?", email); } @@ -117,10 +117,7 @@ export class UserAuthDO extends DurableObject { const existing = this.ctx.storage.sql.exec("SELECT email FROM users WHERE email = ?", email).toArray(); if (existing.length > 0) return Response.json({ error: "An account with this email already exists" }, { status: 409 }); const passwordHash = await hashPassword(password); - this.ctx.storage.sql.exec( - "INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", - email, name, "employee", "pending", passwordHash, new Date().toISOString(), - ); + this.ctx.storage.sql.exec("INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", email, name, "employee", "pending", passwordHash, new Date().toISOString()); return Response.json({ status: "pending" }, { status: 201 }); } @@ -130,7 +127,7 @@ export class UserAuthDO extends DurableObject { const row = this.ctx.storage.sql.exec("SELECT * FROM users WHERE email = ?", email).toArray()[0] as UserRecord | undefined; if (!row || !(await verifyPassword(password, row.passwordHash))) return Response.json({ error: "Invalid email or password" }, { status: 401 }); if (row.status !== "active") return Response.json({ error: row.status === "pending" ? "Your account is awaiting administrator approval" : "Your account is disabled" }, { status: 403 }); - const token = bytesToBase64(crypto.getRandomValues(new Uint8Array(32))).replace(/[^A-Za-z0-9_-]/g, ""); + const token = randomToken(); const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS; this.ctx.storage.sql.exec("INSERT INTO sessions (token,email,expires_at) VALUES (?,?,?)", token, email, expiresAt); return Response.json({ token, user: { email: row.email, name: row.name, role: row.role } }); @@ -138,10 +135,7 @@ export class UserAuthDO extends DurableObject { if (url.pathname === "/session" && request.method === "POST") { const token = String(body.token ?? ""); - const row = this.ctx.storage.sql.exec( - "SELECT u.email,u.name,u.role,u.status,s.expires_at FROM sessions s JOIN users u ON u.email=s.email WHERE s.token=?", - token, - ).toArray()[0] as (UserRecord & { expires_at: number }) | undefined; + const row = this.ctx.storage.sql.exec("SELECT u.email,u.name,u.role,u.status,s.expires_at FROM sessions s JOIN users u ON u.email=s.email WHERE s.token=?", token).toArray()[0] as (UserRecord & { expires_at: number }) | undefined; if (!row || row.expires_at <= Math.floor(Date.now() / 1000) || row.status !== "active") return Response.json({ error: "Invalid session" }, { status: 401 }); return Response.json({ user: { email: row.email, name: row.name, role: row.role } }); } @@ -152,6 +146,36 @@ export class UserAuthDO extends DurableObject { return Response.json({ ok: true }); } + if (url.pathname === "/admin/users" && request.method === "GET") { + const users = this.ctx.storage.sql.exec("SELECT email,name,role,status,created_at FROM users ORDER BY created_at ASC").toArray(); + return Response.json({ users: users.map(publicUser) }); + } + + if (url.pathname === "/admin/approve" && request.method === "POST") { + const email = normalizeEmail(String(body.email ?? "")); + this.ctx.storage.sql.exec("UPDATE users SET status='active' WHERE email=? AND role='employee'", email); + const row = this.ctx.storage.sql.exec("SELECT email,name,role,status,created_at FROM users WHERE email=?", email).toArray()[0]; + return row ? Response.json({ user: publicUser(row) }) : Response.json({ error: "User not found" }, { status: 404 }); + } + + if (url.pathname === "/admin/status" && request.method === "POST") { + const email = normalizeEmail(String(body.email ?? "")); + const status = String(body.status ?? ""); + if (!["active", "disabled", "pending"].includes(status)) return Response.json({ error: "Invalid status" }, { status: 400 }); + this.ctx.storage.sql.exec("UPDATE users SET status=? WHERE email=? AND role='employee'", status, email); + return Response.json({ ok: true }); + } + + if (url.pathname === "/admin/reset-password" && request.method === "POST") { + const email = normalizeEmail(String(body.email ?? "")); + const password = String(body.password ?? ""); + if (password.length < 8) return Response.json({ error: "Password must be at least 8 characters" }, { status: 400 }); + const passwordHash = await hashPassword(password); + this.ctx.storage.sql.exec("UPDATE users SET password_hash=? WHERE email=? AND role='employee'", passwordHash, email); + this.ctx.storage.sql.exec("DELETE FROM sessions WHERE email=?", email); + return Response.json({ ok: true }); + } + return Response.json({ error: "Not found" }, { status: 404 }); } } From e3ae9e018acae933b937bbe7bb7bf92463ca11d6 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:51:13 +0800 Subject: [PATCH 014/140] feat(auth): add admin user management API --- workers/app.ts | 67 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/workers/app.ts b/workers/app.ts index 0e72ef551..ae311c315 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -38,8 +38,6 @@ function getAccessUrls(teamDomain: string) { const app = new Hono<{ Bindings: Env }>(); -// Cloudflare Access remains the outer security layer. The application -// authentication below adds the mailbox-level identity and authorization model. app.use("*", async (c, next) => { if (import.meta.env.DEV) return next(); const { POLICY_AUD, TEAM_DOMAIN } = c.env; @@ -56,8 +54,6 @@ app.use("*", async (c, next) => { return next(); }); -// Application authentication and authorization. Auth endpoints are public to the -// application layer; all other API endpoints require a valid application session. app.use("/api/*", async (c, next) => { if (c.req.path.startsWith("/api/v1/auth/")) return next(); const user = await getSessionUser(c.env, c.req.raw); @@ -73,7 +69,13 @@ app.use("/api/*", async (c, next) => { return next(); }); -// Auth API. The user database and sessions live in an isolated SQLite-backed DO. +async function requireAdmin(c: any): Promise { + const user = await getSessionUser(c.env, c.req.raw); + if (!user) return c.json({ error: "Authentication required" }, 401); + if (user.role !== "admin") return c.json({ error: "Administrator permission required" }, 403); + return user; +} + app.post("/api/v1/auth/register", async (c) => { const body = await c.req.json().catch(() => null) as Record | null; const email = String(body?.email ?? "").trim().toLowerCase(); @@ -117,7 +119,57 @@ app.post("/api/v1/auth/logout", async (c) => { return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json", "Set-Cookie": expiredSessionCookie() } }); }); -// Mailbox directory is handled here so employees never receive the full list. +app.get("/api/v1/admin/users", async (c) => { + const admin = await requireAdmin(c); + if (admin instanceof Response) return admin; + const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); + const response = await stub.fetch("https://user-auth/admin/users"); + return new Response(response.body, response); +}); + +app.post("/api/v1/admin/approve", async (c) => { + const admin = await requireAdmin(c); + if (admin instanceof Response) return admin; + const body = await c.req.json().catch(() => null) as Record | null; + const email = String(body?.email ?? "").trim().toLowerCase(); + const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); + const response = await stub.fetch("https://user-auth/admin/approve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }) }); + if (!response.ok) return new Response(response.body, response); + const data = await response.json() as { user?: { email: string; name: string } }; + if (data.user) { + const key = `mailboxes/${data.user.email}.json`; + if (!(await c.env.BUCKET.head(key))) { + const settings = { fromName: data.user.name, forwarding: { enabled: false, email: "" }, signature: { enabled: false, text: "" }, autoReply: { enabled: false, subject: "", message: "" } }; + await c.env.BUCKET.put(key, JSON.stringify(settings)); + const mailbox = c.env.MAILBOX.get(c.env.MAILBOX.idFromName(data.user.email)); + await mailbox.getFolders(); + } + } + return c.json(data); +}); + +app.post("/api/v1/admin/status", async (c) => { + const admin = await requireAdmin(c); + if (admin instanceof Response) return admin; + const body = await c.req.json().catch(() => null) as Record | null; + const email = String(body?.email ?? "").trim().toLowerCase(); + const status = String(body?.status ?? ""); + const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); + const response = await stub.fetch("https://user-auth/admin/status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, status }) }); + return new Response(response.body, response); +}); + +app.post("/api/v1/admin/reset-password", async (c) => { + const admin = await requireAdmin(c); + if (admin instanceof Response) return admin; + const body = await c.req.json().catch(() => null) as Record | null; + const email = String(body?.email ?? "").trim().toLowerCase(); + const password = String(body?.password ?? ""); + const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); + const response = await stub.fetch("https://user-auth/admin/reset-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) }); + return new Response(response.body, response); +}); + app.get("/api/v1/mailboxes", async (c) => { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); @@ -126,19 +178,16 @@ app.get("/api/v1/mailboxes", async (c) => { return c.json(visible.map((m) => ({ ...m, name: m.id }))); }); -// MCP server endpoint — used by AI coding tools (ProtoAgent, Claude Code, Cursor, etc.) const mcpHandler = EmailMCP.serve("/mcp", { binding: "EMAIL_MCP" }); app.all("/mcp", async (c) => mcpHandler.fetch(c.req.raw, c.env, c.executionCtx as ExecutionContext)); app.all("/mcp/*", async (c) => mcpHandler.fetch(c.req.raw, c.env, c.executionCtx as ExecutionContext)); app.route("/", apiApp); - app.all("/agents/*", async (c) => { const response = await routeAgentRequest(c.req.raw, c.env); if (response) return response; return c.text("Agent not found", 404); }); - app.all("*", (c) => requestHandler(c.req.raw, { cloudflare: { env: c.env, ctx: c.executionCtx as ExecutionContext } })); export default { From c830b891c53473a61e351b080ab5520efb337012 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:51:18 +0800 Subject: [PATCH 015/140] feat(auth): add admin user management client --- app/services/admin.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 app/services/admin.ts diff --git a/app/services/admin.ts b/app/services/admin.ts new file mode 100644 index 000000000..39528ea92 --- /dev/null +++ b/app/services/admin.ts @@ -0,0 +1,26 @@ +export interface AdminUser { + email: string; + name: string; + role: "admin" | "employee"; + status: "pending" | "active" | "disabled"; + createdAt: string; +} + +async function call(url: string, method = "GET", body?: unknown): Promise { + const response = await fetch(url, { + method, + credentials: "same-origin", + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error((data as { error?: string }).error || `Request failed: ${response.status}`); + return data as T; +} + +export const adminApi = { + listUsers: () => call<{ users: AdminUser[] }>("/api/v1/admin/users"), + approve: (email: string) => call("/api/v1/admin/approve", "POST", { email }), + setStatus: (email: string, status: AdminUser["status"]) => call("/api/v1/admin/status", "POST", { email, status }), + resetPassword: (email: string, password: string) => call("/api/v1/admin/reset-password", "POST", { email, password }), +}; From 00dc3584be91052ad7c1d911cb1a0e351207b00f Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:51:27 +0800 Subject: [PATCH 016/140] feat(auth): add administrator user management screen --- app/routes/admin.tsx | 76 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 app/routes/admin.tsx diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx new file mode 100644 index 000000000..07deb4cfb --- /dev/null +++ b/app/routes/admin.tsx @@ -0,0 +1,76 @@ +import { Button, Input } from "@cloudflare/kumo"; +import { useEffect, useState } from "react"; +import { adminApi, type AdminUser } from "~/services/admin"; + +export function meta() { + return [{ title: "Agentic Inbox — Admin" }]; +} + +export default function AdminRoute() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [resetEmail, setResetEmail] = useState(null); + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(null); + + const load = async () => { + try { + setUsers((await adminApi.listUsers()).users); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to load users"); + } finally { setLoading(false); } + }; + useEffect(() => { void load(); }, []); + + const approve = async (email: string) => { + setBusy(email); + try { await adminApi.approve(email); await load(); } catch (err) { setError(err instanceof Error ? err.message : "Approval failed"); } finally { setBusy(null); } + }; + + const toggle = async (user: AdminUser) => { + setBusy(user.email); + try { await adminApi.setStatus(user.email, user.status === "disabled" ? "active" : "disabled"); await load(); } catch (err) { setError(err instanceof Error ? err.message : "Status update failed"); } finally { setBusy(null); } + }; + + const resetPassword = async () => { + if (!resetEmail || newPassword.length < 8) return; + setBusy(resetEmail); + try { await adminApi.resetPassword(resetEmail, newPassword); setResetEmail(null); setNewPassword(""); } catch (err) { setError(err instanceof Error ? err.message : "Password reset failed"); } finally { setBusy(null); } + }; + + return ( +
+
+
+

Employee Management

Approve accounts and manage mailbox access.

+ +
+ {error &&
{error}
} +
+ {loading ?
Loading…
: users.map((user, index) => ( +
+
{user.name}
{user.email}
+
{user.role} · {user.status}
+
+ {user.role === "employee" && user.status === "pending" && } + {user.role === "employee" && user.status !== "pending" && } + {user.role === "employee" && user.status !== "pending" && } +
+
+ ))} +
+
+ + {resetEmail &&
+
+

Reset password

+

{resetEmail}

+
setNewPassword(e.target.value)} />
+
+
+
} +
+ ); +} From 8e87fe058e972f708623c00a6fab6ee14d6cf20d Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:51:32 +0800 Subject: [PATCH 017/140] feat(auth): add admin route --- app/routes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/routes.ts b/app/routes.ts index 93bd8ad20..4a9486aef 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -1,5 +1,5 @@ // Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// Licensed under the Apache 2.0 license found in the LICENSE file and at: // https://opensource.org/licenses/Apache-2.0 import { index, type RouteConfig, route } from "@react-router/dev/routes"; @@ -8,6 +8,7 @@ export default [ index("routes/home.tsx"), route("login", "routes/auth.tsx"), route("register", "routes/auth.tsx"), + route("admin", "routes/admin.tsx"), route("mailbox/:mailboxId", "routes/mailbox.tsx", [ index("routes/mailbox-index.tsx"), route("emails/:folder", "routes/email-list.tsx"), From 5c3c9d44f3509a55553a15e680241389694f2678 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:51:50 +0800 Subject: [PATCH 018/140] fix(auth): read password hash from SQLite column name --- workers/userAuth.ts | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/workers/userAuth.ts b/workers/userAuth.ts index c9af72d9d..f08d94d8a 100644 --- a/workers/userAuth.ts +++ b/workers/userAuth.ts @@ -13,8 +13,8 @@ interface UserRecord { name: string; role: "admin" | "employee"; status: "pending" | "active" | "disabled"; - passwordHash: string; - createdAt: string; + password_hash: string; + created_at: string; } function bytesToBase64(bytes: Uint8Array): string { @@ -50,11 +50,7 @@ function timingSafeEqual(a: string, b: string): boolean { } function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } - -function publicUser(row: any) { - return { email: row.email, name: row.name, role: row.role, status: row.status, createdAt: row.created_at }; -} - +function publicUser(row: any) { return { email: row.email, name: row.name, role: row.role, status: row.status, createdAt: row.created_at }; } function randomToken(): string { const bytes = crypto.getRandomValues(new Uint8Array(32)); return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); @@ -84,9 +80,7 @@ export class UserAuthDO extends DurableObject { this.initialized = true; } - private cleanupSessions() { - this.ctx.storage.sql.exec("DELETE FROM sessions WHERE expires_at <= ?", Math.floor(Date.now() / 1000)); - } + private cleanupSessions() { this.ctx.storage.sql.exec("DELETE FROM sessions WHERE expires_at <= ?", Math.floor(Date.now() / 1000)); } async fetch(request: Request): Promise { this.init(); @@ -102,9 +96,7 @@ export class UserAuthDO extends DurableObject { if (existing.length === 0) { const passwordHash = await hashPassword(password); this.ctx.storage.sql.exec("INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", email, "Administrator", "admin", "active", passwordHash, new Date().toISOString()); - } else { - this.ctx.storage.sql.exec("UPDATE users SET role='admin', status='active' WHERE email = ?", email); - } + } else this.ctx.storage.sql.exec("UPDATE users SET role='admin', status='active' WHERE email = ?", email); return Response.json({ ok: true }); } @@ -114,8 +106,7 @@ export class UserAuthDO extends DurableObject { const password = String(body.password ?? ""); if (!email || !name || password.length < 8) return Response.json({ error: "Name, email and an 8+ character password are required" }, { status: 400 }); if (!email.includes("@")) return Response.json({ error: "Invalid email address" }, { status: 400 }); - const existing = this.ctx.storage.sql.exec("SELECT email FROM users WHERE email = ?", email).toArray(); - if (existing.length > 0) return Response.json({ error: "An account with this email already exists" }, { status: 409 }); + if (this.ctx.storage.sql.exec("SELECT email FROM users WHERE email = ?", email).toArray().length > 0) return Response.json({ error: "An account with this email already exists" }, { status: 409 }); const passwordHash = await hashPassword(password); this.ctx.storage.sql.exec("INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", email, name, "employee", "pending", passwordHash, new Date().toISOString()); return Response.json({ status: "pending" }, { status: 201 }); @@ -125,7 +116,7 @@ export class UserAuthDO extends DurableObject { const email = normalizeEmail(String(body.email ?? "")); const password = String(body.password ?? ""); const row = this.ctx.storage.sql.exec("SELECT * FROM users WHERE email = ?", email).toArray()[0] as UserRecord | undefined; - if (!row || !(await verifyPassword(password, row.passwordHash))) return Response.json({ error: "Invalid email or password" }, { status: 401 }); + if (!row || !(await verifyPassword(password, row.password_hash))) return Response.json({ error: "Invalid email or password" }, { status: 401 }); if (row.status !== "active") return Response.json({ error: row.status === "pending" ? "Your account is awaiting administrator approval" : "Your account is disabled" }, { status: 403 }); const token = randomToken(); const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS; @@ -150,14 +141,12 @@ export class UserAuthDO extends DurableObject { const users = this.ctx.storage.sql.exec("SELECT email,name,role,status,created_at FROM users ORDER BY created_at ASC").toArray(); return Response.json({ users: users.map(publicUser) }); } - if (url.pathname === "/admin/approve" && request.method === "POST") { const email = normalizeEmail(String(body.email ?? "")); this.ctx.storage.sql.exec("UPDATE users SET status='active' WHERE email=? AND role='employee'", email); const row = this.ctx.storage.sql.exec("SELECT email,name,role,status,created_at FROM users WHERE email=?", email).toArray()[0]; return row ? Response.json({ user: publicUser(row) }) : Response.json({ error: "User not found" }, { status: 404 }); } - if (url.pathname === "/admin/status" && request.method === "POST") { const email = normalizeEmail(String(body.email ?? "")); const status = String(body.status ?? ""); @@ -165,7 +154,6 @@ export class UserAuthDO extends DurableObject { this.ctx.storage.sql.exec("UPDATE users SET status=? WHERE email=? AND role='employee'", status, email); return Response.json({ ok: true }); } - if (url.pathname === "/admin/reset-password" && request.method === "POST") { const email = normalizeEmail(String(body.email ?? "")); const password = String(body.password ?? ""); From 28ebda62bbf02b2e66a807e5cafbbe01124802f5 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:00:47 +0800 Subject: [PATCH 019/140] Harden session gates for agents and MCP --- workers/app.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/workers/app.ts b/workers/app.ts index ae311c315..6bc9c1d70 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -69,6 +69,23 @@ app.use("/api/*", async (c, next) => { return next(); }); +// The Agents API is reachable outside the React UI, so require an application session here too. +app.use("/agents/*", async (c, next) => { + const user = await getSessionUser(c.env, c.req.raw); + if (!user) return c.json({ error: "Authentication required" }, 401); + return next(); +}); + +// MCP currently has no request-scoped mailbox identity inside McpAgent. Until its tools +// can consume the authenticated user safely, keep this integration admin-only rather than +// risk exposing another mailbox through an MCP tool argument. +app.use("/mcp*", async (c, next) => { + const user = await getSessionUser(c.env, c.req.raw); + if (!user) return c.json({ error: "Authentication required" }, 401); + if (user.role !== "admin") return c.json({ error: "MCP access is currently restricted to administrators" }, 403); + return next(); +}); + async function requireAdmin(c: any): Promise { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); @@ -200,4 +217,4 @@ export default { throw e; } }, -}; +}; \ No newline at end of file From 49492b274b3efcc90a7546e757b54ef57dcc4a1c Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:00:57 +0800 Subject: [PATCH 020/140] Clean up auth route imports --- app/routes/auth.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/routes/auth.tsx b/app/routes/auth.tsx index 159792a37..f5a65ee66 100644 --- a/app/routes/auth.tsx +++ b/app/routes/auth.tsx @@ -1,6 +1,6 @@ import { Button, Input } from "@cloudflare/kumo"; import { useState } from "react"; -import { Link as RouterLink, useLocation, useNavigate } from "react-router"; +import { Link as RouterLink, useLocation } from "react-router"; import { login, register } from "~/services/auth"; export function meta() { @@ -9,7 +9,6 @@ export function meta() { export default function AuthRoute() { const location = useLocation(); - const navigate = useNavigate(); const isRegister = location.pathname === "/register"; const [name, setName] = useState(""); const [email, setEmail] = useState(""); @@ -78,4 +77,4 @@ export default function AuthRoute() {
); -} +} \ No newline at end of file From 6dabe7a15eeef2aaafdd501a2b9cd8a9bab00ebd Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:01:08 +0800 Subject: [PATCH 021/140] Ensure seeded admin has a mailbox --- workers/lib/auth.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/workers/lib/auth.ts b/workers/lib/auth.ts index 19ace4413..e5a4ef1f2 100644 --- a/workers/lib/auth.ts +++ b/workers/lib/auth.ts @@ -63,11 +63,25 @@ export function isAdmin(user: AuthUser): boolean { export async function seedAdmin(env: Env): Promise { if (!env.ADMIN_PASSWORD) return; - const email = (env.ADMIN_EMAIL || "admin@astratradehk.com").toLowerCase(); + const email = (env.ADMIN_EMAIL || "admin@astratradehk.com").trim().toLowerCase(); const stub = env.USER_AUTH.get(env.USER_AUTH.idFromName("global")); await stub.fetch("https://user-auth/seed-admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password: env.ADMIN_PASSWORD }), }); -} + + // Keep the administrator usable even on a fresh deployment where no mailbox exists yet. + const key = `mailboxes/${email}.json`; + if (!(await env.BUCKET.head(key))) { + const settings = { + fromName: "Administrator", + forwarding: { enabled: false, email: "" }, + signature: { enabled: false, text: "" }, + autoReply: { enabled: false, subject: "", message: "" }, + }; + await env.BUCKET.put(key, JSON.stringify(settings)); + const mailbox = env.MAILBOX.get(env.MAILBOX.idFromName(email)); + await mailbox.getFolders(); + } +} \ No newline at end of file From 52793f35e61d83b300c367ddd7ac29840e7e6129 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:01:28 +0800 Subject: [PATCH 022/140] Use explicit MCP middleware routes --- workers/app.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/workers/app.ts b/workers/app.ts index 6bc9c1d70..769422b3c 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -69,22 +69,20 @@ app.use("/api/*", async (c, next) => { return next(); }); -// The Agents API is reachable outside the React UI, so require an application session here too. app.use("/agents/*", async (c, next) => { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); return next(); }); -// MCP currently has no request-scoped mailbox identity inside McpAgent. Until its tools -// can consume the authenticated user safely, keep this integration admin-only rather than -// risk exposing another mailbox through an MCP tool argument. -app.use("/mcp*", async (c, next) => { +const requireMcpAdmin = async (c: any, next: () => Promise) => { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); if (user.role !== "admin") return c.json({ error: "MCP access is currently restricted to administrators" }, 403); return next(); -}); +}; +app.use("/mcp", requireMcpAdmin); +app.use("/mcp/*", requireMcpAdmin); async function requireAdmin(c: any): Promise { const user = await getSessionUser(c.env, c.req.raw); From d2f5abc172a8291570da84e8b4d573e92bc8eca2 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:02:00 +0800 Subject: [PATCH 023/140] Add admin-managed login background --- workers/app.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/workers/app.ts b/workers/app.ts index 769422b3c..973a809e1 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -27,6 +27,7 @@ declare module "react-router" { } const requestHandler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE); +const LOGIN_BACKGROUND_KEY = "system/login-background"; function getAccessUrls(teamDomain: string) { const certsPath = "/cdn-cgi/access/certs"; @@ -91,6 +92,15 @@ async function requireAdmin(c: any): Promise { return user; } +app.get("/api/v1/auth/login-background", async (c) => { + const object = await c.env.BUCKET.get(LOGIN_BACKGROUND_KEY); + if (!object) return c.body(null, 404); + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set("Cache-Control", "public, max-age=300"); + return new Response(object.body, { headers }); +}); + app.post("/api/v1/auth/register", async (c) => { const body = await c.req.json().catch(() => null) as Record | null; const email = String(body?.email ?? "").trim().toLowerCase(); @@ -185,6 +195,25 @@ app.post("/api/v1/admin/reset-password", async (c) => { return new Response(response.body, response); }); +app.post("/api/v1/admin/login-background", async (c) => { + const admin = await requireAdmin(c); + if (admin instanceof Response) return admin; + const form = await c.req.raw.formData().catch(() => null); + const file = form?.get("file"); + if (!(file instanceof File)) return c.json({ error: "Image file is required" }, 400); + if (!file.type.startsWith("image/")) return c.json({ error: "Only image files are allowed" }, 400); + if (file.size > 5 * 1024 * 1024) return c.json({ error: "Image must be 5 MB or smaller" }, 400); + await c.env.BUCKET.put(LOGIN_BACKGROUND_KEY, file.stream(), { httpMetadata: { contentType: file.type } }); + return c.json({ ok: true }); +}); + +app.delete("/api/v1/admin/login-background", async (c) => { + const admin = await requireAdmin(c); + if (admin instanceof Response) return admin; + await c.env.BUCKET.delete(LOGIN_BACKGROUND_KEY); + return c.json({ ok: true }); +}); + app.get("/api/v1/mailboxes", async (c) => { const user = await getSessionUser(c.env, c.req.raw); if (!user) return c.json({ error: "Authentication required" }, 401); From 1c0b7c526dc2ea54f083cf64a8dfa1fbc559d3e0 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:02:07 +0800 Subject: [PATCH 024/140] Add login background admin API --- app/services/admin.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/services/admin.ts b/app/services/admin.ts index 39528ea92..80e98c967 100644 --- a/app/services/admin.ts +++ b/app/services/admin.ts @@ -23,4 +23,15 @@ export const adminApi = { approve: (email: string) => call("/api/v1/admin/approve", "POST", { email }), setStatus: (email: string, status: AdminUser["status"]) => call("/api/v1/admin/status", "POST", { email, status }), resetPassword: (email: string, password: string) => call("/api/v1/admin/reset-password", "POST", { email, password }), -}; + uploadLoginBackground: async (file: File) => { + const form = new FormData(); + form.set("file", file); + const response = await fetch("/api/v1/admin/login-background", { method: "POST", credentials: "same-origin", body: form }); + const data = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`); + }, + removeLoginBackground: async () => { + const response = await fetch("/api/v1/admin/login-background", { method: "DELETE", credentials: "same-origin" }); + if (!response.ok) throw new Error("Unable to remove login background"); + }, +}; \ No newline at end of file From d2104c4ebf184c02bccf2c4a2a6c09d4fd49711a Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:02:19 +0800 Subject: [PATCH 025/140] Add login background management to admin --- app/routes/admin.tsx | 49 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index 07deb4cfb..c32fbe7c6 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -1,5 +1,5 @@ import { Button, Input } from "@cloudflare/kumo"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { adminApi, type AdminUser } from "~/services/admin"; export function meta() { @@ -13,6 +13,8 @@ export default function AdminRoute() { const [resetEmail, setResetEmail] = useState(null); const [newPassword, setNewPassword] = useState(""); const [error, setError] = useState(null); + const [backgroundBusy, setBackgroundBusy] = useState(false); + const fileRef = useRef(null); const load = async () => { try { @@ -40,14 +42,39 @@ export default function AdminRoute() { try { await adminApi.resetPassword(resetEmail, newPassword); setResetEmail(null); setNewPassword(""); } catch (err) { setError(err instanceof Error ? err.message : "Password reset failed"); } finally { setBusy(null); } }; + const uploadBackground = async (file: File) => { + setBackgroundBusy(true); + setError(null); + try { + await adminApi.uploadLoginBackground(file); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to upload background"); + } finally { + setBackgroundBusy(false); + if (fileRef.current) fileRef.current.value = ""; + } + }; + + const removeBackground = async () => { + setBackgroundBusy(true); + setError(null); + try { + await adminApi.removeLoginBackground(); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to remove background"); + } finally { setBackgroundBusy(false); } + }; + return (
-
-
+
+

Employee Management

Approve accounts and manage mailbox access.

- {error &&
{error}
} + + {error &&
{error}
} +
{loading ?
Loading…
: users.map((user, index) => (
@@ -61,6 +88,18 @@ export default function AdminRoute() {
))}
+ +
+
+

Login background

+

Upload a JPG, PNG, WebP or other image up to 5 MB. It will be shown behind the employee login screen.

+
+
+ { const file = e.target.files?.[0]; if (file) void uploadBackground(file); }} /> + + +
+
{resetEmail &&
@@ -73,4 +112,4 @@ export default function AdminRoute() {
}
); -} +} \ No newline at end of file From 60d91eb291582f9e5098c172714e0aeecf538596 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:02:30 +0800 Subject: [PATCH 026/140] Polish login screen with configurable background --- app/routes/auth.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/routes/auth.tsx b/app/routes/auth.tsx index f5a65ee66..809d2a062 100644 --- a/app/routes/auth.tsx +++ b/app/routes/auth.tsx @@ -32,6 +32,7 @@ export default function AuthRoute() { await register(name, email, password); setMessage("Registration submitted. An administrator must approve your account before you can sign in."); setName(""); + setEmail(""); setPassword(""); setConfirm(""); } else { @@ -47,12 +48,14 @@ export default function AuthRoute() { return (
+
+
-
+
-
-

Agentic Inbox

-

{isRegister ? "Create your company mailbox account" : "Sign in to your company mailbox"}

+
+

Agentic Inbox

+

{isRegister ? "Create your company mailbox account" : "Sign in to your company mailbox"}

@@ -67,11 +70,11 @@ export default function AuthRoute() {
-
+
{isRegister ? ( - <>Already have an account? Sign in + <>Already have an account? Sign in ) : ( - <>Need an account? Employee registration + <>Need an account? Employee registration )}
From 67f97f88dd42d878023bfc6dcdcb539cfb620776 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:03:23 +0800 Subject: [PATCH 027/140] Harden admin seeding and prevent admin address hijacking --- workers/userAuth.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/workers/userAuth.ts b/workers/userAuth.ts index f08d94d8a..f1cfdab56 100644 --- a/workers/userAuth.ts +++ b/workers/userAuth.ts @@ -92,11 +92,16 @@ export class UserAuthDO extends DurableObject { const email = normalizeEmail(String(body.email ?? "")); const password = String(body.password ?? ""); if (!email || password.length < 8) return Response.json({ error: "Invalid admin credentials" }, { status: 400 }); - const existing = this.ctx.storage.sql.exec("SELECT email FROM users WHERE email = ?", email).toArray(); - if (existing.length === 0) { + const existing = this.ctx.storage.sql.exec("SELECT * FROM users WHERE email = ?", email).toArray()[0] as UserRecord | undefined; + if (!existing) { const passwordHash = await hashPassword(password); this.ctx.storage.sql.exec("INSERT INTO users (email,name,role,status,password_hash,created_at) VALUES (?,?,?,?,?,?)", email, "Administrator", "admin", "active", passwordHash, new Date().toISOString()); - } else this.ctx.storage.sql.exec("UPDATE users SET role='admin', status='active' WHERE email = ?", email); + } else if (existing.role !== "admin") { + const passwordHash = await hashPassword(password); + this.ctx.storage.sql.exec("UPDATE users SET name='Administrator', role='admin', status='active', password_hash=? WHERE email = ?", passwordHash, email); + } else { + this.ctx.storage.sql.exec("UPDATE users SET status='active' WHERE email = ?", email); + } return Response.json({ ok: true }); } @@ -166,4 +171,4 @@ export class UserAuthDO extends DurableObject { return Response.json({ error: "Not found" }, { status: 404 }); } -} +} \ No newline at end of file From 1f52799e71eb67047e8ad6d0c0ee50d458b0f394 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:03:46 +0800 Subject: [PATCH 028/140] Reserve administrator email from employee registration --- workers/app.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workers/app.ts b/workers/app.ts index 973a809e1..4d1be3171 100644 --- a/workers/app.ts +++ b/workers/app.ts @@ -108,8 +108,10 @@ app.post("/api/v1/auth/register", async (c) => { const password = String(body?.password ?? ""); const domains = (c.env.DOMAINS || "").split(",").map((d) => d.trim().toLowerCase()).filter(Boolean); const domain = email.split("@")[1] || ""; + const adminEmail = (c.env.ADMIN_EMAIL || "admin@astratradehk.com").trim().toLowerCase(); if (!email || !name || password.length < 8) return c.json({ error: "Name, company email and an 8+ character password are required" }, 400); if (!domains.some((d) => domain === d)) return c.json({ error: "Registration is restricted to the company email domain" }, 403); + if (email === adminEmail) return c.json({ error: "This address is reserved for the administrator" }, 403); await seedAdmin(c.env); const stub = c.env.USER_AUTH.get(c.env.USER_AUTH.idFromName("global")); const response = await stub.fetch("https://user-auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, name, password }) }); From 15dabb1de3cbd43df87ae8a2d1a104eb3dd15fc8 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:15:05 +0800 Subject: [PATCH 029/140] feat: add resilient post-delivery forwarding and Telegram notifications --- workers/lib/post-delivery.ts | 139 +++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 workers/lib/post-delivery.ts diff --git a/workers/lib/post-delivery.ts b/workers/lib/post-delivery.ts new file mode 100644 index 000000000..f467549c2 --- /dev/null +++ b/workers/lib/post-delivery.ts @@ -0,0 +1,139 @@ +import { sendEmail } from "../email-sender"; +import type { Env } from "../types"; + +export interface DeliveredEmail { + mailboxId: string; + emailId: string; + sender: string; + recipient: string; + subject: string; + body: string; + date: string; + messageId?: string | null; +} + +type NotificationSettings = { + forwarding?: { + enabled?: boolean; + email?: string; + includeInternal?: boolean; + }; + telegram?: { + enabled?: boolean; + botToken?: string; + chatId?: string; + includeInternal?: boolean; + }; +}; + +function extractAddresses(value: string): string[] { + return value + .split(/[;,]/) + .map((v) => v.trim().toLowerCase()) + .filter(Boolean); +} + +function isInternal(delivery: DeliveredEmail, env: Env): boolean { + const domains = String(env.DOMAINS || "") + .split(",") + .map((d) => d.trim().toLowerCase()) + .filter(Boolean); + const recipients = extractAddresses(delivery.recipient); + const sender = delivery.sender.toLowerCase(); + const isInternalAddress = (address: string) => + domains.some((domain) => address.endsWith(`@${domain}`)); + return isInternalAddress(sender) && recipients.some(isInternalAddress); +} + +function renderForwardBody(email: DeliveredEmail): string { + return [ + `---------- Forwarded message ----------`, + `From: ${email.sender}`, + `To: ${email.recipient}`, + `Date: ${email.date}`, + `Subject: ${email.subject}`, + ``, + email.body || "", + ].join("\n"); +} + +function telegramText(email: DeliveredEmail): string { + const body = (email.body || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); + const snippet = body.length > 700 ? `${body.slice(0, 700)}…` : body; + return [ + `📧 New email: ${email.mailboxId}`, + `From: ${email.sender}`, + `Subject: ${email.subject || "(no subject)"}`, + snippet ? `\n${snippet}` : "", + ].join("\n"); +} + +async function notifyTelegram(settings: NotificationSettings["telegram"], email: DeliveredEmail) { + if (!settings?.enabled || !settings.botToken || !settings.chatId) return; + const response = await fetch(`https://api.telegram.org/bot${encodeURIComponent(settings.botToken)}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: settings.chatId, text: telegramText(email), disable_web_page_preview: true }), + }); + if (!response.ok) throw new Error(`Telegram notification failed: HTTP ${response.status}`); +} + +/** + * Runs after an email has been durably stored. Notification failures are + * deliberately isolated from mailbox delivery so a broken Telegram bot or + * forwarding destination can never make an email disappear. + * + * The includeInternal flags are important: internal mail follows the same + * post-delivery pipeline as external mail instead of being silently skipped. + */ +export async function runPostDelivery( + env: Env, + executionCtx: ExecutionContext, + email: DeliveredEmail, + settings: NotificationSettings, +) { + const internal = isInternal(email, env); + const forwarding = settings.forwarding; + const telegram = settings.telegram; + + if (internal && forwarding?.includeInternal === false && telegram?.includeInternal === false) return; + + const tasks: Promise[] = []; + + if ( + forwarding?.enabled && + forwarding.email && + (!internal || forwarding.includeInternal !== false) + ) { + const target = forwarding.email.trim().toLowerCase(); + if (target && target !== email.mailboxId.toLowerCase() && !extractAddresses(email.recipient).includes(target)) { + tasks.push( + sendEmail(env.EMAIL, { + to: target, + from: email.mailboxId, + subject: `[Forwarded] ${email.subject || "(no subject)"}`, + text: renderForwardBody(email), + headers: { + "X-Agentic-Inbox-Forwarded": "1", + ...(email.messageId ? { "X-Agentic-Inbox-Original-Message-Id": email.messageId } : {}), + }, + }), + ); + } + } + + if (telegram?.enabled && (!internal || telegram.includeInternal !== false)) { + tasks.push(notifyTelegram(telegram, email)); + } + + if (tasks.length === 0) return; + executionCtx.waitUntil( + Promise.allSettled(tasks).then((results) => { + for (const result of results) { + if (result.status === "rejected") { + console.error("Post-delivery notification failed:", result.reason instanceof Error ? result.reason.message : result.reason); + } + } + }), + ); +} From 35a55731b4a1c6f9f583444ce9b428da7c6359c1 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:27:26 +0800 Subject: [PATCH 030/140] feat: harden post-delivery settings and forwarding loop checks --- workers/lib/post-delivery.ts | 57 +++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/workers/lib/post-delivery.ts b/workers/lib/post-delivery.ts index f467549c2..430ef8b48 100644 --- a/workers/lib/post-delivery.ts +++ b/workers/lib/post-delivery.ts @@ -73,18 +73,45 @@ async function notifyTelegram(settings: NotificationSettings["telegram"], email: const response = await fetch(`https://api.telegram.org/bot${encodeURIComponent(settings.botToken)}/sendMessage`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ chat_id: settings.chatId, text: telegramText(email), disable_web_page_preview: true }), + body: JSON.stringify({ + chat_id: settings.chatId, + text: telegramText(email), + disable_web_page_preview: true, + }), }); if (!response.ok) throw new Error(`Telegram notification failed: HTTP ${response.status}`); } +/** Load per-mailbox notification settings without exposing secrets to callers. */ +export async function getPostDeliverySettings(env: Env, mailboxId: string): Promise { + const object = await env.BUCKET.get(`mailboxes/${mailboxId.toLowerCase()}.json`); + if (!object) return {}; + const value = await object.json }>(); + return { + forwarding: value.forwarding + ? { + enabled: value.forwarding.enabled === true, + email: typeof value.forwarding.email === "string" ? value.forwarding.email : "", + includeInternal: value.forwarding.includeInternal !== false, + } + : undefined, + telegram: value.telegram + ? { + enabled: value.telegram.enabled === true, + botToken: typeof value.telegram.botToken === "string" ? value.telegram.botToken : "", + chatId: typeof value.telegram.chatId === "string" ? value.telegram.chatId : "", + includeInternal: value.telegram.includeInternal !== false, + } + : undefined, + }; +} + /** * Runs after an email has been durably stored. Notification failures are * deliberately isolated from mailbox delivery so a broken Telegram bot or * forwarding destination can never make an email disappear. * - * The includeInternal flags are important: internal mail follows the same - * post-delivery pipeline as external mail instead of being silently skipped. + * Internal mail intentionally uses the same pipeline as external mail. */ export async function runPostDelivery( env: Env, @@ -96,8 +123,6 @@ export async function runPostDelivery( const forwarding = settings.forwarding; const telegram = settings.telegram; - if (internal && forwarding?.includeInternal === false && telegram?.includeInternal === false) return; - const tasks: Promise[] = []; if ( @@ -106,7 +131,21 @@ export async function runPostDelivery( (!internal || forwarding.includeInternal !== false) ) { const target = forwarding.email.trim().toLowerCase(); - if (target && target !== email.mailboxId.toLowerCase() && !extractAddresses(email.recipient).includes(target)) { + const recipientAddresses = extractAddresses(email.recipient); + const originalMessageId = email.messageId?.trim(); + const alreadyForwarded = !originalMessageId + ? false + : originalMessageId.toLowerCase().startsWith("forwarded:"); + + // Never forward to the mailbox itself, never forward when the configured + // destination is already one of the original recipients, and never create + // a simple forwarding loop. + if ( + target && + target !== email.mailboxId.toLowerCase() && + !recipientAddresses.includes(target) && + !alreadyForwarded + ) { tasks.push( sendEmail(env.EMAIL, { to: target, @@ -127,11 +166,15 @@ export async function runPostDelivery( } if (tasks.length === 0) return; + executionCtx.waitUntil( Promise.allSettled(tasks).then((results) => { for (const result of results) { if (result.status === "rejected") { - console.error("Post-delivery notification failed:", result.reason instanceof Error ? result.reason.message : result.reason); + console.error( + "Post-delivery notification failed:", + result.reason instanceof Error ? result.reason.message : result.reason, + ); } } }), From bc91bd0c06cea0dae3109da3b64e43a918c20af1 Mon Sep 17 00:00:00 2001 From: guiming99 <153085985+guiming99@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:28:28 +0800 Subject: [PATCH 031/140] feat: add forwarding and Telegram notification settings UI --- app/routes/settings.tsx | 152 ++++++++++++++++++++++------------------ 1 file changed, 84 insertions(+), 68 deletions(-) diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index a4634cda4..d96ee3e82 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -1,15 +1,13 @@ // Copyright (c) 2026 Cloudflare, Inc. -// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// Licensed under the Apache 2.0 license found in the LICENSE file and at: // https://opensource.org/licenses/Apache-2.0 import { Badge, Button, Input, Loader, useKumoToastManager } from "@cloudflare/kumo"; -import { RobotIcon, ArrowCounterClockwiseIcon } from "@phosphor-icons/react"; +import { RobotIcon, ArrowCounterClockwiseIcon, PaperPlaneTiltIcon, TelegramLogoIcon } from "@phosphor-icons/react"; import { useEffect, useState } from "react"; import { useParams } from "react-router"; import { useMailbox, useUpdateMailbox } from "~/queries/mailboxes"; -// Placeholder shown in the textarea when no custom prompt is set. -// The authoritative default prompt lives in workers/agent/index.ts (DEFAULT_SYSTEM_PROMPT). const PROMPT_PLACEHOLDER = `You are an email assistant that helps manage this inbox. You read emails, draft replies, and help organize conversations.\n\nWrite like a real person. Short, direct, flowing prose. Plain text only.\n\n(Leave empty to use the full built-in default prompt)`; export default function SettingsRoute() { @@ -20,46 +18,71 @@ export default function SettingsRoute() { const [displayName, setDisplayName] = useState(""); const [agentPrompt, setAgentPrompt] = useState(""); + const [forwardEnabled, setForwardEnabled] = useState(false); + const [forwardEmail, setForwardEmail] = useState(""); + const [forwardInternal, setForwardInternal] = useState(true); + const [telegramEnabled, setTelegramEnabled] = useState(false); + const [telegramToken, setTelegramToken] = useState(""); + const [telegramChatId, setTelegramChatId] = useState(""); + const [telegramInternal, setTelegramInternal] = useState(true); const [isSaving, setIsSaving] = useState(false); useEffect(() => { - if (mailbox) { - setDisplayName(mailbox.settings?.fromName || mailbox.name || ""); - setAgentPrompt(mailbox.settings?.agentSystemPrompt || ""); - } + if (!mailbox) return; + setDisplayName(mailbox.settings?.fromName || mailbox.name || ""); + setAgentPrompt(mailbox.settings?.agentSystemPrompt || ""); + setForwardEnabled(mailbox.settings?.forwarding?.enabled === true); + setForwardEmail(mailbox.settings?.forwarding?.email || ""); + setForwardInternal(mailbox.settings?.forwarding?.includeInternal !== false); + setTelegramEnabled(mailbox.settings?.telegram?.enabled === true); + setTelegramToken(mailbox.settings?.telegram?.botToken || ""); + setTelegramChatId(mailbox.settings?.telegram?.chatId || ""); + setTelegramInternal(mailbox.settings?.telegram?.includeInternal !== false); }, [mailbox]); const handleSave = async () => { if (!mailbox || !mailboxId) return; + if (forwardEnabled && !forwardEmail.trim()) { + toastManager.add({ title: "Enter a forwarding email address", variant: "error" }); + return; + } + if (telegramEnabled && (!telegramToken.trim() || !telegramChatId.trim())) { + toastManager.add({ title: "Enter the Telegram Bot Token and Chat ID", variant: "error" }); + return; + } setIsSaving(true); const settings = { ...mailbox.settings, fromName: displayName, agentSystemPrompt: agentPrompt.trim() || undefined, + forwarding: { + ...mailbox.settings?.forwarding, + enabled: forwardEnabled, + email: forwardEmail.trim().toLowerCase(), + includeInternal: forwardInternal, + }, + telegram: { + ...mailbox.settings?.telegram, + enabled: telegramEnabled, + botToken: telegramToken.trim(), + chatId: telegramChatId.trim(), + includeInternal: telegramInternal, + }, }; try { await updateMailboxMutation.mutateAsync({ mailboxId, settings }); toastManager.add({ title: "Settings saved!" }); } catch { - toastManager.add({ - title: "Failed to save settings", - variant: "error", - }); + toastManager.add({ title: "Failed to save settings", variant: "error" }); } finally { setIsSaving(false); } }; - const handleResetPrompt = () => { - setAgentPrompt(""); - }; + const handleResetPrompt = () => setAgentPrompt(""); if (!mailbox) { - return ( -
- -
- ); + return
; } const isCustomPrompt = agentPrompt.trim().length > 0; @@ -67,70 +90,63 @@ export default function SettingsRoute() { return (

Settings

-
- {/* Account */}
-
- Account -
+
Account
- setDisplayName(e.target.value)} - /> + setDisplayName(e.target.value)} />
- {/* Agent System Prompt */}
- - AI Agent Prompt - - {isCustomPrompt ? ( - Custom - ) : ( - Default - )} + AI Agent Prompt + {isCustomPrompt ? Custom : Default}
- {isCustomPrompt && ( - - )} + {isCustomPrompt && } +
+

Customize how the AI agent behaves for this mailbox. Leave empty to use the built-in default prompt.

+