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/.changeset/validate-test-email-headers.md b/.changeset/validate-test-email-headers.md new file mode 100644 index 00000000000..68d1e436ad9 --- /dev/null +++ b/.changeset/validate-test-email-headers.md @@ -0,0 +1,7 @@ +--- +"miniflare": patch +--- + +Validate custom headers sent through the Local Explorer test-email endpoint + +Reject header names and values that cannot be safely encoded. Multiline values remain supported and are folded into valid MIME continuation lines. diff --git a/packages/local-explorer-ui/package.json b/packages/local-explorer-ui/package.json index 8223e2d1520..44b29dd9c64 100644 --- a/packages/local-explorer-ui/package.json +++ b/packages/local-explorer-ui/package.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-utils": "workspace:*", "@hey-api/openapi-ts": "catalog:default", "@tanstack/react-router-devtools": "^1.158.0", "@tanstack/router-plugin": "^1.158.0", diff --git a/packages/local-explorer-ui/src/__e2e__/email/email-routing.spec.ts b/packages/local-explorer-ui/src/__e2e__/email/email-routing.spec.ts new file mode 100644 index 00000000000..1565b8394b2 --- /dev/null +++ b/packages/local-explorer-ui/src/__e2e__/email/email-routing.spec.ts @@ -0,0 +1,615 @@ +import { afterEach, describe, test } from "vitest"; +import { page, viteUrl } from "../utils"; +import { + cleanupEmailMocks, + EMAIL_ROUTING_DETAIL_ROUTE, + EMAIL_ROUTING_SEND_ROUTE, + fulfillApiResult, + loadWorker, + mockEmailRoutingDetail, + mockEmptyEmailSending, +} from "./utils"; + +afterEach(async () => { + await cleanupEmailMocks(); +}); + +describe("email routing", () => { + test("navigates from collapsed Email group links", async ({ expect }) => { + await mockEmailRoutingDetail(); + await mockEmptyEmailSending(); + await loadWorker(); + + const sidebar = page.locator('[data-sidebar="sidebar"]'); + if ((await sidebar.getAttribute("data-state")) !== "collapsed") { + await page.getByRole("button", { name: "Toggle sidebar" }).click(); + } + await expect + .poll(() => sidebar.getAttribute("data-state")) + .toBe("collapsed"); + + const emailGroup = page.getByRole("button", { + exact: true, + name: "Email", + }); + await page.locator("html").evaluate((element) => { + element.dataset.spaNavigationMarker = "preserved"; + }); + await emailGroup.hover(); + await emailGroup.click(); + await page.getByRole("link", { name: "Routing", exact: true }).click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toMatch(/\/email\/routing$/); + expect( + await page.locator("html").getAttribute("data-spa-navigation-marker") + ).toBe("preserved"); + + // The popup stays open across SPA navigation. Clicking its trigger here + // would close it while the next link is being selected. + const sendingLink = page.getByRole("link", { + name: "Sending", + exact: true, + }); + await sendingLink.waitFor(); + await sendingLink.click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toMatch(/\/email\/sending$/); + expect( + await page.locator("html").getAttribute("data-spa-navigation-marker") + ).toBe("preserved"); + }); + + test("waits for attachments and keeps an in-flight send dialog open", async ({ + expect, + }) => { + await mockEmailRoutingDetail(); + await loadWorker(); + let releaseSend: (() => void) | undefined; + const sendReleased = new Promise((resolve) => { + releaseSend = resolve; + }); + let sentBody: unknown; + await page.route(EMAIL_ROUTING_SEND_ROUTE, async (route) => { + sentBody = route.request().postDataJSON(); + await sendReleased; + await fulfillApiResult(route, { + messageId: "", + outcome: "ok", + }); + }); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-1", + viteUrl + ).toString() + ); + await page.getByRole("button", { name: "Send Test Email" }).click(); + await page.locator("#test-email-from").fill("sender@example.com"); + await page.locator("#test-email-to").fill("recipient@example.com"); + await page.evaluate(() => { + const arrayBuffer = File.prototype.arrayBuffer; + File.prototype.arrayBuffer = async function () { + await new Promise((resolve) => setTimeout(resolve, 200)); + return arrayBuffer.call(this); + }; + }); + await page.getByLabel("Attachments").setInputFiles({ + buffer: Buffer.from("attachment body"), + mimeType: "text/plain", + name: "example.txt", + }); + const sendButton = page.getByRole("button", { name: "Send Email" }); + await expect.poll(() => sendButton.isDisabled()).toBe(true); + await page.getByText("example.txt").waitFor(); + await expect.poll(() => sendButton.isEnabled()).toBe(true); + await sendButton.click(); + await page.keyboard.press("Escape"); + await page.getByRole("heading", { name: "Send test email" }).waitFor(); + await expect + .poll(() => sentBody) + .toMatchObject({ + attachments: [ + { + content: Buffer.from("attachment body").toString("base64"), + filename: "example.txt", + type: "text/plain", + }, + ], + }); + releaseSend?.(); + await expect + .poll(() => + page.getByRole("heading", { name: "Send test email" }).count() + ) + .toBe(0); + await page.getByRole("button", { name: "Edit and resend" }).click(); + await page.getByText("example.txt").waitFor(); + expect(await page.getByText("text/plain · 15 B").count()).toBe(1); + }); + + test("closes and refreshes when an email is captured without a handler", async ({ + expect, + }) => { + let listRequests = 0; + await page.route(EMAIL_ROUTING_DETAIL_ROUTE, async (route) => { + listRequests++; + await fulfillApiResult( + route, + listRequests === 1 + ? [] + : [ + { + attachments: [], + events: [ + { + timestamp: "2026-08-27T00:00:00.000Z", + type: "unhandled", + }, + ], + from: "sender@example.com", + messageId: "", + outcome: "exception", + rawSize: 42, + receivedAt: "2026-08-27T00:00:00.000Z", + subject: "Captured without handler", + to: "recipient@example.com", + }, + ], + { + resultInfo: { + count: listRequests === 1 ? 0 : 1, + has_more: false, + per_page: 25, + }, + } + ); + }); + await loadWorker(); + await page.route(EMAIL_ROUTING_SEND_ROUTE, async (route) => { + await route.fulfill({ + body: JSON.stringify({ + errors: [ + { + code: 10602, + message: "Worker 'worker-1' does not export an email() handler.", + }, + ], + messages: [], + result: null, + success: false, + }), + contentType: "application/json", + status: 400, + }); + }); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-1", + viteUrl + ).toString() + ); + await page.getByRole("button", { name: "Send Test Email" }).click(); + await page.locator("#test-email-from").fill("sender@example.com"); + await page.locator("#test-email-to").fill("recipient@example.com"); + await page.getByLabel("Subject").fill("Captured without handler"); + await page.getByRole("button", { name: "Send Email" }).click(); + + await page + .getByText("Worker 'worker-1' does not export an email() handler.") + .waitFor(); + await expect + .poll(() => + page.getByRole("heading", { name: "Send test email" }).count() + ) + .toBe(0); + const emailRow = page.getByRole("button", { + name: /Captured without handler/, + }); + await emailRow.waitFor(); + await emailRow + .getByRole("img", { name: "Email processing exception" }) + .waitFor(); + expect(listRequests).toBeGreaterThan(1); + }); + + test("allows large attachments and cancels pending reads", async ({ + expect, + }) => { + await mockEmailRoutingDetail(); + await loadWorker(); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-1", + viteUrl + ).toString() + ); + await page.getByRole("button", { name: "Send Test Email" }).click(); + const attachmentInput = page.getByLabel("Attachments"); + await page.evaluate(() => { + const arrayBuffer = File.prototype.arrayBuffer; + File.prototype.arrayBuffer = async function () { + await new Promise((resolve) => setTimeout(resolve, 200)); + return arrayBuffer.call(this); + }; + }); + await attachmentInput.setInputFiles({ + buffer: Buffer.from("cancelled attachment"), + mimeType: "application/octet-stream", + name: "cancelled-on-close.bin", + }); + await page.keyboard.press("Escape"); + await page.waitForTimeout(250); + await page.getByRole("button", { name: "Send Test Email" }).click(); + expect(await page.getByText("cancelled-on-close.bin").count()).toBe(0); + + await attachmentInput.setInputFiles({ + buffer: Buffer.alloc(700 * 1024 + 1), + mimeType: "application/octet-stream", + name: "over-legacy-limit.bin", + }); + await page.getByText("over-legacy-limit.bin").waitFor(); + expect( + await page.getByText(/Attachments must total less than/).count() + ).toBe(0); + }); + + test("edits and resends the last successful email with multiline headers", async ({ + expect, + }) => { + await mockEmailRoutingDetail(true, { showInList: true }); + await loadWorker(); + const sentBodies: Array> = []; + await page.route(EMAIL_ROUTING_SEND_ROUTE, async (route) => { + sentBodies.push( + route.request().postDataJSON() as Record + ); + await fulfillApiResult(route, { + messageId: ``, + outcome: "ok", + }); + }); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-1", + viteUrl + ).toString() + ); + + const editAndResendButton = page.getByRole("button", { + name: "Edit and resend", + }); + await editAndResendButton.waitFor(); + expect(await editAndResendButton.isDisabled()).toBe(true); + await page.getByRole("button", { name: "Send Test Email" }).click(); + await page.locator("#test-email-from").fill("sender@example.com"); + await page.locator("#test-email-to").fill("recipient@example.com"); + await page.getByLabel("Subject").fill("Original subject"); + await page.getByLabel("Text body").fill("Original body"); + await page.getByRole("button", { name: "Add header" }).click(); + const headerNameInput = page.getByLabel("Header 1 name"); + const headerValueInput = page.getByLabel("Header 1 value"); + await expect.poll(() => headerNameInput.isEditable()).toBe(true); + await expect.poll(() => headerValueInput.isEditable()).toBe(true); + await headerNameInput.fill("X-Multiline"); + await headerValueInput.fill("first line\nsecond line"); + expect(await headerNameInput.inputValue()).toBe("X-Multiline"); + expect(await headerValueInput.inputValue()).toBe("first line\nsecond line"); + await page.getByRole("button", { name: "Add header" }).click(); + await page.getByLabel("Header 2 name").fill("__proto__"); + await page.getByLabel("Header 2 value").fill("prototype-safe value"); + await page.getByRole("button", { name: "Send Email" }).click(); + + await expect.poll(() => editAndResendButton.isEnabled()).toBe(true); + expect(sentBodies[0]).toMatchObject({ + from: "sender@example.com", + subject: "Original subject", + text: "Original body", + to: ["recipient@example.com"], + }); + expect(sentBodies[0]?.headers).toEqual( + Object.fromEntries([ + ["X-Multiline", "first line\nsecond line"], + ["__proto__", "prototype-safe value"], + ]) + ); + + await page.getByRole("button", { name: /Test email/ }).click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toMatch(/\/email\/routing\/[^/]+$/); + await page.getByRole("link", { name: "Routing", exact: true }).click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toMatch(/\/email\/routing$/); + await expect.poll(() => editAndResendButton.isEnabled()).toBe(true); + + await page.getByRole("button", { name: "Edit and resend" }).click(); + expect(await page.locator("#test-email-from").inputValue()).toBe( + "sender@example.com" + ); + expect(await page.getByLabel("Subject").inputValue()).toBe( + "Original subject" + ); + expect(await page.getByLabel("Text body").inputValue()).toBe( + "Original body" + ); + expect(await page.getByLabel("Header 1 name").inputValue()).toBe( + "X-Multiline" + ); + expect(await page.getByLabel("Header 1 value").inputValue()).toBe( + "first line\nsecond line" + ); + expect(await page.getByLabel("Header 2 name").inputValue()).toBe( + "__proto__" + ); + expect(await page.getByLabel("Header 2 value").inputValue()).toBe( + "prototype-safe value" + ); + + await page.getByLabel("Subject").fill("Updated subject"); + await page.getByRole("button", { name: "Send Email" }).click(); + await expect.poll(() => sentBodies.length).toBe(2); + expect(sentBodies[1]).toMatchObject({ subject: "Updated subject" }); + }); + + test("reports composer validation errors accessibly and rejects managed headers", async ({ + expect, + }) => { + await mockEmailRoutingDetail(); + await loadWorker(); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-1", + viteUrl + ).toString() + ); + await page.getByRole("button", { name: "Send Test Email" }).click(); + await page.getByRole("heading", { name: "Send test email" }).waitFor(); + const fromInput = page.locator("#test-email-from"); + const toInput = page.locator("#test-email-to"); + expect(await fromInput.getAttribute("required")).not.toBeNull(); + expect(await toInput.getAttribute("required")).not.toBeNull(); + expect( + await page.locator('label[for="test-email-from"]').textContent() + ).toContain("From *"); + expect( + await page.locator('label[for="test-email-to"]').textContent() + ).toContain("To *"); + expect(await page.getByText("(optional)", { exact: true }).count()).toBe(0); + await page.getByRole("button", { name: "Send Email" }).click(); + await page + .getByText("A sender address is required.", { exact: true }) + .waitFor(); + await page + .getByText("At least one recipient is required.", { exact: true }) + .waitFor(); + expect(await fromInput.getAttribute("aria-invalid")).toBe("true"); + expect(await toInput.getAttribute("aria-invalid")).toBe("true"); + const fromErrorId = await fromInput.getAttribute("aria-describedby"); + const toErrorId = await toInput.getAttribute("aria-describedby"); + expect(fromErrorId).toBeTruthy(); + expect(toErrorId).toBeTruthy(); + expect(await page.locator(`[id="${fromErrorId}"]`).textContent()).toContain( + "A sender address is required." + ); + expect(await page.locator(`[id="${toErrorId}"]`).textContent()).toContain( + "At least one recipient is required." + ); + + await fromInput.fill("sender@example.com"); + await toInput.fill("recipient@example.com"); + expect( + await page + .getByText("A sender address is required.", { exact: true }) + .count() + ).toBe(0); + expect( + await page + .getByText("At least one recipient is required.", { exact: true }) + .count() + ).toBe(0); + await page.getByRole("button", { name: "Add header" }).click(); + const headersInput = page.getByLabel("Header 1 name"); + const headerValueInput = page.getByLabel("Header 1 value"); + expect(await page.getByText("Header 1", { exact: true }).count()).toBe(0); + expect( + await headerValueInput.evaluate( + (element) => window.getComputedStyle(element).resize + ) + ).toBe("none"); + const dialog = page.getByRole("dialog", { name: "Send test email" }); + const dialogWidthBeforeError = await dialog.evaluate( + (element) => element.getBoundingClientRect().width + ); + await headersInput.fill("message-id"); + await headerValueInput.fill("custom-message-id@example.com"); + await page.getByRole("button", { name: "Send Email" }).click(); + + const headersError = page.getByText(/is managed by the email composer/); + await headersError.waitFor(); + expect(await headersError.count()).toBe(1); + expect( + await dialog.evaluate((element) => element.getBoundingClientRect().width) + ).toBe(dialogWidthBeforeError); + expect(await headersInput.getAttribute("aria-invalid")).toBe("true"); + const headersErrorId = await headersInput.getAttribute("aria-describedby"); + expect(headersErrorId).toBeTruthy(); + expect(await page.locator(`[id="${headersErrorId}"]`).textContent()).toBe( + "message-id is managed by the email composer and cannot be overridden." + ); + await headersInput.fill("X-Custom-Header"); + expect(await headersError.count()).toBe(0); + expect(await headersInput.getAttribute("aria-invalid")).toBeNull(); + }); + + test("preserves the current email page after a pagination failure and retries", async ({ + expect, + }) => { + let failNextPage = true; + await page.route(EMAIL_ROUTING_DETAIL_ROUTE, async (route) => { + const cursor = new URL(route.request().url()).searchParams.get("cursor"); + if (cursor && failNextPage) { + failNextPage = false; + await route.fulfill({ status: 500, body: "Pagination failed" }); + return; + } + const pageNumber = cursor ? 2 : 1; + await fulfillApiResult( + route, + [ + { + attachments: [], + from: `sender-${pageNumber}@example.com`, + messageId: ``, + rawSize: 4, + receivedAt: "2026-08-24T00:00:00.000Z", + subject: `Page ${pageNumber}`, + to: "recipient@example.com", + }, + ], + { + resultInfo: { + count: 1, + cursor: cursor ? undefined : "next-page", + has_more: !cursor, + per_page: 10, + }, + } + ); + }); + await loadWorker(); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-1", + viteUrl + ).toString() + ); + await page.getByText("Page 1", { exact: true }).waitFor(); + + await page.getByRole("button", { name: "Next page" }).click(); + await page.getByRole("alert").waitFor(); + await page.getByText("Page 1", { exact: true }).waitFor(); + + await page.getByRole("button", { name: "Next page" }).click(); + await page.getByText("Page 2", { exact: true }).waitFor(); + expect(await page.getByRole("alert").count()).toBe(0); + }); + + test("explains the email handler requirement and toggles received raw content", async ({ + expect, + }) => { + await mockEmailRoutingDetail(false); + await loadWorker(); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-1", + viteUrl + ).toString() + ); + await page + .getByText( + /Email capture only works when the selected Worker has an email\(\) handler configured\./ + ) + .waitFor(); + + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing/test-email-id?worker=worker-1", + viteUrl + ).toString() + ); + const contentButton = page.getByRole("button", { name: /^Content/ }); + expect(await contentButton.getAttribute("aria-expanded")).toBe("false"); + await contentButton.click(); + const htmlButton = page.getByRole("button", { + name: "Preview", + exact: true, + }); + const rawButton = page.getByRole("button", { + name: "HTML source", + exact: true, + }); + await htmlButton.waitFor(); + expect(await htmlButton.getAttribute("aria-pressed")).toBe("true"); + expect(await rawButton.getAttribute("aria-pressed")).toBe("false"); + await page + .locator('iframe[title="Rendered received HTML email body"]') + .waitFor(); + + const headersButton = page.getByRole("button", { + name: /Email headers/, + }); + expect(await headersButton.textContent()).toContain("1 header"); + expect(await headersButton.getAttribute("aria-expanded")).toBe("false"); + expect(await page.getByText("X-Test-Header", { exact: true }).count()).toBe( + 0 + ); + await headersButton.click(); + expect(await headersButton.getAttribute("aria-expanded")).toBe("true"); + const headersPanel = page.getByTestId("received-email-headers-panel"); + await headersPanel.getByText("X-Test-Header", { exact: true }).waitFor(); + for (const structuredHeader of ["From", "Message-ID", "Subject", "To"]) { + expect( + await headersPanel.getByText(structuredHeader, { exact: true }).count() + ).toBe(0); + } + const multilineHeaderValue = headersPanel + .locator("dd") + .filter({ hasText: "first line" }); + await multilineHeaderValue.waitFor(); + expect(await multilineHeaderValue.textContent()).toBe( + "first line\nsecond line" + ); + await page.getByText("test-email-id", { exact: true }).waitFor(); + expect( + await page.getByText("", { exact: true }).count() + ).toBe(0); + + await rawButton.click(); + expect(await htmlButton.getAttribute("aria-pressed")).toBe("false"); + expect(await rawButton.getAttribute("aria-pressed")).toBe("true"); + await page + .locator("pre") + .filter({ + hasText: "

Rendered received HTML body

", + }) + .waitFor(); + const contentPanel = page.getByTestId("received-email-content-panel"); + await contentPanel.getByRole("heading", { name: "Raw MIME" }).waitFor(); + await contentPanel.getByText(/Content-Type: text\/plain/).waitFor(); + expect( + await page + .locator('iframe[title="Rendered received HTML email body"]') + .count() + ).toBe(0); + }); + + test("shows handler exceptions and truncated replies as message diagnostics", async () => { + await mockEmailRoutingDetail(false, { + handlerException: true, + replyTruncated: true, + }); + await loadWorker(); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-1", + viteUrl + ).toString() + ); + const emailRow = page.getByRole("button", { name: /Test email/ }); + await emailRow + .getByRole("img", { name: "Email processing exception" }) + .waitFor(); + await emailRow.click(); + + await page + .getByRole("alert") + .getByText(/email\(\) handler threw an exception/) + .waitFor(); + await page + .getByText(/Reply content was truncated during local capture/) + .waitFor(); + }); +}); diff --git a/packages/local-explorer-ui/src/__e2e__/email/email-sending.spec.ts b/packages/local-explorer-ui/src/__e2e__/email/email-sending.spec.ts new file mode 100644 index 00000000000..d1a098801a9 --- /dev/null +++ b/packages/local-explorer-ui/src/__e2e__/email/email-sending.spec.ts @@ -0,0 +1,156 @@ +import { afterEach, test } from "vitest"; +import { page, viteUrl } from "../utils"; +import { + cleanupEmailMocks, + EMAIL_PREVIEW_REMOTE_ROUTE, + mockSentEmail, +} from "./utils"; + +// Keep this independent of the production constant so weakening the preview +// policy causes the security test to fail. +const EXPECTED_EMAIL_PREVIEW_CSP = + "default-src 'none'; img-src data: cid:; style-src 'unsafe-inline'; font-src data:"; + +afterEach(async () => { + await cleanupEmailMocks(); +}); + +test("shows sent email details in a split view", async ({ expect }) => { + const hostileHtml = + '
'; + let remoteRequests = 0; + await page.route(EMAIL_PREVIEW_REMOTE_ROUTE, async (route) => { + remoteRequests += 1; + await route.abort(); + }); + const { requestedWorkers, showFullDetail } = await mockSentEmail({ + html: hostileHtml, + }); + + 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(); + await expect + .poll(() => page.getByTestId("sent-email-details").isVisible()) + .toBe(false); + + await page.getByRole("button", { name: /Sent email subject/ }).click(); + await expect + .poll(() => page.getByTestId("sent-email-details").isVisible()) + .toBe(true); + await page.getByRole("button", { name: /^Content/ }).click(); + await page.getByText("sent-email-id", { exact: true }).waitFor(); + expect(await page.getByText("", { exact: true }).count()).toBe( + 0 + ); + await page + .getByText("recipient@example.com", { exact: true }) + .last() + .waitFor(); + expect( + await page.getByText("", { exact: true }).count() + ).toBe(0); + await page.getByRole("heading", { name: "Standard headers" }).waitFor(); + await page.getByText("sender@example.com", { exact: true }).waitFor(); + expect( + await page.getByText("", { exact: true }).count() + ).toBe(0); + await page.getByText("Content-Type", { exact: true }).waitFor(); + await page.getByText("text/plain; charset=utf-8", { exact: true }).waitFor(); + await page.getByRole("heading", { name: "Custom headers" }).waitFor(); + await page.getByText("X-Custom-Header").waitFor(); + await page.getByText("custom value").waitFor(); + expect(await page.getByText("Worker", { exact: true }).count()).toBe(0); + 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); + + showFullDetail(); + await page.getByRole("button", { name: /Sent email subject/ }).click(); + await page.getByRole("button", { name: /^Content/ }).click(); + const contentPanel = page.getByTestId("sent-email-content-panel"); + const textBody = contentPanel.locator("pre").filter({ + hasText: "Plain text body", + }); + await textBody.waitFor(); + expect( + await textBody.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + hasHorizontalOverflow: element.scrollWidth > element.clientWidth, + overflowWrap: style.overflowWrap, + overflowX: style.overflowX, + }; + }) + ).toEqual({ + hasHorizontalOverflow: false, + overflowWrap: "break-word", + overflowX: "hidden", + }); + const parentUrl = page.url(); + const preview = page.locator('iframe[title="Rendered HTML email body"]'); + await preview.waitFor(); + expect(await preview.getAttribute("csp")).toBe(EXPECTED_EMAIL_PREVIEW_CSP); + expect(await preview.getAttribute("sandbox")).toBe(""); + expect(await preview.getAttribute("referrerpolicy")).toBe("no-referrer"); + const previewFrame = preview.contentFrame(); + await previewFrame.locator("body").waitFor(); + expect( + await previewFrame + .locator('meta[http-equiv="Content-Security-Policy"]') + .getAttribute("content") + ).toBe(EXPECTED_EMAIL_PREVIEW_CSP); + expect( + await previewFrame.locator("body").getAttribute("data-script-executed") + ).toBeNull(); + await previewFrame + .getByRole("button", { name: "Submit unsafe form" }) + .click(); + await page.waitForTimeout(250); + expect(remoteRequests).toBe(0); + expect(page.url()).toBe(parentUrl); + const previewView = page.getByRole("button", { + name: "Preview", + exact: true, + }); + const sourceView = page.getByRole("button", { + name: "HTML source", + exact: true, + }); + await expect + .poll(() => previewView.getAttribute("aria-pressed")) + .toBe("true"); + await expect + .poll(() => sourceView.getAttribute("aria-pressed")) + .toBe("false"); + await sourceView.click(); + await page.getByText(hostileHtml, { exact: true }).waitFor(); + await expect.poll(() => sourceView.getAttribute("aria-pressed")).toBe("true"); + expect( + await page.locator('iframe[title="Rendered HTML email body"]').count() + ).toBe(0); + await contentPanel.getByRole("heading", { name: "Raw MIME" }).waitFor(); + await contentPanel + .getByText("Content-Type: text/html; charset=utf-8", { exact: false }) + .waitFor(); +}); diff --git a/packages/local-explorer-ui/src/__e2e__/email/utils.ts b/packages/local-explorer-ui/src/__e2e__/email/utils.ts new file mode 100644 index 00000000000..b78435390ae --- /dev/null +++ b/packages/local-explorer-ui/src/__e2e__/email/utils.ts @@ -0,0 +1,239 @@ +import { page, viteUrl } from "../utils"; +import type { Route } from "playwright-chromium"; + +export const WORKERS_ROUTE = "**/cdn-cgi/local/explorer/api/local/workers"; +export const EMAIL_ROUTING_DETAIL_ROUTE = + "**/cdn-cgi/local/explorer/api/local/email/routing?*"; +export const EMAIL_ROUTING_SEND_ROUTE = + "**/cdn-cgi/local/explorer/api/local/email/routing/send?*"; +export const EMAIL_SENDING_ROUTE = + "**/cdn-cgi/local/explorer/api/local/email/sending?*"; +export const EMAIL_PREVIEW_REMOTE_ROUTE = "https://email-preview.invalid/**"; + +interface ApiResponseOptions { + messages?: Array<{ code: number; message: string }>; + resultInfo?: Record; +} + +interface MockSentEmailOptions { + html: string; +} + +interface MockRoutingEmailOptions { + handlerException?: boolean; + showInList?: boolean; + replyTruncated?: boolean; +} + +interface Worker { + bindings?: Record; + isSelf: boolean; + name: string; +} + +async function mockWorkers(workers: Worker[]): Promise { + await page.route(WORKERS_ROUTE, async (route) => { + await fulfillApiResult(route, workers); + }); +} + +/** Fulfils a mocked Local Explorer API request with the standard envelope. */ +export async function fulfillApiResult( + route: Route, + result: unknown, + options: ApiResponseOptions = {} +): Promise { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + errors: [], + messages: options.messages ?? [], + result, + result_info: options.resultInfo, + success: true, + }), + }); +} + +/** Mocks the worker list and loads the Local Explorer application. */ +export async function loadWorker( + workers: Worker[] = [{ isSelf: true, name: "worker-1" }] +): Promise { + await mockWorkers(workers); + await page.goto(viteUrl); +} + +/** Mocks the received-email list/detail endpoint used by routing tests. */ +export async function mockEmailRoutingDetail( + truncated = true, + options: MockRoutingEmailOptions = {} +): Promise { + await page.route(EMAIL_ROUTING_DETAIL_ROUTE, async (route) => { + const emailId = new URL(route.request().url()).searchParams.get("email_id"); + const messages = emailId + ? [ + ...(truncated + ? [ + { + code: 10604, + message: + "Displayed received email content was truncated during local capture. The complete message was still delivered to the Worker.", + }, + ] + : []), + ...(options.replyTruncated + ? [ + { + code: 10604, + message: + "Displayed reply content was truncated during local capture. The complete reply is available in the local filesystem; see the development log for its path.", + }, + ] + : []), + ] + : []; + const summary = { + attachments: [], + events: options.handlerException + ? [ + { + timestamp: "2024-01-01T00:00:00.000Z", + type: "received", + }, + ] + : [], + forwards: [], + from: "sender@example.com", + messageId: "", + outcome: options.handlerException ? "exception" : "ok", + rawSize: 42, + receivedAt: "2024-01-01T00:00:00.000Z", + replies: [], + subject: "Test email", + to: "recipient@example.com", + }; + const result = emailId + ? { + ...summary, + headers: { + From: "sender@example.com", + "Message-ID": "", + Subject: "Test email", + To: "recipient@example.com", + "X-Test-Header": "first line\nsecond line", + }, + headerEntries: [ + ["From", "sender@example.com"], + ["Message-ID", ""], + ["Subject", "Test email"], + ["To", "recipient@example.com"], + ["X-Test-Header", "first line\nsecond line"], + ], + html: "

