src/server.ts
Rama main · réplica de lectura del repositorio original.
import { createApiApp } from "./api/hono";
import type { InboundEmailQueueMessage } from "./cloudflare/types";
import { deriveDevTestMailboxId } from "./db/seed-dev";
import { mailboxStub } from "./lib/mailbox-stub";
export { MailboxDurableObject } from "./do/mailbox-do";
const api = createApiApp();
async function timingSafeEqual(a: string, b: string): Promise<boolean> {
// Compare fixed-length digests instead of the raw strings so neither the early-exit
// behavior of `===` nor the input length itself can leak timing information.
const encoder = new TextEncoder();
const [digestA, digestB] = await Promise.all([
crypto.subtle.digest("SHA-256", encoder.encode(a)),
crypto.subtle.digest("SHA-256", encoder.encode(b)),
]);
const bytesA = new Uint8Array(digestA);
const bytesB = new Uint8Array(digestB);
let diff = 0;
for (let i = 0; i < bytesA.length; i++) {
diff |= bytesA[i]! ^ bytesB[i]!;
}
return diff === 0;
}
async function phase0DebugAuthorized(request: Request, env: Env): Promise<boolean> {
const token = env.PHASE0_DEBUG_TOKEN;
// Fail closed: debug endpoints are disabled entirely unless an operator explicitly
// configures PHASE0_DEBUG_TOKEN. An unset/empty token must never authorize access.
if (!token) {
return false;
}
const provided = request.headers.get("x-phase0-debug-token");
if (!provided) {
return false;
}
return timingSafeEqual(provided, token);
}
api.get("/api/debug/phase0/mailboxes/:mailboxId/schema", async (c) => {
if (!(await phase0DebugAuthorized(c.req.raw, c.env))) {
return c.notFound();
}
const mailboxId = c.req.param("mailboxId");
const stub = mailboxStub(c.env, mailboxId);
return stub.fetch("https://mailbox-do/debug/schema");
});
api.get("/api/debug/phase0/schema/:mailboxId", async (c) => {
if (!(await phase0DebugAuthorized(c.req.raw, c.env))) {
return c.notFound();
}
const mailboxId = c.req.param("mailboxId");
if (c.req.query("dry") === "1") {
return c.json({ ok: true, route: "schema", mailboxId });
}
const stub = mailboxStub(c.env, mailboxId);
return stub.fetch("https://mailbox-do/debug/schema");
});
api.get("/api/debug/phase0/mailboxes/:mailboxId", async (c) => {
if (!(await phase0DebugAuthorized(c.req.raw, c.env))) {
return c.notFound();
}
const mailboxId = c.req.param("mailboxId");
const stub = mailboxStub(c.env, mailboxId);
return stub.fetch("https://mailbox-do/debug");
});
api.get("/api/debug/phase0/r2/head", async (c) => {
if (!(await phase0DebugAuthorized(c.req.raw, c.env))) {
return c.notFound();
}
const key = c.req.query("key");
if (!key) {
return c.json({ error: "key required" }, 400);
}
const object = await c.env.MAIL_OBJECTS.head(key);
return c.json({
exists: Boolean(object),
key,
size: object?.size ?? null,
customMetadata: object?.customMetadata ?? null,
});
});
api.get("/api/debug/phase0/test-mailbox-id", async (c) => {
if (!(await phase0DebugAuthorized(c.req.raw, c.env))) {
return c.notFound();
}
return c.json({ mailboxId: await deriveDevTestMailboxId() });
});
api.get("/api/mailboxes/:mailboxId/ws", async (c) => {
const mailboxId = c.req.param("mailboxId");
const upgrade = c.req.header("Upgrade");
if (upgrade?.toLowerCase() !== "websocket") {
return c.text("Expected WebSocket upgrade", 426);
}
const doUrl = new URL("https://mailbox-do/ws");
doUrl.searchParams.set("mailboxId", mailboxId);
const stub = mailboxStub(c.env, mailboxId);
return stub.fetch(
new Request(doUrl.toString(), {
headers: c.req.raw.headers,
}),
);
});
// --- Transactional API: template management (session-protected, under /api/*) ---
/**
* Session + per-mailbox owner gate for the template routes.
*
* These routes used to stop at "is an owner of this deployment", while the key
* routes (and the docs, for both) also require the mailbox's D1 `owner_email` to be
* the caller. Templates decide what a live key sends, so they get the same gate.
* Returns a Response to send back, or null when the caller may proceed.
*/
async function templateRouteGate(
request: Request,
env: Env,
mailboxId: string,
): Promise<Response | null> {
const { requireAuth, assertMailboxAccess } = await import("./api/auth");
const auth = await requireAuth(request, env);
assertMailboxAccess(auth, mailboxId, env);
const { getMailboxForOwner } = await import("./db/d1");
const mailbox = await getMailboxForOwner(env.INDEX_DB, mailboxId, auth.email);
return mailbox ? null : Response.json({ error: "forbidden" }, { status: 403 });
}
api.post("/api/mailboxes/:mailboxId/transactional/templates", async (c) => {
const denied = await templateRouteGate(c.req.raw, c.env, c.req.param("mailboxId"));
if (denied) return denied;
const mailboxId = c.req.param("mailboxId");
const stub = mailboxStub(c.env, mailboxId);
return stub.fetch("https://mailbox-do/transactional/templates", {
method: "POST",
body: JSON.stringify(await c.req.json()),
headers: { "content-type": "application/json" },
});
});
api.get("/api/mailboxes/:mailboxId/transactional/templates", async (c) => {
const denied = await templateRouteGate(c.req.raw, c.env, c.req.param("mailboxId"));
if (denied) return denied;
const mailboxId = c.req.param("mailboxId");
const stub = mailboxStub(c.env, mailboxId);
return stub.fetch("https://mailbox-do/transactional/templates");
});
api.put("/api/mailboxes/:mailboxId/transactional/templates/:templateId", async (c) => {
const denied = await templateRouteGate(c.req.raw, c.env, c.req.param("mailboxId"));
if (denied) return denied;
const mailboxId = c.req.param("mailboxId");
const templateId = c.req.param("templateId");
const stub = mailboxStub(c.env, mailboxId);
return stub.fetch(`https://mailbox-do/transactional/templates/${templateId}`, {
method: "PUT",
body: JSON.stringify(await c.req.json()),
headers: { "content-type": "application/json" },
});
});
api.post("/api/mailboxes/:mailboxId/transactional/templates/:templateId/archive", async (c) => {
const denied = await templateRouteGate(c.req.raw, c.env, c.req.param("mailboxId"));
if (denied) return denied;
const mailboxId = c.req.param("mailboxId");
const templateId = c.req.param("templateId");
const stub = mailboxStub(c.env, mailboxId);
return stub.fetch(`https://mailbox-do/transactional/templates/${templateId}/archive`, {
method: "POST",
});
});
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
if (url.pathname === "/cdn-cgi/handler/email") {
const { handleLocalEmailSimulation } = await import("./cloudflare/local-email");
return handleLocalEmailSimulation(request, env, ctx);
}
if (url.pathname === "/api/debug/phase0/email") {
if (!(await phase0DebugAuthorized(request, env))) {
return new Response("Not found", { status: 404 });
}
const { handleLocalEmailSimulation } = await import("./cloudflare/local-email");
return handleLocalEmailSimulation(request, env, ctx);
}
if (
url.pathname.startsWith("/api/") ||
url.pathname === "/mcp" ||
// OAuth discovery documents (RFC 8414/9728) that MCP clients fetch at the
// root of the origin; see the /.well-known route in api/hono.ts.
url.pathname.startsWith("/.well-known/") ||
url.pathname === "/telegram/webhook"
) {
return api.fetch(request, env, ctx);
}
// --- Transactional API: external endpoint (outside /api/*, bypasses Access) ---
// Auth is handled by the API key in the DO, not Cloudflare Access.
// POST /v1/mailboxes/:mailboxId/transactional/messages
// GET /v1/mailboxes/:mailboxId/transactional/messages/:requestId
// JSON only, Cache-Control: no-store, body limit, no CORS, no query key.
if (url.pathname.startsWith("/v1/")) {
const transactionalMatch = url.pathname.match(
/^\/v1\/mailboxes\/([^/]+)\/transactional\/messages(\/([^/]+))?$/,
);
if (!transactionalMatch) {
return new Response("Not found", { status: 404 });
}
// Content-Type must be JSON for POST
if (
request.method === "POST" &&
!request.headers.get("content-type")?.startsWith("application/json")
) {
return new Response("Content-Type must be application/json", { status: 415 });
}
const mailboxId = transactionalMatch[1];
if (!mailboxId) return new Response("Bad request", { status: 400 });
const requestId = transactionalMatch[3];
const stub = mailboxStub(env, mailboxId);
if (request.method === "POST" && !requestId) {
// Body size limit (100KB), including requests without Content-Length.
const contentLength = request.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > 100_000) {
return new Response("Request too large", { status: 413 });
}
const body = await request.arrayBuffer();
if (body.byteLength > 100_000) {
return new Response("Request too large", { status: 413 });
}
// Send — forward with auth headers intact
const headers: Record<string, string> = {
Authorization: request.headers.get("Authorization") ?? "",
"Idempotency-Key": request.headers.get("Idempotency-Key") ?? "",
"content-type": "application/json",
};
// No Cache-Control for POST
const doResponse = await stub.fetch("https://mailbox-do/transactional/send", {
method: "POST",
headers,
body,
});
// Add no-store to response
const doBody = await doResponse.text();
return new Response(doBody, {
status: doResponse.status,
headers: { "Cache-Control": "no-store" },
});
}
if (request.method === "GET" && requestId) {
// Status — forward with auth headers intact
const doResponse = await stub.fetch(
`https://mailbox-do/transactional/requests/${requestId}`,
{
method: "GET",
headers: {
Authorization: request.headers.get("Authorization") ?? "",
},
},
);
const doBody = await doResponse.text();
return new Response(doBody, {
status: doResponse.status,
headers: { "Cache-Control": "no-store" },
});
}
return new Response("Method not allowed", { status: 405 });
}
const startHandler = (await import("@tanstack/react-start/server-entry")).default;
return startHandler.fetch(request);
},
async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) {
const { handleEmail } = await import("./cloudflare/email-handler");
return handleEmail(message, env, ctx);
},
// Explicit allowlist, no fallthrough. Every queue this Worker consumes —
// DLQs included — must name its consumer here; an unlisted queue throws
// instead of being handed to the inbound consumer, which would parse
// lifecycle events, notifications or dead letters as if they were mail. A
// deploy that adds a queue without a branch has to fail loudly.
async queue(batch: MessageBatch<InboundEmailQueueMessage>, env: Env, ctx: ExecutionContext) {
switch (batch.queue) {
case "inbox-mcp-inbound":
case "inbox-mcp-inbound-dev": {
const { handleInboundQueue } = await import("./cloudflare/queue-consumer");
return handleInboundQueue(batch, env, ctx);
}
case "inbox-mcp-email-events":
case "inbox-mcp-email-events-dev": {
const { handleEmailEventsQueue } = await import("./cloudflare/email-events-consumer");
return handleEmailEventsQueue(batch as MessageBatch<unknown>, env, ctx);
}
case "inbox-mcp-notify":
case "inbox-mcp-notify-dev": {
const { handleNotifyQueue } = await import("./cloudflare/notify-consumer");
return handleNotifyQueue(batch as MessageBatch<unknown>, env, ctx);
}
case "inbox-mcp-inbound-dlq":
case "inbox-mcp-inbound-dlq-dev":
case "inbox-mcp-email-events-dlq":
case "inbox-mcp-email-events-dlq-dev":
case "inbox-mcp-notify-dlq":
case "inbox-mcp-notify-dlq-dev": {
const { handleDeadLetterQueue } = await import("./cloudflare/dlq-consumer");
return handleDeadLetterQueue(batch as MessageBatch<unknown>, env, ctx);
}
default:
throw new Error(`queue(): no consumer wired for queue "${batch.queue}"`);
}
},
async scheduled(controller: ScheduledController, env: Env, _ctx: ExecutionContext) {
const { handleScheduled } = await import("./cloudflare/scheduled");
return handleScheduled(controller, env);
},
} satisfies ExportedHandler<Env, InboundEmailQueueMessage>;