diff --git a/workers/index.ts b/workers/index.ts index fd3359ce..b49b9114 100644 --- a/workers/index.ts +++ b/workers/index.ts @@ -20,6 +20,7 @@ import { handleReplyEmail, handleForwardEmail } from "./routes/reply-forward"; import { Folders } from "../shared/folders"; import type { Env } from "./types"; import { requireMailbox, type MailboxContext } from "./lib/mailbox"; +import { parseJsonBody } from "./lib/validation"; type AppContext = Context; @@ -42,6 +43,23 @@ const DraftBody = z.object({ draft_id: z.string().optional(), }); +const UpdateMailboxBody = z.object({ + settings: z.record(z.any()), +}); + +const UpdateEmailBody = z.object({ + read: z.boolean().optional(), + starred: z.boolean().optional(), +}); + +const MoveEmailBody = z.object({ + folderId: z.string().min(1), +}); + +const FolderBody = z.object({ + name: z.string().min(1), +}); + // -- Helpers -------------------------------------------------------- function slugify(text: string) { // can return "" for non-alphanumeric input @@ -100,7 +118,9 @@ app.get("/api/v1/mailboxes", async (c) => { }); app.post("/api/v1/mailboxes", async (c) => { - const { name, settings, email: rawEmail } = CreateMailboxBody.parse(await c.req.json()); + const parsed = await parseJsonBody(c, CreateMailboxBody); + if (!parsed.success) return parsed.response; + const { name, settings, email: rawEmail } = parsed.data; const email = rawEmail.toLowerCase(); const allowedAddresses = (c.env.EMAIL_ADDRESSES ?? []) as string[]; if (allowedAddresses.length > 0 && !allowedAddresses.map((a) => a.toLowerCase()).includes(email)) { @@ -125,7 +145,9 @@ app.get("/api/v1/mailboxes/:mailboxId", async (c) => { app.put("/api/v1/mailboxes/:mailboxId", async (c) => { const mailboxId = c.req.param("mailboxId")!; - const { settings } = (await c.req.json()) as { settings: Record }; + const parsed = await parseJsonBody(c, UpdateMailboxBody); + if (!parsed.success) return parsed.response; + const { settings } = parsed.data; const key = `mailboxes/${mailboxId}.json`; if (!(await c.env.BUCKET.head(key))) return c.json({ error: "Not found" }, 404); await c.env.BUCKET.put(key, JSON.stringify(settings)); @@ -167,7 +189,9 @@ app.get("/api/v1/mailboxes/:mailboxId/emails", async (c: AppContext) => { app.post("/api/v1/mailboxes/:mailboxId/emails", async (c: AppContext) => { const mailboxId = c.req.param("mailboxId")!; - const body = SendEmailRequestSchema.parse(await c.req.json()); + const parsed = await parseJsonBody(c, SendEmailRequestSchema); + if (!parsed.success) return parsed.response; + const body = parsed.data; const { to, cc, bcc, from, subject, html, text, attachments, in_reply_to, references, thread_id } = body; let toStr: string, fromEmail: string, fromDomain: string; @@ -213,7 +237,9 @@ app.post("/api/v1/mailboxes/:mailboxId/emails", async (c: AppContext) => { app.post("/api/v1/mailboxes/:mailboxId/drafts", async (c: AppContext) => { const mailboxId = c.req.param("mailboxId")!; - const { to, cc, bcc, subject, body, in_reply_to, thread_id, draft_id } = DraftBody.parse(await c.req.json()); + const parsed = await parseJsonBody(c, DraftBody); + if (!parsed.success) return parsed.response; + const { to, cc, bcc, subject, body, in_reply_to, thread_id, draft_id } = parsed.data; const stub = c.var.mailboxStub; if (draft_id) await stub.deleteEmail(draft_id); // not atomic — create-then-delete would be safer const messageId = crypto.randomUUID(); @@ -236,7 +262,9 @@ app.get("/api/v1/mailboxes/:mailboxId/emails/:id", async (c: AppContext) => { }); app.put("/api/v1/mailboxes/:mailboxId/emails/:id", async (c: AppContext) => { - const { read, starred } = (await c.req.json()) as { read?: boolean; starred?: boolean }; + const parsed = await parseJsonBody(c, UpdateEmailBody); + if (!parsed.success) return parsed.response; + const { read, starred } = parsed.data; const email = await c.var.mailboxStub.updateEmail(c.req.param("id")!, { read, starred }); return email ? c.json(email) : c.json({ error: "Email not found" }, 404); }); @@ -250,7 +278,9 @@ app.delete("/api/v1/mailboxes/:mailboxId/emails/:id", async (c: AppContext) => { }); app.post("/api/v1/mailboxes/:mailboxId/emails/:id/move", async (c: AppContext) => { - const { folderId } = (await c.req.json()) as { folderId: string }; + const parsed = await parseJsonBody(c, MoveEmailBody); + if (!parsed.success) return parsed.response; + const { folderId } = parsed.data; const success = await c.var.mailboxStub.moveEmail(c.req.param("id")!, folderId); return success ? c.json({ status: "moved" }) : c.json({ error: "Folder not found" }, 400); }); @@ -276,7 +306,9 @@ app.post("/api/v1/mailboxes/:mailboxId/emails/:id/forward", handleForwardEmail); app.get("/api/v1/mailboxes/:mailboxId/folders", async (c: AppContext) => c.json(await c.var.mailboxStub.getFolders())); app.post("/api/v1/mailboxes/:mailboxId/folders", async (c: AppContext) => { - const { name } = (await c.req.json()) as { name: string }; + const parsed = await parseJsonBody(c, FolderBody); + if (!parsed.success) return parsed.response; + const { name } = parsed.data; const slug = slugify(name); if (!slug) return c.json({ error: "Folder name must contain alphanumeric characters" }, 400); const f = await c.var.mailboxStub.createFolder(slug, name); @@ -284,7 +316,9 @@ app.post("/api/v1/mailboxes/:mailboxId/folders", async (c: AppContext) => { }); app.put("/api/v1/mailboxes/:mailboxId/folders/:id", async (c: AppContext) => { - const { name } = (await c.req.json()) as { name: string }; + const parsed = await parseJsonBody(c, FolderBody); + if (!parsed.success) return parsed.response; + const { name } = parsed.data; const f = await c.var.mailboxStub.updateFolder(c.req.param("id")!, name); return f ? c.json(f) : c.json({ error: "Folder not found" }, 404); }); diff --git a/workers/lib/validation.ts b/workers/lib/validation.ts new file mode 100644 index 00000000..08da9f9c --- /dev/null +++ b/workers/lib/validation.ts @@ -0,0 +1,30 @@ +// 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 { z } from "zod"; + +type ParsedJsonBody = + | { success: true; data: T } + | { success: false; response: Response }; + +export async function parseJsonBody( + c: Context, + schema: z.ZodType, +): Promise> { + const json = await c.req.json().catch(() => undefined); + const parsed = schema.safeParse(json); + + if (!parsed.success) { + return { + success: false, + response: c.json({ + error: "Invalid request body", + details: parsed.error.flatten(), + }, 400), + }; + } + + return { success: true, data: parsed.data }; +} diff --git a/workers/routes/reply-forward.ts b/workers/routes/reply-forward.ts index 950333c3..e4deb2f8 100644 --- a/workers/routes/reply-forward.ts +++ b/workers/routes/reply-forward.ts @@ -17,6 +17,7 @@ import { import { SendEmailRequestSchema } from "../lib/schemas"; import { Folders } from "../../shared/folders"; import type { MailboxContext } from "../lib/mailbox"; +import { parseJsonBody } from "../lib/validation"; type AppContext = Context; type RateLimitStub = { checkSendRateLimit: () => Promise }; @@ -24,7 +25,9 @@ type RateLimitStub = { checkSendRateLimit: () => Promise }; export async function handleReplyEmail(c: AppContext) { const mailboxId = c.req.param("mailboxId") ?? ""; const id = c.req.param("id") ?? ""; - const body = SendEmailRequestSchema.parse(await c.req.json()); + const parsed = await parseJsonBody(c, SendEmailRequestSchema); + if (!parsed.success) return parsed.response; + const body = parsed.data; const { to, cc, bcc, from, subject, html, text, attachments } = body; const stub = c.var.mailboxStub; @@ -115,7 +118,9 @@ export async function handleReplyEmail(c: AppContext) { export async function handleForwardEmail(c: AppContext) { const mailboxId = c.req.param("mailboxId") ?? ""; const id = c.req.param("id") ?? ""; - const body = SendEmailRequestSchema.parse(await c.req.json()); + const parsed = await parseJsonBody(c, SendEmailRequestSchema); + if (!parsed.success) return parsed.response; + const body = parsed.data; const { to, cc, bcc, from, subject, html, text, attachments } = body; const stub = c.var.mailboxStub;