Rendered received HTML body

", + raw: "Content-Type: text/plain\r\n\r\nPlain received text body", + text: "Plain received text body", + } + : options.handlerException || options.showInList + ? [summary] + : []; + await fulfillApiResult(route, result, { + messages, + resultInfo: emailId + ? undefined + : { + count: Array.isArray(result) ? result.length : 0, + has_more: false, + per_page: 25, + }, + }); + }); +} + +/** Mocks an empty sent-email list for navigation tests. */ +export async function mockEmptyEmailSending(): Promise { + await page.route(EMAIL_SENDING_ROUTE, async (route) => { + await fulfillApiResult(route, [], { + resultInfo: { count: 0, has_more: false, per_page: 25 }, + }); + }); +} + +/** Mocks the sent-email list/detail flow and exposes its mutable test state. */ +export async function mockSentEmail({ html }: MockSentEmailOptions): Promise<{ + requestedWorkers: Array; + showFullDetail: () => void; +}> { + const requestedWorkers: Array = []; + let detailTruncated = true; + await mockWorkers([ + { + bindings: { sendEmail: [{ bindingName: "SEND_EMAIL" }] }, + isSelf: true, + name: "worker-1", + }, + { bindings: {}, isSelf: false, name: "worker-2" }, + ]); + 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: "", + headers: { + "Content-Type": "text/plain; charset=utf-8", + "X-Custom-Header": "custom value", + }, + messageId: "", + sentAt: "2026-08-21T12:00:00.000Z", + subject: "Sent email subject", + to: [""], + worker: "worker-1", + }; + await fulfillApiResult( + route, + emailId + ? { + ...summary, + html, + rawBase64: Buffer.from( + `From: sender@example.com\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${html}` + ).toString("base64"), + text: `Plain text body ${"x".repeat(300)}`, + } + : [summary], + { + messages: + emailId && detailTruncated + ? [ + { + 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.", + }, + ] + : [], + resultInfo: emailId + ? undefined + : { count: 1, has_more: false, per_page: 25 }, + } + ); + }); + return { + requestedWorkers, + showFullDetail: () => { + detailTruncated = false; + }, + }; +} + +/** Removes all email-specific request handlers registered by a test. */ +export async function cleanupEmailMocks(): Promise { + await Promise.all([ + page.unroute(WORKERS_ROUTE), + page.unroute(EMAIL_ROUTING_DETAIL_ROUTE), + page.unroute(EMAIL_ROUTING_SEND_ROUTE), + page.unroute(EMAIL_SENDING_ROUTE), + page.unroute(EMAIL_PREVIEW_REMOTE_ROUTE), + ]); +} 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..9054e2417c9 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,201 @@ 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 + ? { + attachments: [], + events: [], + forwards: [], + from: "sender@example.com", + html: "

Rendered received HTML body

", + messageId: "", + outcome: "ok", + raw: "Content-Type: text/plain\r\n\r\nPlain received text body", + rawSize: 42, + receivedAt: "2024-01-01T00:00:00.000Z", + replies: [], + subject: "Test email", + text: "Plain received text body", + to: "recipient@example.com", + } + : [], + 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("canonicalizes missing and invalid workers before loading email data", async ({ + expect, + }) => { + const requestedWorkers: Array = []; + await page.route(EMAIL_ROUTING_DETAIL_ROUTE, async (route) => { + const search = new URL(route.request().url()).searchParams; + const emailId = search.get("email_id"); + requestedWorkers.push(search.get("worker")); + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + errors: [], + messages: [], + result: emailId + ? { + attachments: [], + events: [], + forwards: [], + from: "sender@example.com", + messageId: "", + outcome: "ok", + raw: "Content-Type: text/plain\r\n\r\nBody", + rawSize: 4, + receivedAt: "2026-08-24T00:00:00.000Z", + replies: [], + subject: "Direct email", + text: "Body", + to: "recipient@example.com", + } + : [], + result_info: emailId + ? undefined + : { count: 0, has_more: false, per_page: 10 }, + success: true, + }), + }); + }); + await loadWorkers(2); + + await page.goto( + new URL("/cdn-cgi/local/explorer/email/routing", viteUrl).toString() + ); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-1"); + await expect.poll(() => requestedWorkers.length).toBeGreaterThan(0); + expect(requestedWorkers.every((worker) => worker === "worker-1")).toBe( + true + ); + + requestedWorkers.length = 0; + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=missing-worker", + viteUrl + ).toString() + ); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-1"); + await expect.poll(() => requestedWorkers.length).toBeGreaterThan(0); + expect(requestedWorkers.every((worker) => worker === "worker-1")).toBe( + true + ); + + requestedWorkers.length = 0; + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing/test-email-id", + viteUrl + ).toString() + ); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-1"); + await page.getByText("Direct email").last().waitFor(); + await page.getByText("test-email-id", { exact: true }).waitFor(); + expect( + await page.getByText("", { exact: true }).count() + ).toBe(0); + await expect.poll(() => requestedWorkers.length).toBeGreaterThan(0); + expect(requestedWorkers.every((worker) => worker === "worker-1")).toBe( + true + ); + }); + + 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("button", { 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("button", { name: /Worker two email/ }).waitFor(); + releaseStaleResponse?.(); + await page.waitForTimeout(100); + + expect(await page.getByRole("button", { name: /Old email/ }).count()).toBe( + 0 + ); + }); + test("stays hidden when there is only one worker", async ({ expect }) => { await loadWorkers(1); @@ -129,4 +321,55 @@ 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.getByRole("button", { name: /^Content/ }).click(); + 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"); + + // The redirect belongs to the selector action. Browser history can still + // restore the previous worker's valid email detail page. + await page.goBack(); + await expect + .poll(() => new URL(page.url()).pathname) + .toMatch(/\/email\/routing\/test-email-id$/); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-1"); + }); }); diff --git a/packages/local-explorer-ui/src/__tests__/components/email-handler-outcome-warning.test.ts b/packages/local-explorer-ui/src/__tests__/components/email-handler-outcome-warning.test.ts new file mode 100644 index 00000000000..b9153f6e059 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/components/email-handler-outcome-warning.test.ts @@ -0,0 +1,35 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { test } from "vitest"; +import { + EmailHandlerOutcomeWarning, + hasEmailHandlerException, +} from "../../components/email/EmailHandlerOutcomeWarning"; + +test("identifies handler exceptions without treating missing handlers as throws", ({ + expect, +}) => { + expect( + hasEmailHandlerException({ + events: [{ timestamp: "2026-08-27T00:00:00.000Z", type: "received" }], + outcome: "exception", + }) + ).toBe(true); + expect( + hasEmailHandlerException({ + events: [{ timestamp: "2026-08-27T00:00:00.000Z", type: "unhandled" }], + outcome: "exception", + }) + ).toBe(false); + expect( + hasEmailHandlerException({ + events: [{ timestamp: "2026-08-27T00:00:00.000Z", type: "received" }], + outcome: "ok", + }) + ).toBe(false); +}); + +test("renders a message-level handler exception alert", ({ expect }) => { + const markup = renderToStaticMarkup(EmailHandlerOutcomeWarning()); + expect(markup).toContain('role="alert"'); + expect(markup).toContain("handler threw an exception"); +}); diff --git a/packages/local-explorer-ui/src/__tests__/components/email-truncation-warning.test.ts b/packages/local-explorer-ui/src/__tests__/components/email-truncation-warning.test.ts new file mode 100644 index 00000000000..0932765ce12 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/components/email-truncation-warning.test.ts @@ -0,0 +1,50 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { test } from "vitest"; +import { + EmailTruncationWarning, + hasEmailTruncationWarning, +} from "../../components/email/EmailTruncationWarning"; + +test("identifies truncation using the warning message for its context", ({ + expect, +}) => { + const receivedWarning = [ + { + code: 10604, + message: + "Displayed received email content was truncated during local capture. The complete message was still delivered to the Worker.", + }, + ]; + const replyWarning = [ + { + code: 10604, + message: + "Displayed reply content was truncated during local capture. The complete reply is available in the local filesystem; see the development log for its path.", + }, + ]; + const sentWarning = [ + { + 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.", + }, + ]; + + expect(hasEmailTruncationWarning(receivedWarning, "received")).toBe(true); + expect(hasEmailTruncationWarning(receivedWarning, "reply")).toBe(false); + expect(hasEmailTruncationWarning(receivedWarning, "sent")).toBe(false); + expect(hasEmailTruncationWarning(replyWarning, "received")).toBe(false); + expect(hasEmailTruncationWarning(replyWarning, "reply")).toBe(true); + expect(hasEmailTruncationWarning(replyWarning, "sent")).toBe(false); + expect(hasEmailTruncationWarning(sentWarning, "received")).toBe(false); + expect(hasEmailTruncationWarning(sentWarning, "reply")).toBe(false); + expect(hasEmailTruncationWarning(sentWarning, "sent")).toBe(true); +}); + +test("renders a reply-specific truncation warning", ({ expect }) => { + const markup = renderToStaticMarkup( + EmailTruncationWarning({ kind: "reply" }) + ); + expect(markup).toContain("Reply content was truncated"); + expect(markup).toContain("temporary local storage"); +}); diff --git a/packages/local-explorer-ui/src/__tests__/utils/email-headers.test.ts b/packages/local-explorer-ui/src/__tests__/utils/email-headers.test.ts new file mode 100644 index 00000000000..3dc0d0d6330 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/utils/email-headers.test.ts @@ -0,0 +1,29 @@ +import { + EMAIL_HEADER_NAME_CASES, + EMAIL_HEADER_VALUE_CASES, + MANAGED_EMAIL_HEADER_CASES, +} from "@cloudflare/workers-utils/test-helpers"; +import { test } from "vitest"; +import { + hasInvalidEmailHeaderValueCharacters, + isEmailHeaderName, + isManagedEmailHeaderName, +} from "../../utils/email-headers"; + +for (const [name, value, valid] of EMAIL_HEADER_NAME_CASES) { + test(`header names: ${name}`, ({ expect }) => { + expect(isEmailHeaderName(value)).toBe(valid); + }); +} + +for (const [name, managed] of MANAGED_EMAIL_HEADER_CASES) { + test(`managed headers: identifies ${name}`, ({ expect }) => { + expect(isManagedEmailHeaderName(name)).toBe(managed); + }); +} + +for (const [name, value, valid] of EMAIL_HEADER_VALUE_CASES) { + test(`header values: ${name}`, ({ expect }) => { + expect(hasInvalidEmailHeaderValueCharacters(value)).toBe(!valid); + }); +} 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/format.test.ts b/packages/local-explorer-ui/src/__tests__/utils/format.test.ts index d2cd841a6f2..3a840008a8e 100644 --- a/packages/local-explorer-ui/src/__tests__/utils/format.test.ts +++ b/packages/local-explorer-ui/src/__tests__/utils/format.test.ts @@ -1,5 +1,43 @@ import { describe, test } from "vitest"; -import { formatDate } from "../../utils/format"; +import { + formatDate, + formatEmailAddress, + formatMessageId, +} from "../../utils/format"; + +describe("formatEmailAddress", () => { + test("removes surrounding angle brackets", ({ expect }) => { + expect(formatEmailAddress("")).toBe( + "recipient@example.com" + ); + }); + + test("removes angle brackets around an address with a display name", ({ + expect, + }) => { + expect(formatEmailAddress('"Recipient" ')).toBe( + '"Recipient" recipient@example.com' + ); + }); + + test("preserves an address without angle brackets", ({ expect }) => { + expect(formatEmailAddress("recipient@example.com")).toBe( + "recipient@example.com" + ); + }); +}); + +describe("formatMessageId", () => { + test("removes surrounding angle brackets", ({ expect }) => { + expect(formatMessageId("")).toBe( + "message@example.com" + ); + }); + + test("preserves an ID without angle brackets", ({ expect }) => { + expect(formatMessageId("message@example.com")).toBe("message@example.com"); + }); +}); describe("formatDate", () => { test("`undefined` returns '-'", ({ expect }) => { 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/components/Sidebar.tsx b/packages/local-explorer-ui/src/components/Sidebar.tsx index 610ffd68259..1adb274e5dc 100644 --- a/packages/local-explorer-ui/src/components/Sidebar.tsx +++ b/packages/local-explorer-ui/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { useSidebar, } from "@cloudflare/kumo"; import { + EnvelopeSimpleIcon, MonitorIcon, MoonIcon, PulseIcon, @@ -208,6 +209,36 @@ export function AppSidebar({ })), title: "Workflows", }, + { + emptyLabel: "No email", + groupId: "email" as const, + icon: EnvelopeSimpleIcon, + 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; @@ -362,7 +393,15 @@ export function AppSidebar({ {sidebarItemGroups.map((group) => ( } + icon={ + + } items={group.items} key={group.groupId} title={group.title} diff --git a/packages/local-explorer-ui/src/components/email/EmailContent.tsx b/packages/local-explorer-ui/src/components/email/EmailContent.tsx new file mode 100644 index 00000000000..097813cd094 --- /dev/null +++ b/packages/local-explorer-ui/src/components/email/EmailContent.tsx @@ -0,0 +1,114 @@ +import { LayerCard } from "@cloudflare/kumo"; +import { Accordion } from "@cloudflare/kumo/primitives/accordion"; +import { CaretDownIcon } from "@phosphor-icons/react"; +import { EmailHtmlBody } from "./EmailHtmlBody"; +import { EmailTruncationWarning } from "./EmailTruncationWarning"; +import type { JSX } from "react"; + +interface EmailContentProps { + html?: string; + kind: "received" | "sent"; + previewTitle: string; + raw?: string; + rawBase64?: string; + text?: string; + truncated: boolean; +} + +function decodeRawMime(raw?: string, rawBase64?: string): string | undefined { + if (raw !== undefined) { + return raw; + } + if (rawBase64 === undefined) { + return undefined; + } + try { + const binary = atob(rawBase64); + const bytes = Uint8Array.from(binary, (character) => + character.charCodeAt(0) + ); + return new TextDecoder().decode(bytes); + } catch { + return undefined; + } +} + +function ContentHeading({ children }: { children: string }): JSX.Element { + return ( +

+ {children} +

+ ); +} + +/** Renders captured email bodies and raw MIME in a shared accordion. */ +export function EmailContent({ + html, + kind, + previewTitle, + raw, + rawBase64, + text, + truncated, +}: EmailContentProps): JSX.Element { + const rawMime = decodeRawMime(raw, rawBase64); + + return ( + + + + + + + Content + + + + + + + + +
+ {truncated ? : null} + + {!truncated && text ? ( +
+ Text body +
+											{text}
+										
+
+ ) : null} + + {!truncated && html ? ( +
+ +
+ ) : null} + + {!truncated && !text && !html ? ( +

+ This email has no captured text or HTML body. +

+ ) : null} + + {rawMime === undefined ? null : ( +
+ Raw MIME +
+											{rawMime}
+										
+
+ )} +
+
+
+
+
+
+ ); +} diff --git a/packages/local-explorer-ui/src/components/email/EmailHandlerOutcomeWarning.tsx b/packages/local-explorer-ui/src/components/email/EmailHandlerOutcomeWarning.tsx new file mode 100644 index 00000000000..2245c898bfb --- /dev/null +++ b/packages/local-explorer-ui/src/components/email/EmailHandlerOutcomeWarning.tsx @@ -0,0 +1,31 @@ +import { WarningIcon } from "@phosphor-icons/react"; +import type { EmailRoutingDetail } from "../../api"; +import type { JSX } from "react"; + +/** Distinguishes a thrown handler from a Worker with no email handler. */ +export function hasEmailHandlerException( + email: Pick +): boolean { + return ( + email.outcome === "exception" && + !email.events.some(({ type }) => type === "unhandled") + ); +} + +/** Indicates that delivery reached the handler, but the handler threw. */ +export function EmailHandlerOutcomeWarning(): JSX.Element { + return ( +
+ + + +

+ The Worker’s email() handler threw an exception while processing + this message. See the development log for error details. +

+
+ ); +} diff --git a/packages/local-explorer-ui/src/components/email/EmailHtmlBody.tsx b/packages/local-explorer-ui/src/components/email/EmailHtmlBody.tsx new file mode 100644 index 00000000000..eff52082eda --- /dev/null +++ b/packages/local-explorer-ui/src/components/email/EmailHtmlBody.tsx @@ -0,0 +1,54 @@ +import { Button } from "@cloudflare/kumo"; +import { useState } from "react"; +import { EmailHtmlPreview } from "./EmailHtmlPreview"; +import type { JSX } from "react"; + +interface EmailHtmlBodyProps { + html: string; + previewTitle: string; +} + +/** + * Renders an HTML email preview with an optional HTML-source view. + * + * @param html - Captured HTML email content + * @param previewTitle - Accessible title for the HTML preview iframe + * @returns The HTML body frame and its view controls + */ +export function EmailHtmlBody({ + html, + previewTitle, +}: EmailHtmlBodyProps): JSX.Element { + const [view, setView] = useState<"preview" | "source">("preview"); + + return ( +
+
+

HTML body

+
+ + +
+
+ {view === "preview" ? ( + + ) : ( +
+					{html}
+				
+ )} +
+ ); +} 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 ( +