From 96833747de5212420c2c540ede38a7b26e8922e4 Mon Sep 17 00:00:00 2001 From: tmo Date: Mon, 24 Aug 2026 15:08:25 +0100 Subject: [PATCH 1/7] [miniflare] Expose parsed received email bodies --- .../miniflare/src/workers/email/contracts.ts | 2 + .../local-explorer/generated/types.gen.ts | 8 +++ .../local-explorer/generated/zod.gen.ts | 2 + .../workers/local-explorer/openapi.local.json | 8 +++ .../workers/local-explorer/resources/email.ts | 15 ++++- .../test/plugins/local-explorer/email.spec.ts | 60 +++++++++++++++++++ 6 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/miniflare/src/workers/email/contracts.ts b/packages/miniflare/src/workers/email/contracts.ts index 20a033b9670..6c55a8a205d 100644 --- a/packages/miniflare/src/workers/email/contracts.ts +++ b/packages/miniflare/src/workers/email/contracts.ts @@ -162,6 +162,8 @@ export const zEmailRoutingItem = zEmailBase.extend({ export type EmailRoutingItem = z.infer; export const zEmailRoutingDetail = zEmailRoutingItem.extend({ + text: z.string().describe("Parsed plain text body, when present.").optional(), + html: z.string().describe("Parsed HTML body, when present.").optional(), raw: z.string().describe("Raw MIME content of the received email."), rawBase64: z .string() diff --git a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts index 07a684f6072..443f64c0b31 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts @@ -1047,6 +1047,14 @@ export type EmailRoutingDetail = { messageId: string; } >; + /** + * Parsed plain text body, when present. + */ + text?: string; + /** + * Parsed HTML body, when present. + */ + html?: string; /** * Raw MIME content of the received email. */ diff --git a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts index f4067d8477d..41773d47de9 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts @@ -674,6 +674,8 @@ export const zEmailRoutingDetail = z.object({ }), ]) ), + text: z.string().optional(), + html: z.string().optional(), raw: z.string(), rawBase64: z.string().optional(), }); diff --git a/packages/miniflare/src/workers/local-explorer/openapi.local.json b/packages/miniflare/src/workers/local-explorer/openapi.local.json index 497fff1f977..81db136efe3 100644 --- a/packages/miniflare/src/workers/local-explorer/openapi.local.json +++ b/packages/miniflare/src/workers/local-explorer/openapi.local.json @@ -4328,6 +4328,14 @@ "description": "One entry in the ordered lifecycle of what the handler did to the message. `received` is first for any message actually delivered to an `email()` handler. The exception is `unhandled`: when the Worker exports no `email()` handler the message never reaches one, so the timeline is a single `unhandled` event with no preceding `received`. `forward`/`reply` events carry a `messageId` correlating with the matching `forwards`/`replies` entry." } }, + "text": { + "type": "string", + "description": "Parsed plain text body, when present." + }, + "html": { + "type": "string", + "description": "Parsed HTML body, when present." + }, "raw": { "type": "string", "description": "Raw MIME content of the received email." diff --git a/packages/miniflare/src/workers/local-explorer/resources/email.ts b/packages/miniflare/src/workers/local-explorer/resources/email.ts index 35b391eb00e..313c20e45bf 100644 --- a/packages/miniflare/src/workers/local-explorer/resources/email.ts +++ b/packages/miniflare/src/workers/local-explorer/resources/email.ts @@ -1,4 +1,4 @@ -import { decodeWords } from "postal-mime"; +import PostalMime, { decodeWords } from "postal-mime"; import { z } from "zod"; import { EMAIL_STORE_SERVICE_NAME } from "../../../plugins/core/constants"; import { CoreBindings, CorePaths } from "../../core"; @@ -803,11 +803,24 @@ export async function getReceivedEmail( // Decode MIME "encoded-word" headers (e.g. `=?utf-8?B?...?=`) in each reply's // display text so the explorer shows readable subjects. const { captureTruncated, replies: storedReplies, ...storedEmail } = email; + let body: { text?: string; html?: string } = {}; + if (email.rawBase64 !== undefined) { + try { + const parsed = await new PostalMime().parse( + base64ToBytes(email.rawBase64) + ); + body = { text: parsed.text, html: parsed.html }; + } catch { + // A truncated capture may end mid-MIME-part. Keep the raw message available + // even when a structured body cannot be recovered from it. + } + } const replyCaptureTruncated = storedReplies.some( (reply) => reply.captureTruncated ); const decoded = { ...storedEmail, + ...body, replies: storedReplies.map( ({ captureTruncated: _captureTruncated, ...reply }) => ({ ...reply, diff --git a/packages/miniflare/test/plugins/local-explorer/email.spec.ts b/packages/miniflare/test/plugins/local-explorer/email.spec.ts index cfc4a5e07a3..df9aa5eb773 100644 --- a/packages/miniflare/test/plugins/local-explorer/email.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/email.spec.ts @@ -1209,11 +1209,71 @@ describe("Local Explorer email API", () => { expect ); expect(detail.result).toMatchObject({ + text: "Received message.\n", raw, rawBase64: Buffer.from(raw).toString("base64"), }); }); + test("parses multipart received bodies from their lossless MIME bytes", async ({ + expect, + }) => { + const beforeLatin1Body = new TextEncoder().encode( + [ + "From: sender@example.com", + "To: recipient@example.com", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: multipart/alternative; boundary=example", + "", + "--example", + "Content-Type: text/plain; charset=iso-8859-1", + "Content-Transfer-Encoding: 8bit", + "", + "caf", + ].join("\r\n") + ); + const afterLatin1Body = new TextEncoder().encode( + [ + "", + "--example", + "Content-Type: text/html; charset=utf-8", + "", + "

café

", + "--example--", + "", + ].join("\r\n") + ); + const raw = new Uint8Array( + beforeLatin1Body.byteLength + 1 + afterLatin1Body.byteLength + ); + raw.set(beforeLatin1Body); + raw[beforeLatin1Body.byteLength] = 0xe9; + raw.set(afterLatin1Body, beforeLatin1Body.byteLength + 1); + const response = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + format: "json", + }).toString(), + { method: "POST", body: raw } + ); + expect(response.status).toBe(200); + await response.json(); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=received-latin1%40example.com` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result?.rawBase64).toBe(Buffer.from(raw).toString("base64")); + expect(detail.result?.text).toBe("café\n"); + expect(detail.result?.html).toBe("

café

\n"); + }); + test("omits BCC from routing items but retains it on sent items", async ({ expect, }) => { From 45f11c7cca3fdad00b4a736c4dd6225ada4e63de Mon Sep 17 00:00:00 2001 From: tmo Date: Mon, 24 Aug 2026 15:09:36 +0100 Subject: [PATCH 2/7] [local-explorer-ui] Add email inspection views --- .changeset/email-local-explorer-ui.md | 10 + .../src/__e2e__/email-sending.spec.ts | 202 +++++++++++++ .../src/__e2e__/worker-selector.spec.ts | 144 ++++++++++ .../src/__tests__/utils/email-html.test.ts | 19 ++ .../src/__tests__/utils/sidebar-state.test.ts | 3 + .../__tests__/utils/worker-navigation.test.ts | 50 ++++ .../src/assets/icons/email.svg | 3 + .../src/components/Sidebar.tsx | 31 ++ .../src/components/email/EmailHtmlPreview.tsx | 33 +++ .../src/components/email/EmailList.tsx | 137 +++++++++ .../src/components/email/EmailPagination.tsx | 44 +++ .../email/EmailServiceEmptyState.tsx | 26 ++ .../email/EmailTruncationWarning.tsx | 36 +++ .../components/email/ReceivedEmailContent.tsx | 64 +++++ .../src/components/email/SentEmailDetails.tsx | 173 +++++++++++ .../local-explorer-ui/src/routeTree.gen.ts | 92 ++++++ .../local-explorer-ui/src/routes/__root.tsx | 40 ++- .../src/routes/email/routing.tsx | 10 + .../src/routes/email/routing/$emailId.tsx | 126 ++++++++ .../src/routes/email/routing/index.tsx | 231 +++++++++++++++ .../src/routes/email/sending.tsx | 271 ++++++++++++++++++ .../src/routes/email/shared/ConstantsCard.tsx | 86 ++++++ .../src/routes/email/shared/EventNode.tsx | 185 ++++++++++++ .../src/routes/email/shared/InfoFlow.tsx | 50 ++++ .../src/routes/email/shared/InfoLoading.tsx | 28 ++ .../src/routes/email/shared/format.ts | 21 ++ .../src/routes/email/shared/types.ts | 59 ++++ .../email/shared/useCursorPaginatedList.ts | 157 ++++++++++ .../local-explorer-ui/src/utils/email-html.ts | 14 + .../src/utils/sidebar-state.ts | 2 + .../src/utils/worker-navigation.ts | 27 ++ packages/local-explorer-ui/tsconfig.e2e.json | 3 +- packages/local-explorer-ui/vite.config.ts | 1 + 33 files changed, 2372 insertions(+), 6 deletions(-) create mode 100644 .changeset/email-local-explorer-ui.md create mode 100644 packages/local-explorer-ui/src/__e2e__/email-sending.spec.ts create mode 100644 packages/local-explorer-ui/src/__tests__/utils/email-html.test.ts create mode 100644 packages/local-explorer-ui/src/__tests__/utils/worker-navigation.test.ts create mode 100644 packages/local-explorer-ui/src/assets/icons/email.svg create mode 100644 packages/local-explorer-ui/src/components/email/EmailHtmlPreview.tsx create mode 100644 packages/local-explorer-ui/src/components/email/EmailList.tsx create mode 100644 packages/local-explorer-ui/src/components/email/EmailPagination.tsx create mode 100644 packages/local-explorer-ui/src/components/email/EmailServiceEmptyState.tsx create mode 100644 packages/local-explorer-ui/src/components/email/EmailTruncationWarning.tsx create mode 100644 packages/local-explorer-ui/src/components/email/ReceivedEmailContent.tsx create mode 100644 packages/local-explorer-ui/src/components/email/SentEmailDetails.tsx create mode 100644 packages/local-explorer-ui/src/routes/email/routing.tsx create mode 100644 packages/local-explorer-ui/src/routes/email/routing/$emailId.tsx create mode 100644 packages/local-explorer-ui/src/routes/email/routing/index.tsx create mode 100644 packages/local-explorer-ui/src/routes/email/sending.tsx create mode 100644 packages/local-explorer-ui/src/routes/email/shared/ConstantsCard.tsx create mode 100644 packages/local-explorer-ui/src/routes/email/shared/EventNode.tsx create mode 100644 packages/local-explorer-ui/src/routes/email/shared/InfoFlow.tsx create mode 100644 packages/local-explorer-ui/src/routes/email/shared/InfoLoading.tsx create mode 100644 packages/local-explorer-ui/src/routes/email/shared/format.ts create mode 100644 packages/local-explorer-ui/src/routes/email/shared/types.ts create mode 100644 packages/local-explorer-ui/src/routes/email/shared/useCursorPaginatedList.ts create mode 100644 packages/local-explorer-ui/src/utils/email-html.ts create mode 100644 packages/local-explorer-ui/src/utils/worker-navigation.ts diff --git a/.changeset/email-local-explorer-ui.md b/.changeset/email-local-explorer-ui.md new file mode 100644 index 00000000000..56c2ab9a098 --- /dev/null +++ b/.changeset/email-local-explorer-ui.md @@ -0,0 +1,10 @@ +--- +"@cloudflare/local-explorer-ui": minor +"miniflare": minor +--- + +Add email inspection and testing to Local Explorer + +Add an Email group with Routing and Sending views for inspecting messages received by a Worker's `email()` handler and messages sent through its `send_email` bindings. Detail views show message content, metadata, attachments, and handler activity including forwarding, replies, rejection, and unhandled messages. + +Add a test-email composer that delivers custom text, HTML, headers, and attachments directly to the selected Worker's `email()` handler during local development. diff --git a/packages/local-explorer-ui/src/__e2e__/email-sending.spec.ts b/packages/local-explorer-ui/src/__e2e__/email-sending.spec.ts new file mode 100644 index 00000000000..aeb62cd439d --- /dev/null +++ b/packages/local-explorer-ui/src/__e2e__/email-sending.spec.ts @@ -0,0 +1,202 @@ +import { afterEach, test } from "vitest"; +import { createSafeEmailPreview, EMAIL_PREVIEW_CSP } from "../utils/email-html"; +import { page, viteUrl } from "./utils"; + +const WORKERS_ROUTE = "**/cdn-cgi/local/explorer/api/local/workers"; +const EMAIL_SENDING_ROUTE = + "**/cdn-cgi/local/explorer/api/local/email/sending?*"; + +afterEach(async () => { + await page.unroute(WORKERS_ROUTE); + await page.unroute(EMAIL_SENDING_ROUTE); +}); + +test("shows sent email details in a split view", async ({ expect }) => { + const requestedWorkers: Array = []; + await page.route(WORKERS_ROUTE, async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + errors: [], + messages: [], + result: [ + { + bindings: { sendEmail: [{ bindingName: "SEND_EMAIL" }] }, + isSelf: true, + name: "worker-1", + }, + { + bindings: {}, + isSelf: false, + name: "worker-2", + }, + ], + success: true, + }), + }); + }); + + await page.route(EMAIL_SENDING_ROUTE, async (route) => { + const search = new URL(route.request().url()).searchParams; + const emailId = search.get("email_id"); + requestedWorkers.push(search.get("worker")); + const summary = { + attachments: [], + from: "sender@example.com", + headers: { "X-Custom-Header": "custom value" }, + messageId: "", + sentAt: "2026-08-21T12:00:00.000Z", + subject: "Sent email subject", + to: ["recipient@example.com"], + worker: "worker-1", + }; + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + errors: [], + messages: emailId + ? [ + { + code: 10604, + message: + "Displayed sent email content was truncated during local capture. The complete email is available in the local filesystem; see the development log for its path.", + }, + ] + : [], + result: emailId + ? { + ...summary, + html: "

Rendered HTML body

", + text: "Plain text body", + } + : [summary], + result_info: emailId + ? undefined + : { count: 1, has_more: false, per_page: 25 }, + success: true, + }), + }); + }); + + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/sending?worker=missing-worker", + viteUrl + ).toString() + ); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-1"); + await expect.poll(() => requestedWorkers.includes("worker-1")).toBe(true); + await page.getByTestId("sent-email-list").waitFor(); + const viewportOverflow = await page.locator("main").evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + expect(viewportOverflow.scrollHeight).toBe(viewportOverflow.clientHeight); + const refreshTop = await page + .getByRole("button", { name: "Refresh" }) + .evaluate((element) => element.getBoundingClientRect().top); + const paginationTop = await page + .getByRole("button", { name: "Next page" }) + .evaluate((element) => element.getBoundingClientRect().top); + expect(Math.abs(refreshTop - paginationTop)).toBeLessThanOrEqual(1); + await expect + .poll(() => page.getByTestId("sent-email-details").isVisible()) + .toBe(false); + const fullWidth = await page + .getByTestId("sent-email-list") + .evaluate((element) => element.getBoundingClientRect().width); + + await page.getByRole("button", { name: /Sent email subject/ }).click(); + await expect + .poll(() => page.getByTestId("sent-email-details").isVisible()) + .toBe(true); + await expect + .poll(async () => { + const listWidth = await page + .getByTestId("sent-email-list") + .evaluate((element) => element.getBoundingClientRect().width); + const detailWidth = await page + .getByTestId("sent-email-details") + .evaluate((element) => element.getBoundingClientRect().width); + return Math.abs(listWidth - detailWidth); + }) + .toBeLessThanOrEqual(1); + const listWidth = await page + .getByTestId("sent-email-list") + .evaluate((element) => element.getBoundingClientRect().width); + const detailWidth = await page + .getByTestId("sent-email-details") + .evaluate((element) => element.getBoundingClientRect().width); + expect(Math.abs(listWidth - detailWidth)).toBeLessThanOrEqual(1); + expect(listWidth).toBeLessThan(fullWidth * 0.6); + await page.getByText("X-Custom-Header").waitFor(); + await page.getByText("custom value").waitFor(); + await page + .getByText(/complete email is available in temporary local storage/) + .first() + .waitFor(); + expect(await page.getByText("Plain text body", { exact: true }).count()).toBe( + 0 + ); + expect( + await page.locator('iframe[title="Rendered HTML email body"]').count() + ).toBe(0); + + await page.getByRole("button", { name: /Sent email subject/ }).click(); + await expect + .poll(() => page.getByTestId("sent-email-details").isVisible()) + .toBe(false); + await expect + .poll(async () => { + const restoredWidth = await page + .getByTestId("sent-email-list") + .evaluate((element) => element.getBoundingClientRect().width); + return Math.abs(restoredWidth - fullWidth); + }) + .toBeLessThanOrEqual(1); +}); + +test("email previews block remote resources and navigation", async ({ + expect, +}) => { + let remoteRequests = 0; + await page.route("https://email-preview.invalid/**", async (route) => { + remoteRequests += 1; + await route.abort(); + }); + try { + const parentUrl = page.url(); + await page.setContent("
"); + await page.evaluate( + ({ csp, preview }) => { + const iframe = document.createElement("iframe"); + iframe.setAttribute("csp", csp); + iframe.setAttribute("sandbox", ""); + iframe.srcdoc = preview; + document.querySelector("main")?.append(iframe); + }, + { + csp: EMAIL_PREVIEW_CSP, + preview: createSafeEmailPreview( + '
' + ), + } + ); + const previewFrame = page.locator("iframe").contentFrame(); + await previewFrame.locator("body").waitFor(); + expect( + await previewFrame.locator("body").getAttribute("data-script-executed") + ).toBeNull(); + await previewFrame + .getByRole("button", { name: "Submit unsafe form" }) + .click(); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(remoteRequests).toBe(0); + expect(page.url()).toBe(parentUrl); + } finally { + await page.unroute("https://email-preview.invalid/**"); + } +}); diff --git a/packages/local-explorer-ui/src/__e2e__/worker-selector.spec.ts b/packages/local-explorer-ui/src/__e2e__/worker-selector.spec.ts index 0a8369e38e8..261e508c139 100644 --- a/packages/local-explorer-ui/src/__e2e__/worker-selector.spec.ts +++ b/packages/local-explorer-ui/src/__e2e__/worker-selector.spec.ts @@ -2,6 +2,8 @@ import { afterEach, describe, test } from "vitest"; import { page, viteUrl } from "./utils"; const WORKERS_ROUTE = "**/cdn-cgi/local/explorer/api/local/workers"; +const EMAIL_ROUTING_DETAIL_ROUTE = + "**/cdn-cgi/local/explorer/api/local/email/routing?*"; function createWorkers(count: number) { return Array.from({ length: count }, (_, index) => ({ @@ -32,11 +34,113 @@ function waitForWorkersResponse() { ); } +async function mockEmailRoutingDetail(): Promise { + await page.route(EMAIL_ROUTING_DETAIL_ROUTE, async (route) => { + const emailId = new URL(route.request().url()).searchParams.get("email_id"); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + errors: [], + messages: emailId + ? [ + { + code: 10604, + message: + "Displayed received email content was truncated during local capture. The complete message was still delivered to the Worker.", + }, + ] + : [], + result: emailId + ? { + messageId: "", + from: "sender@example.com", + html: "

