// Copyright (c) 2026 Doforu // Copyright (c) 2026 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE-APACHE file or at: // https://opensource.org/licenses/Apache-2.0 // Modifications Copyright (c) 2026 Doforu, distributed under the AGPL-3.0-only (see LICENSE). import { type Context, Hono } from "hono"; import setup from "./setup"; import { cors } from "hono/cors"; import PostalMime from "postal-mime"; import { z } from "zod"; import { sendEmailFromMailbox } from "./email-sender"; import { storeAttachments, type StoredAttachment } from "./lib/attachments"; import { validateSender, SenderValidationError, generateMessageId, buildThreadingHeaders, listMailboxes, } from "./lib/email-helpers"; import { SendEmailRequestSchema } from "./lib/schemas"; import { handleReplyEmail, handleForwardEmail } from "./routes/reply-forward"; import { Folders } from "../shared/folders"; import { resolveLocale, DEFAULT_LOCALE } from "../shared/i18n/config"; import { getBackendT } from "../shared/i18n/translate"; import type { Locale } from "../shared/i18n/types"; // [i18n-foundation] Stable sentinel persisted to D1 instead of the localized // fallback text — see the module docstring in shared/ai-fallback.ts. import { AI_FALLBACK_SENTINEL } from "../shared/ai-fallback"; import { formatSenderWithAddress } from "../shared/participants"; import type { Env } from "./types"; import { requireMailbox, type D1MailboxContext } from "./lib/d1-middleware"; import { requireAuth, handleLogin, handleLogout, handleMe, handleAdminStatus, handleCreateAdmin } from "./lib/auth"; import { handleResendInbound } from "./inbound"; import { fetchWithTimeout } from "./lib/fetch-with-timeout"; import * as db from "./db"; import type { SearchFilterOptions, EmailFull } from "./db"; import { TOOL_DEFINITIONS, executeToolCall } from "./lib/tool-dispatch"; // Agent surface: admin management endpoints for the global agent API keys // (cookie/session auth), mounted under /api/v1/* below, plus the external // agent gateway itself (`./routes/agent-api`, exposing POST /mcp, GET /tools // and POST /tools/call), which is mounted at the ROOT so it stays outside the // cookie-based `requireAuth` that covers `/api/v1/*`. import { agentApiKeysRoute } from "./routes/agent-api-keys"; import { agentApiRoute } from "./routes/agent-api"; type AppContext = Context; // Local type for AI text generation output (available in CF Workers runtime) export interface AiToolCall { id: string; type: "function"; function: { name: string; arguments: string; }; } export interface AiTextGenerationOutput { response?: string; choices?: { message?: { content?: string | null; tool_calls?: AiToolCall[]; }; }[]; tool_calls?: Array<{ name: string; arguments: Record; }>; } export interface AiChatMessage { role: string; content: string | null; tool_calls?: AiToolCall[]; } // -- Request body schemas (kept for validation) --------------------- const CreateMailboxBody = z.object({ // NOTE: the schema is built once at module scope (no request-scoped `t`), // so this message stays English as a last-resort fallback. The handler // localizes the surfaced error via `api:validationFailed` instead. // // `trim()` + `toLowerCase()` normalise the address BEFORE the regex runs. // Without them a pasted local part carrying a stray space (" privacy", // "privacy ") failed the anchored regex and surfaced as a bare // "validation failed" that named no field and no character. Trimming is // safe: whitespace is never significant around an address, and the handler // already lower-cased the value, so the stored id/key is unchanged. email: z.string().trim().toLowerCase().regex(/^[a-z0-9*][a-z0-9.*_-]*@[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i, "Invalid email address"), name: z.string().min(1), settings: z.record(z.any()).optional(), // unvalidated — agentSystemPrompt goes straight to AI }); const DraftBody = z.object({ to: z.string().optional(), cc: z.string().optional(), bcc: z.string().optional(), subject: z.string().optional(), body: z.string(), in_reply_to: z.string().optional(), thread_id: z.string().optional(), draft_id: z.string().optional(), }); // -- Helpers -------------------------------------------------------- function slugify(text: string) { // can return "" for non-alphanumeric input return text.toString().toLowerCase() .replace(/\s+/g, "-").replace(/[^\w-]+/g, "") .replace(/--+/g, "-").replace(/^-+/, "").replace(/-+$/, ""); } function intQuery(c: AppContext, key: string): number | undefined { const v = c.req.query(key); if (!v) return undefined; const n = Number(v); return Number.isNaN(n) ? undefined : n; } function boolQuery(c: AppContext, key: string): boolean | undefined { const v = c.req.query(key); if (v === undefined || v === "") return undefined; return v === "true" || v === "1"; } const normalizeDomainStatus = (status: string | undefined): "pending" | "verified" | "failed" => { if (status === "verified") return "verified"; if (status === "failed" || status === "temporary_failure") return "failed"; return "pending"; }; // -- App & middleware ----------------------------------------------- const app = new Hono(); // [i18n-foundation] Resolve the request locale once and expose it on the // context, so API handlers can localize their responses via `getBackendT`. // // ⚠️ Localization applies to RESPONSE strings only. Anything persisted to D1 // (notably AI chat messages via `saveAiMessage`) MUST store stable data — user // input, enum values, ids — never pre-translated text. AI history is replayed // across sessions, so a stored translation would freeze the language it was // generated in and read as mixed-language after a switch. See // `shared/i18n/translate.ts` for the full note. app.use("/api/*", async (c, next) => { const locale = resolveLocale(c.req.raw); c.set("locale", locale); c.set("t", getBackendT(locale)); await next(); }); app.use("/api/*", cors({ origin: (origin) => { // Same-origin requests have no Origin header — allow them. if (!origin) return origin; // In development, allow localhost for Vite dev server. try { const url = new URL(origin); if (url.hostname === "localhost" || url.hostname === "127.0.0.1") return origin; } catch { /* invalid origin */ } // Block all other cross-origin requests. The app is served from the // same origin as the API, so legitimate browser requests never send // an Origin header. Returning undefined omits Access-Control-Allow-Origin. return undefined; }, })); // ====== Admin Authentication (login / logout / me) ====== app.post("/api/v1/auth/login", handleLogin); app.post("/api/v1/auth/logout", handleLogout); app.get("/api/v1/auth/me", handleMe); // First-run admin setup. Public by design, but only usable while no admin // account exists yet: the insert is guarded by `WHERE NOT EXISTS`, and every // later call returns 409. app.get("/api/v1/setup/admin/status", handleAdminStatus); app.post("/api/v1/setup/admin", handleCreateAdmin); // Protect all remaining /api/v1/* endpoints (login, inbound webhook and the // first-run setup bootstrap are exempted inside requireAuth). app.use("/api/v1/*", requireAuth); // Every /api/v1/mailboxes/:mailboxId/* route needs the R2 mailbox check, which // also populates c.var.db and c.var.mailboxId for the handlers below. app.use("/api/v1/mailboxes/:mailboxId/*", requireMailbox); // ====== 单个域名详情 ====== app.get("/api/v1/domains/:domainId", async (c) => { try { const db = c.env.DB; const domainId = c.req.param("domainId")!; const t = c.get("t"); const { getDomain } = await import("./db/index"); const domain = await getDomain(db, domainId); if (!domain) { return c.json({ error: t("api:domainNotFound") }, 404); } return c.json(domain, 200); } catch (error: any) { // [error-redaction] Never echo `error.message` to the client: a D1/drizzle // failure carries the failing SQL, bound parameters and column names. // Answer with the stable i18n copy and keep the exception server-side. console.error("Failed to get domain:", error); return c.json({ error: c.get("t")("api:failedToGetDomain") }, 500); } }); // ── Setup routes (mounted here so they sit behind the admin session) ── // The /api/v1/setup/* helpers below talk to Resend and the Cloudflare API // using credentials stored in the database, so they require a session. Only // the first-run bootstrap endpoints in ./lib/auth are public. app.route("/", setup); // -- Platform Settings ------------------------------------------------ app.get("/api/v1/platform-settings/:key", async (c) => { const key = c.req.param("key")!; const value = await db.getSetting(c.env.DB, key); return c.json({ key, value }); }); app.put("/api/v1/platform-settings/:key", async (c) => { const key = c.req.param("key")!; const { value } = (await c.req.json()) as { value: string }; if (typeof value !== "string") { return c.json({ error: c.get("t")("api:valueMustBeString") }, 400); } await db.setSetting(c.env.DB, key, value); return c.json({ key, value }); }); // -- Config --------------------------------------------------------- app.get("/api/v1/config", async (c) => { // Derive domains dynamically from existing mailboxes in R2 const allMailboxes = await listMailboxes(c.env.BUCKET); const domainSet = new Set(); for (const m of allMailboxes) { const domain = m.id.split("@")[1]; if (domain) domainSet.add(domain); } return c.json({ domains: Array.from(domainSet), emailAddresses: allMailboxes.map(m => m.id) }); }); // -- Mailboxes ------------------------------------------------------ /** * Turn a raw email body into a short, tag-free snippet for the dashboard. * * Order matters: `