Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions workers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MailboxContext>;

Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -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<string, unknown> };
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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -276,15 +306,19 @@ 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);
return f ? c.json(f, 201) : c.json({ error: "Folder with this name already exists" }, 409);
});

app.put("/api/v1/mailboxes/:mailboxId/folders/:id", async (c: AppContext) => {
const { name } = (await c.req.json()) as { name: string };
const 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);
});
Expand Down
30 changes: 30 additions & 0 deletions workers/lib/validation.ts
Original file line number Diff line number Diff line change
@@ -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<T> =
| { success: true; data: T }
| { success: false; response: Response };

export async function parseJsonBody<T>(
c: Context,
schema: z.ZodType<T>,
): Promise<ParsedJsonBody<T>> {
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 };
}
9 changes: 7 additions & 2 deletions workers/routes/reply-forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@ 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<MailboxContext>;
type RateLimitStub = { checkSendRateLimit: () => Promise<string | null> };

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;
Expand Down Expand Up @@ -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;
Expand Down