Rendered received HTML body

", + outcome: "ok", + raw: "Content-Type: text/plain\r\n\r\nPlain received text body", + to: "recipient@example.com", + subject: "Test email", + text: "Plain received text body", + receivedAt: "2024-01-01T00:00:00.000Z", + rawSize: 42, + attachments: [], + events: [], + forwards: [], + replies: [], + } + : [], + result_info: emailId + ? undefined + : { count: 0, has_more: false, per_page: 25 }, + success: true, + }), + }); + }); +} + afterEach(async () => { await page.unroute(WORKERS_ROUTE); + await page.unroute(EMAIL_ROUTING_DETAIL_ROUTE); }); describe("worker selector", () => { + test("discards stale email lists after switching workers", async ({ + expect, + }) => { + let releaseStaleResponse: (() => void) | undefined; + const staleResponse = new Promise((resolve) => { + releaseStaleResponse = resolve; + }); + let workerOneRequests = 0; + await page.route(EMAIL_ROUTING_DETAIL_ROUTE, async (route) => { + const worker = new URL(route.request().url()).searchParams.get("worker"); + workerOneRequests += worker === "worker-1" ? 1 : 0; + if (worker === "worker-1" && workerOneRequests === 2) { + await staleResponse; + } + const subject = worker === "worker-2" ? "Worker two email" : "Old email"; + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + errors: [], + messages: [], + result: [ + { + attachments: [], + events: [], + forwards: [], + from: "sender@example.com", + messageId: `<${worker}-${workerOneRequests}>`, + outcome: "ok", + rawSize: 1, + receivedAt: "2026-08-24T00:00:00.000Z", + replies: [], + subject, + to: "recipient@example.com", + }, + ], + result_info: { count: 1, has_more: false, per_page: 10 }, + success: true, + }), + }); + }); + await loadWorkers(2); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-1", + viteUrl + ).toString() + ); + await page.getByRole("link", { name: /Old email/ }).waitFor(); + await page.getByRole("button", { name: "Refresh" }).click(); + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: "worker-2" }).click(); + await page.getByRole("link", { name: /Worker two email/ }).waitFor(); + releaseStaleResponse?.(); + await page.waitForTimeout(100); + + expect(await page.getByRole("link", { name: /Old email/ }).count()).toBe(0); + }); + test("stays hidden when there is only one worker", async ({ expect }) => { await loadWorkers(1); @@ -129,4 +233,44 @@ describe("worker selector", () => { await page.getByRole("combobox").getByText("worker-12").waitFor(); await page.waitForLoadState("networkidle"); }); + + test("returns to the routing list when switching workers on the email detail page", async ({ + expect, + }) => { + await mockEmailRoutingDetail(); + await loadWorkers(2); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing/test-email-id?worker=worker-1", + viteUrl + ).toString() + ); + await page.waitForLoadState("networkidle"); + await page + .getByText(/complete email was delivered to the Worker/) + .first() + .waitFor(); + expect( + await page.getByText("Plain received text body", { exact: true }).count() + ).toBe(0); + expect( + await page + .locator('iframe[title="Rendered received HTML email body"]') + .count() + ).toBe(0); + + const workersResponse = waitForWorkersResponse(); + await page.getByRole("combobox").click(); + await page.getByRole("option", { name: "worker-2" }).click(); + await workersResponse; + + // Switching workers on the detail page redirects back to the parent + // "Routing" list, carrying the newly selected worker forward. + await expect + .poll(() => new URL(page.url()).pathname) + .toMatch(/\/email\/routing$/); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-2"); + }); }); diff --git a/packages/local-explorer-ui/src/__tests__/utils/email-html.test.ts b/packages/local-explorer-ui/src/__tests__/utils/email-html.test.ts new file mode 100644 index 00000000000..2632c358d78 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/utils/email-html.test.ts @@ -0,0 +1,19 @@ +import { describe, test } from "vitest"; +import { createSafeEmailPreview } from "../../utils/email-html"; + +describe("createSafeEmailPreview", () => { + test("blocks remote resources while preserving inline email content", ({ + expect, + }) => { + const html = + '

Hello

'; + + const preview = createSafeEmailPreview(html); + + expect(preview).toContain("default-src 'none'"); + expect(preview).toContain("img-src data: cid:"); + expect(preview).toContain("style-src 'unsafe-inline'"); + expect(preview).not.toContain("img-src https:"); + expect(preview.endsWith(html)).toBe(true); + }); +}); diff --git a/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts b/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts index fa335c5aed8..e49b19d30e7 100644 --- a/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts +++ b/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts @@ -112,6 +112,7 @@ describe("sidebar-state", () => { kv: true, r2: false, workflows: true, + email: false, }; storageStub.setItem(GROUPS_STORAGE_KEY, JSON.stringify(stored)); expect(loadGroupState()).toEqual(stored); @@ -159,6 +160,7 @@ describe("sidebar-state", () => { kv: false, r2: true, workflows: false, + email: true, }; saveGroupState(state); const raw = storageStub.getItem(GROUPS_STORAGE_KEY); @@ -185,6 +187,7 @@ describe("sidebar-state", () => { kv: true, r2: false, workflows: true, + email: true, }; saveGroupState(state); expect(loadGroupState()).toEqual(state); diff --git a/packages/local-explorer-ui/src/__tests__/utils/worker-navigation.test.ts b/packages/local-explorer-ui/src/__tests__/utils/worker-navigation.test.ts new file mode 100644 index 00000000000..68efd3b93ec --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/utils/worker-navigation.test.ts @@ -0,0 +1,50 @@ +import { describe, test } from "vitest"; +import { getWorkerChangeDestination } from "../../utils/worker-navigation"; + +describe("getWorkerChangeDestination", () => { + test("redirects an email routing detail path to its parent list", ({ + expect, + }) => { + expect(getWorkerChangeDestination("/email/routing/some-email-id")).toBe( + "/email/routing" + ); + }); + + test("redirects an email sending detail path to its parent list", ({ + expect, + }) => { + expect(getWorkerChangeDestination("/email/sending/some-email-id")).toBe( + "/email/sending" + ); + }); + + test("preserves the router basepath when redirecting", ({ expect }) => { + expect( + getWorkerChangeDestination( + "/cdn-cgi/local/explorer/email/routing/some-email-id" + ) + ).toBe("/cdn-cgi/local/explorer/email/routing"); + }); + + test("leaves the parent list pages unchanged", ({ expect }) => { + expect(getWorkerChangeDestination("/email/routing")).toBe("/email/routing"); + expect(getWorkerChangeDestination("/email/sending")).toBe("/email/sending"); + }); + + test("leaves the routing index (trailing slash) unchanged", ({ expect }) => { + // The list view lives at "/email/routing/"; only actual detail segments + // (a non-empty id after the slash) should trigger a redirect. + expect(getWorkerChangeDestination("/email/routing/")).toBe( + "/email/routing/" + ); + }); + + test("leaves unrelated paths unchanged", ({ expect }) => { + expect(getWorkerChangeDestination("/kv/my-namespace")).toBe( + "/kv/my-namespace" + ); + expect(getWorkerChangeDestination("/cdn-cgi/local/explorer/")).toBe( + "/cdn-cgi/local/explorer/" + ); + }); +}); diff --git a/packages/local-explorer-ui/src/assets/icons/email.svg b/packages/local-explorer-ui/src/assets/icons/email.svg new file mode 100644 index 00000000000..92b68967738 --- /dev/null +++ b/packages/local-explorer-ui/src/assets/icons/email.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/local-explorer-ui/src/components/Sidebar.tsx b/packages/local-explorer-ui/src/components/Sidebar.tsx index 610ffd68259..f1575511bd0 100644 --- a/packages/local-explorer-ui/src/components/Sidebar.tsx +++ b/packages/local-explorer-ui/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router"; import { useCallback, useState } from "react"; import D1Icon from "../assets/icons/d1.svg?react"; import DOIcon from "../assets/icons/durable-objects.svg?react"; +import EmailIcon from "../assets/icons/email.svg?react"; import KVIcon from "../assets/icons/kv.svg?react"; import R2Icon from "../assets/icons/r2.svg?react"; import WorkflowsIcon from "../assets/icons/workflows.svg?react"; @@ -208,6 +209,36 @@ export function AppSidebar({ })), title: "Workflows", }, + { + emptyLabel: "No email", + groupId: "email" as const, + icon: EmailIcon, + items: [ + { + id: "sending", + isActive: currentPath === "/email/sending", + label: "Sending", + link: { + params: {}, + search: workerSearch, + to: "/email/sending", + }, + }, + { + id: "routing", + isActive: + currentPath === "/email/routing" || + currentPath.startsWith("/email/routing/"), + label: "Routing", + link: { + params: {}, + search: workerSearch, + to: "/email/routing", + }, + }, + ], + title: "Email", + }, ] satisfies Array<{ emptyLabel: string; groupId: SidebarGroupId; diff --git a/packages/local-explorer-ui/src/components/email/EmailHtmlPreview.tsx b/packages/local-explorer-ui/src/components/email/EmailHtmlPreview.tsx new file mode 100644 index 00000000000..527ebae2cb4 --- /dev/null +++ b/packages/local-explorer-ui/src/components/email/EmailHtmlPreview.tsx @@ -0,0 +1,33 @@ +import { + createSafeEmailPreview, + EMAIL_PREVIEW_CSP, +} from "../../utils/email-html"; +import type { JSX } from "react"; + +/** + * Renders untrusted email HTML with the sandbox and resource policy shared by + * every email detail view. + * + * @param html - Captured HTML email content. + * @param title - Accessible description of the preview. + * @returns A constrained iframe containing the email HTML. + */ +export function EmailHtmlPreview({ + html, + title, +}: { + html: string; + title: string; +}): JSX.Element { + const embeddedCsp = { csp: EMAIL_PREVIEW_CSP }; + return ( +