diff --git a/.changeset/fresh-mails-resend.md b/.changeset/fresh-mails-resend.md new file mode 100644 index 00000000000..50bcdbc3cd5 --- /dev/null +++ b/.changeset/fresh-mails-resend.md @@ -0,0 +1,8 @@ +--- +"@cloudflare/local-explorer-ui": minor +"miniflare": minor +--- + +Add row-level email resend tools to the Local Explorer + +Routing captures can now be resent directly or loaded into the test email composer for editing. Routing rows expose and use a UUID-based capture ID for identity, detail lookup, and resend operations instead of relying on the email's Message-ID. Message-ID detail lookup remains available for compatibility, and resends preserve partial-capture warnings across replayed messages. 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 index 1565b8394b2..63c2fb54daf 100644 --- a/packages/local-explorer-ui/src/__e2e__/email/email-routing.spec.ts +++ b/packages/local-explorer-ui/src/__e2e__/email/email-routing.spec.ts @@ -3,11 +3,14 @@ import { page, viteUrl } from "../utils"; import { cleanupEmailMocks, EMAIL_ROUTING_DETAIL_ROUTE, + EMAIL_ROUTING_RESEND_DRAFT_ROUTE, + EMAIL_ROUTING_RESEND_ROUTE, EMAIL_ROUTING_SEND_ROUTE, fulfillApiResult, loadWorker, mockEmailRoutingDetail, mockEmptyEmailSending, + WORKERS_ROUTE, } from "./utils"; afterEach(async () => { @@ -85,7 +88,7 @@ describe("email routing", () => { viteUrl ).toString() ); - await page.getByRole("button", { name: "Send Test Email" }).click(); + 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(() => { @@ -124,9 +127,125 @@ describe("email routing", () => { 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("suppresses stale composer results after unmounting Routing", async ({ + expect, + }) => { + await mockEmailRoutingDetail(); + await mockEmptyEmailSending(); + await loadWorker(); + let releaseSend: (() => void) | undefined; + const sendReleased = new Promise((resolve) => { + releaseSend = resolve; + }); + let sendRouteSettled = false; + let sendRouteStarted = false; + await page.route(EMAIL_ROUTING_SEND_ROUTE, async (route) => { + sendRouteStarted = true; + await sendReleased; + try { + await route.fulfill({ + body: JSON.stringify({ + errors: [{ code: 10602, message: "Stale worker failure" }], + messages: [], + result: null, + success: false, + }), + contentType: "application/json", + status: 400, + }); + } catch { + // The UI intentionally aborts this route on Worker change. + } finally { + sendRouteSettled = true; + } + }); + 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.getByRole("button", { name: "Send Email" }).click(); + await expect.poll(() => sendRouteStarted).toBe(true); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/sending?worker=worker-1", + viteUrl + ).toString() + ); + releaseSend?.(); + await expect.poll(() => sendRouteSettled).toBe(true); + + expect(await page.getByText("Stale worker failure").count()).toBe(0); + expect( + await page.getByRole("heading", { name: "Send test email" }).count() + ).toBe(0); + }); + + test("suppresses stale row-action results after unmounting Routing", async ({ + expect, + }) => { + await mockEmailRoutingDetail(true, { showInList: true }); + await mockEmptyEmailSending(); + await loadWorker(); + let releaseDraft: (() => void) | undefined; + const draftReleased = new Promise((resolve) => { + releaseDraft = resolve; + }); + let draftRouteSettled = false; + let draftRouteStarted = false; + await page.route(EMAIL_ROUTING_RESEND_DRAFT_ROUTE, async (route) => { + draftRouteStarted = true; + await draftReleased; + try { + await route.fulfill({ + body: JSON.stringify({ + errors: [{ code: 10602, message: "Stale draft failure" }], + messages: [], + result: null, + success: false, + }), + contentType: "application/json", + status: 400, + }); + } catch { + // Layout cleanup aborts the request before the stale response settles. + } finally { + draftRouteSettled = true; + } + }); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-1", + viteUrl + ).toString() + ); + await page + .getByRole("button", { name: "Edit and resend" }) + .dispatchEvent("click"); + await expect.poll(() => draftRouteStarted).toBe(true); + await page.evaluate(() => { + window.history.pushState( + null, + "", + "/cdn-cgi/local/explorer/email/sending?worker=worker-1" + ); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + await page.waitForURL(/\/email\/sending/); + await page.getByText("No sending service", { exact: true }).waitFor(); + releaseDraft?.(); + await expect.poll(() => draftRouteSettled).toBe(true); + + expect(await page.getByText("Stale draft failure").count()).toBe(0); + expect( + await page.getByRole("heading", { name: "Send test email" }).count() + ).toBe(0); }); test("closes and refreshes when an email is captured without a handler", async ({ @@ -142,6 +261,11 @@ describe("email routing", () => { : [ { attachments: [], + captureId: "00000000-0000-4000-8000-000000000002", + capturedPortion: false, + editAndResendAvailable: false, + editAndResendUnavailableReason: + "Raw or unknown captures cannot be edited and resent.", events: [ { timestamp: "2026-08-27T00:00:00.000Z", @@ -155,6 +279,7 @@ describe("email routing", () => { receivedAt: "2026-08-27T00:00:00.000Z", subject: "Captured without handler", to: "recipient@example.com", + worker: "worker-1", }, ], { @@ -190,7 +315,7 @@ describe("email routing", () => { viteUrl ).toString() ); - await page.getByRole("button", { name: "Send Test Email" }).click(); + 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"); @@ -225,7 +350,7 @@ describe("email routing", () => { viteUrl ).toString() ); - await page.getByRole("button", { name: "Send Test Email" }).click(); + await page.getByRole("button", { name: "Send test email" }).click(); const attachmentInput = page.getByLabel("Attachments"); await page.evaluate(() => { const arrayBuffer = File.prototype.arrayBuffer; @@ -241,7 +366,7 @@ describe("email routing", () => { }); await page.keyboard.press("Escape"); await page.waitForTimeout(250); - await page.getByRole("button", { name: "Send Test Email" }).click(); + await page.getByRole("button", { name: "Send test email" }).click(); expect(await page.getByText("cancelled-on-close.bin").count()).toBe(0); await attachmentInput.setInputFiles({ @@ -255,13 +380,43 @@ describe("email routing", () => { ).toBe(0); }); - test("edits and resends the last successful email with multiline headers", async ({ + test("loads an exact captured draft and submits edited fields", async ({ expect, }) => { - await mockEmailRoutingDetail(true, { showInList: true }); - await loadWorker(); + const routingMock = await mockEmailRoutingDetail(true, { + showInList: true, + worker: "worker-2", + }); + await loadWorker([ + { isSelf: true, name: "worker-1" }, + { isSelf: false, name: "worker-2" }, + ]); + let draftQuery: URLSearchParams | undefined; + await page.route(EMAIL_ROUTING_RESEND_DRAFT_ROUTE, async (route) => { + draftQuery = new URL(route.request().url()).searchParams; + await fulfillApiResult(route, { + attachments: [ + { + content: Buffer.from("attachment body").toString("base64"), + filename: "example.txt", + type: "text/plain", + }, + ], + from: "sender@example.com", + headers: { + "X-Multiline": "first line\nsecond line", + }, + subject: "Original subject", + text: "Original body", + to: ['"Friends": first@example.com, second@example.com;'], + }); + }); const sentBodies: Array> = []; + const sentWorkers: Array = []; await page.route(EMAIL_ROUTING_SEND_ROUTE, async (route) => { + sentWorkers.push( + new URL(route.request().url()).searchParams.get("worker") + ); sentBodies.push( route.request().postDataJSON() as Record ); @@ -281,77 +436,226 @@ describe("email routing", () => { 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(); + expect(await editAndResendButton.getAttribute("aria-disabled")).toBeNull(); + await editAndResendButton.click(); + await page.getByRole("heading", { name: "Send test email" }).waitFor(); + expect(draftQuery?.get("capture_id")).toBe( + "00000000-0000-4000-8000-000000000001" + ); + expect(draftQuery?.get("worker")).toBe("worker-2"); + expect(await page.locator("#test-email-from").inputValue()).toBe( + "sender@example.com" + ); + expect(await page.getByLabel("Subject").inputValue()).toBe( + "Original subject" + ); + expect(await page.getByText("text/plain · 15 B").count()).toBe(1); 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.getByLabel("Subject").fill("Updated subject"); await page.getByRole("button", { name: "Send Email" }).click(); - - await expect.poll(() => editAndResendButton.isEnabled()).toBe(true); + await expect.poll(() => sentBodies.length).toBe(1); 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"], - ]) + attachments: [ + { + content: Buffer.from("attachment body").toString("base64"), + filename: "example.txt", + type: "text/plain", + }, + ], + subject: "Updated subject", + to: ['"Friends": first@example.com, second@example.com;'], + }); + expect(sentBodies[0]).not.toHaveProperty("captureId"); + expect(sentWorkers).toEqual(["worker-2"]); + await expect.poll(() => routingMock.listRequestCount()).toBeGreaterThan(1); + }); + + test("isolates exact-capture row actions and reports immediate resend", async ({ + expect, + }) => { + const routingMock = await mockEmailRoutingDetail(true, { + showInList: true, + worker: "worker-2", + }); + await loadWorker([ + { isSelf: true, name: "worker-1" }, + { isSelf: false, name: "worker-2" }, + ]); + let requestQuery: URLSearchParams | undefined; + let resendRequests = 0; + let releaseResend: (() => void) | undefined; + const resendReleased = new Promise((resolve) => { + releaseResend = resolve; + }); + await page.route(EMAIL_ROUTING_RESEND_ROUTE, async (route) => { + resendRequests++; + requestQuery = new URL(route.request().url()).searchParams; + await resendReleased; + await fulfillApiResult(route, { + capturedPortion: true, + messageId: "", + outcome: "ok", + }); + }); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-2", + viteUrl + ).toString() ); - await page.getByRole("button", { name: /Test email/ }).click(); + const row = page.getByRole("button", { name: /Test email/ }); + const resendButton = page.getByRole("button", { + exact: true, + name: "Resend", + }); + await row.waitFor(); + await resendButton.click(); + expect(new URL(page.url()).pathname).toMatch(/\/email\/routing$/); + await expect.poll(() => resendButton.isDisabled()).toBe(true); + expect(resendRequests).toBe(1); + expect(requestQuery?.get("capture_id")).toBe( + "00000000-0000-4000-8000-000000000001" + ); + expect(requestQuery?.get("worker")).toBe("worker-2"); + + releaseResend?.(); + await page.getByText("Email resent.", { exact: true }).waitFor(); + await page + .getByText( + "Only the captured portion of the original email was available.", + { exact: true } + ) + .waitFor(); + await expect.poll(() => routingMock.listRequestCount()).toBeGreaterThan(1); + + await page.unroute(WORKERS_ROUTE); + await page.route(WORKERS_ROUTE, async (route) => { + await fulfillApiResult(route, [{ isSelf: true, name: "worker-1" }]); + }); + await row.click(); await expect .poll(() => new URL(page.url()).pathname) - .toMatch(/\/email\/routing\/[^/]+$/); - await page.getByRole("link", { name: "Routing", exact: true }).click(); + .toContain("00000000-0000-4000-8000-000000000001"); await expect - .poll(() => new URL(page.url()).pathname) - .toMatch(/\/email\/routing$/); - await expect.poll(() => editAndResendButton.isEnabled()).toBe(true); + .poll(() => routingMock.detailRequestWorkers.at(-1)) + .toBe("worker-2"); + }); - 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" + test("keeps unavailable editing focusable and uses compatibility detail lookup for older-peer rows", async ({ + expect, + }) => { + let draftRequests = 0; + let legacyDetailQuery: URLSearchParams | undefined; + await page.route(EMAIL_ROUTING_DETAIL_ROUTE, async (route) => { + const search = new URL(route.request().url()).searchParams; + if (search.has("email_id")) { + legacyDetailQuery = search; + await fulfillApiResult(route, { + attachments: [], + events: [], + forwards: [], + from: "legacy@example.com", + headers: {}, + messageId: "", + outcome: "ok", + raw: "Content-Type: text/plain\r\n\r\nLegacy body", + rawBase64: btoa("Content-Type: text/plain\r\n\r\nLegacy body"), + rawSize: 42, + receivedAt: "2026-08-26T00:00:00.000Z", + replies: [], + subject: "Older peer capture", + text: "Legacy body", + to: "recipient@example.com", + worker: "worker-2", + }); + return; + } + await fulfillApiResult( + route, + [ + { + attachments: [], + captureId: "00000000-0000-4000-8000-000000000003", + capturedPortion: true, + editAndResendAvailable: false, + editAndResendUnavailableReason: + "An incomplete capture cannot be edited and resent safely.", + from: "partial@example.com", + messageId: "", + outcome: "ok", + rawSize: 42, + receivedAt: "2026-08-27T00:00:00.000Z", + subject: "Partial capture", + to: "recipient@example.com", + worker: "worker-2", + }, + { + attachments: [], + from: "legacy@example.com", + messageId: "", + outcome: "ok", + rawSize: 42, + receivedAt: "2026-08-26T00:00:00.000Z", + subject: "Older peer capture", + to: "recipient@example.com", + worker: "worker-2", + }, + ], + { + resultInfo: { count: 2, has_more: false, per_page: 25 }, + } + ); + }); + await page.route(EMAIL_ROUTING_RESEND_DRAFT_ROUTE, async (route) => { + draftRequests++; + await fulfillApiResult(route, null); + }); + await loadWorker([ + { isSelf: true, name: "worker-1" }, + { isSelf: false, name: "worker-2" }, + ]); + await page.goto( + new URL( + "/cdn-cgi/local/explorer/email/routing?worker=worker-2", + viteUrl + ).toString() ); - 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" }); + const editButton = page.getByRole("button", { name: "Edit and resend" }); + await editButton.waitFor(); + expect(await editButton.getAttribute("disabled")).toBeNull(); + expect(await editButton.getAttribute("aria-disabled")).toBe("true"); + const editButtonClass = (await editButton.getAttribute("class")) ?? ""; + expect(editButtonClass).toContain("cursor-not-allowed"); + expect(editButtonClass).toContain("text-kumo-subtle"); + expect(editButtonClass).toContain("opacity-50"); + await editButton.focus(); + await page + .getByText("An incomplete capture cannot be edited and resent safely.") + .waitFor(); + await editButton.press("Enter"); + expect(draftRequests).toBe(0); + const legacyRow = page.getByRole("button", { + name: /Older peer capture/, + }); + await page.unroute(WORKERS_ROUTE); + await page.route(WORKERS_ROUTE, async (route) => { + await fulfillApiResult(route, [{ isSelf: true, name: "worker-1" }]); + }); + await legacyRow.click(); + await expect + .poll(() => legacyDetailQuery?.get("email_id")) + .toBe(""); + expect(legacyDetailQuery?.get("capture_id")).toBeNull(); + expect(legacyDetailQuery?.get("worker")).toBe("worker-2"); + await page.getByRole("button", { name: "Content" }).click(); + await page.getByText("Legacy body", { exact: true }).waitFor(); }); test("reports composer validation errors accessibly and rejects managed headers", async ({ @@ -365,7 +669,7 @@ describe("email routing", () => { viteUrl ).toString() ); - await page.getByRole("button", { name: "Send Test Email" }).click(); + 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"); @@ -461,12 +765,16 @@ describe("email routing", () => { [ { attachments: [], + captureId: `00000000-0000-4000-8000-00000000000${pageNumber}`, + capturedPortion: false, + editAndResendAvailable: true, from: `sender-${pageNumber}@example.com`, messageId: ``, rawSize: 4, receivedAt: "2026-08-24T00:00:00.000Z", subject: `Page ${pageNumber}`, to: "recipient@example.com", + worker: "worker-1", }, ], { @@ -497,6 +805,91 @@ describe("email routing", () => { expect(await page.getByRole("alert").count()).toBe(0); }); + test("retires stale pagination while preserving the refresh loading interval", async ({ + expect, + }) => { + let releaseNextPage: (() => void) | undefined; + const nextPageReleased = new Promise((resolve) => { + releaseNextPage = resolve; + }); + let nextPageSettled = false; + let nextPageStarted = false; + await page.route(EMAIL_ROUTING_DETAIL_ROUTE, async (route) => { + const cursor = new URL(route.request().url()).searchParams.get("cursor"); + if (cursor) { + nextPageStarted = true; + await nextPageReleased; + } + await fulfillApiResult( + route, + [ + { + attachments: [], + captureId: cursor + ? "00000000-0000-4000-8000-000000000002" + : "00000000-0000-4000-8000-000000000001", + capturedPortion: false, + editAndResendAvailable: true, + from: "sender@example.com", + messageId: cursor ? "" : "", + rawSize: 4, + receivedAt: "2026-08-24T00:00:00.000Z", + subject: cursor ? "Page 2" : "Page 1", + to: "recipient@example.com", + worker: "worker-1", + }, + ], + { + resultInfo: { + count: 1, + cursor: cursor ? undefined : "next-page", + has_more: !cursor, + per_page: 25, + }, + } + ); + if (cursor) { + nextPageSettled = true; + } + }); + 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(); + try { + await page.getByRole("button", { name: "Next page" }).click(); + await expect.poll(() => nextPageStarted).toBe(true); + const refreshResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname.endsWith("/api/local/email/routing") && + !url.searchParams.has("cursor") + ); + }); + const refreshButton = page.getByRole("button", { name: "Refresh" }); + const refreshStarted = performance.now(); + await refreshButton.click(); + await refreshResponse; + await expect.poll(() => refreshButton.isDisabled()).toBe(true); + await expect.poll(() => refreshButton.isEnabled()).toBe(true); + expect(performance.now() - refreshStarted).toBeGreaterThanOrEqual(250); + await expect + .poll(() => page.getByRole("button", { name: "Next page" }).isEnabled()) + .toBe(true); + } finally { + releaseNextPage?.(); + } + + await expect.poll(() => nextPageSettled).toBe(true); + await page.getByText("Page 1", { exact: true }).waitFor(); + expect(await page.getByText("Page 2", { exact: true }).count()).toBe(0); + expect(await page.getByRole("alert").count()).toBe(0); + }); + test("explains the email handler requirement and toggles received raw content", async ({ expect, }) => { @@ -516,7 +909,7 @@ describe("email routing", () => { await page.goto( new URL( - "/cdn-cgi/local/explorer/email/routing/test-email-id?worker=worker-1", + "/cdn-cgi/local/explorer/email/routing/00000000-0000-4000-8000-000000000001?worker=worker-1", viteUrl ).toString() ); diff --git a/packages/local-explorer-ui/src/__e2e__/email/utils.ts b/packages/local-explorer-ui/src/__e2e__/email/utils.ts index b78435390ae..1c33cfda0c8 100644 --- a/packages/local-explorer-ui/src/__e2e__/email/utils.ts +++ b/packages/local-explorer-ui/src/__e2e__/email/utils.ts @@ -6,6 +6,10 @@ 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_ROUTING_RESEND_ROUTE = + "**/cdn-cgi/local/explorer/api/local/email/routing/resend?*"; +export const EMAIL_ROUTING_RESEND_DRAFT_ROUTE = + "**/cdn-cgi/local/explorer/api/local/email/routing/resend/draft?*"; export const EMAIL_SENDING_ROUTE = "**/cdn-cgi/local/explorer/api/local/email/sending?*"; export const EMAIL_PREVIEW_REMOTE_ROUTE = "https://email-preview.invalid/**"; @@ -23,6 +27,7 @@ interface MockRoutingEmailOptions { handlerException?: boolean; showInList?: boolean; replyTruncated?: boolean; + worker?: string; } interface Worker { @@ -67,10 +72,16 @@ export async function loadWorker( export async function mockEmailRoutingDetail( truncated = true, options: MockRoutingEmailOptions = {} -): Promise { +): Promise<{ + detailRequestWorkers: Array; + listRequestCount: () => number; +}> { + let listRequests = 0; + const detailRequestWorkers: Array = []; await page.route(EMAIL_ROUTING_DETAIL_ROUTE, async (route) => { - const emailId = new URL(route.request().url()).searchParams.get("email_id"); - const messages = emailId + const search = new URL(route.request().url()).searchParams; + const captureId = search.get("capture_id"); + const messages = captureId ? [ ...(truncated ? [ @@ -92,8 +103,16 @@ export async function mockEmailRoutingDetail( : []), ] : []; + if (!captureId) { + listRequests++; + } else { + detailRequestWorkers.push(search.get("worker")); + } const summary = { attachments: [], + captureId: "00000000-0000-4000-8000-000000000001", + capturedPortion: false, + editAndResendAvailable: true, events: options.handlerException ? [ { @@ -111,8 +130,9 @@ export async function mockEmailRoutingDetail( replies: [], subject: "Test email", to: "recipient@example.com", + worker: options.worker ?? "worker-1", }; - const result = emailId + const result = captureId ? { ...summary, headers: { @@ -138,7 +158,7 @@ export async function mockEmailRoutingDetail( : []; await fulfillApiResult(route, result, { messages, - resultInfo: emailId + resultInfo: captureId ? undefined : { count: Array.isArray(result) ? result.length : 0, @@ -147,6 +167,17 @@ export async function mockEmailRoutingDetail( }, }); }); + await page.route(EMAIL_ROUTING_RESEND_DRAFT_ROUTE, async (route) => { + await fulfillApiResult(route, { + attachments: [], + from: "sender@example.com", + headers: { "X-Test-Header": "first line\nsecond line" }, + subject: "Test email", + text: "Plain received text body", + to: ["recipient@example.com"], + }); + }); + return { detailRequestWorkers, listRequestCount: () => listRequests }; } /** Mocks an empty sent-email list for navigation tests. */ @@ -233,6 +264,8 @@ export async function cleanupEmailMocks(): Promise { page.unroute(WORKERS_ROUTE), page.unroute(EMAIL_ROUTING_DETAIL_ROUTE), page.unroute(EMAIL_ROUTING_SEND_ROUTE), + page.unroute(EMAIL_ROUTING_RESEND_ROUTE), + page.unroute(EMAIL_ROUTING_RESEND_DRAFT_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 9054e2417c9..4fea924d6b5 100644 --- a/packages/local-explorer-ui/src/__e2e__/worker-selector.spec.ts +++ b/packages/local-explorer-ui/src/__e2e__/worker-selector.spec.ts @@ -36,12 +36,14 @@ 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"); + const captureId = new URL(route.request().url()).searchParams.get( + "capture_id" + ); await route.fulfill({ contentType: "application/json", body: JSON.stringify({ errors: [], - messages: emailId + messages: captureId ? [ { code: 10604, @@ -50,9 +52,12 @@ async function mockEmailRoutingDetail(): Promise { }, ] : [], - result: emailId + result: captureId ? { attachments: [], + captureId, + capturedPortion: false, + editAndResendAvailable: true, events: [], forwards: [], from: "sender@example.com", @@ -66,9 +71,10 @@ async function mockEmailRoutingDetail(): Promise { subject: "Test email", text: "Plain received text body", to: "recipient@example.com", + worker: "worker-1", } : [], - result_info: emailId + result_info: captureId ? undefined : { count: 0, has_more: false, per_page: 25 }, success: true, @@ -89,16 +95,19 @@ describe("worker selector", () => { 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"); + const captureId = search.get("capture_id"); requestedWorkers.push(search.get("worker")); await route.fulfill({ contentType: "application/json", body: JSON.stringify({ errors: [], messages: [], - result: emailId + result: captureId ? { attachments: [], + captureId, + capturedPortion: false, + editAndResendAvailable: true, events: [], forwards: [], from: "sender@example.com", @@ -111,9 +120,10 @@ describe("worker selector", () => { subject: "Direct email", text: "Body", to: "recipient@example.com", + worker: search.get("worker") ?? "worker-1", } : [], - result_info: emailId + result_info: captureId ? undefined : { count: 0, has_more: false, per_page: 10 }, success: true, @@ -151,7 +161,7 @@ describe("worker selector", () => { requestedWorkers.length = 0; await page.goto( new URL( - "/cdn-cgi/local/explorer/email/routing/test-email-id", + "/cdn-cgi/local/explorer/email/routing/00000000-0000-4000-8000-000000000001", viteUrl ).toString() ); @@ -169,6 +179,68 @@ describe("worker selector", () => { ); }); + test("keeps workerless Message-ID detail lookups unfiltered", async ({ + expect, + }) => { + const requestedWorkers: Array = []; + const requestedEmailIds: Array = []; + await page.route(EMAIL_ROUTING_DETAIL_ROUTE, async (route) => { + const search = new URL(route.request().url()).searchParams; + const emailId = search.get("email_id"); + if (emailId !== null) { + requestedWorkers.push(search.get("worker")); + requestedEmailIds.push(emailId); + } + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + errors: [], + messages: [], + result: + emailId === null + ? [] + : { + attachments: [], + events: [], + forwards: [], + from: "legacy@example.com", + headers: {}, + messageId: emailId, + outcome: "ok", + raw: "Content-Type: text/plain\r\n\r\nLegacy body", + rawSize: 11, + receivedAt: "2026-09-11T00:00:00.000Z", + replies: [], + subject: "Legacy detail", + text: "Legacy body", + to: "recipient@example.com", + worker: "worker-2", + }, + result_info: + emailId === null + ? { count: 0, has_more: false, per_page: 10 } + : undefined, + success: true, + }), + }); + }); + await loadWorkers(2); + + const messageId = ""; + await page.goto( + new URL( + `/cdn-cgi/local/explorer/email/routing/${encodeURIComponent(messageId)}?lookup=message-id`, + viteUrl + ).toString() + ); + + await page.getByText("Legacy detail").last().waitFor(); + expect(new URL(page.url()).searchParams.get("worker")).toBeNull(); + await expect.poll(() => requestedEmailIds.length).toBeGreaterThan(0); + expect(requestedEmailIds.every((id) => id === messageId)).toBe(true); + expect(requestedWorkers.every((worker) => worker === null)).toBe(true); + }); + test("discards stale email lists after switching workers", async ({ expect, }) => { @@ -192,6 +264,12 @@ describe("worker selector", () => { result: [ { attachments: [], + captureId: + worker === "worker-2" + ? "00000000-0000-4000-8000-000000000002" + : "00000000-0000-4000-8000-000000000001", + capturedPortion: false, + editAndResendAvailable: true, events: [], forwards: [], from: "sender@example.com", @@ -202,6 +280,7 @@ describe("worker selector", () => { replies: [], subject, to: "recipient@example.com", + worker: worker ?? "worker-1", }, ], result_info: { count: 1, has_more: false, per_page: 10 }, @@ -329,7 +408,7 @@ describe("worker selector", () => { await loadWorkers(2); await page.goto( new URL( - "/cdn-cgi/local/explorer/email/routing/test-email-id?worker=worker-1", + "/cdn-cgi/local/explorer/email/routing/00000000-0000-4000-8000-000000000001?worker=worker-1", viteUrl ).toString() ); @@ -367,7 +446,7 @@ describe("worker selector", () => { await page.goBack(); await expect .poll(() => new URL(page.url()).pathname) - .toMatch(/\/email\/routing\/test-email-id$/); + .toMatch(/\/email\/routing\/00000000-0000-4000-8000-000000000001$/); await expect .poll(() => new URL(page.url()).searchParams.get("worker")) .toBe("worker-1"); diff --git a/packages/local-explorer-ui/src/__tests__/utils/email-resend.test.ts b/packages/local-explorer-ui/src/__tests__/utils/email-resend.test.ts new file mode 100644 index 00000000000..78684496664 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/utils/email-resend.test.ts @@ -0,0 +1,259 @@ +import { describe, test } from "vitest"; +import { + createInboxRefreshCoordinator, + getEmailResendErrorFeedback, + getEmailResendFeedback, + getEmailResendNetworkFeedback, + toTestEmailDraft, +} from "../../utils/email-resend"; + +interface Deferred { + promise: Promise; + reject: (cause: unknown) => void; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolvePromise: ((value: T) => void) | undefined; + let rejectPromise: ((cause: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + return { + promise, + reject: (cause) => rejectPromise?.(cause), + resolve: (value) => resolvePromise?.(value), + }; +} + +function at(values: T[], index: number): T { + const value = values[index]; + if (value === undefined) { + throw new Error(`Missing deferred value at index ${index}.`); + } + return value; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("email resend UI helpers", () => { + test("converts projected composer fields and attachment sizes", ({ + expect, + }) => { + const draft = toTestEmailDraft({ + attachments: [ + { + content: "AAECAwQ=", + contentId: "image-1", + disposition: "inline", + filename: "image.bin", + type: "application/octet-stream", + }, + ], + cc: ["copy@example.com"], + from: "sender@example.com", + headers: { "X-Test": "value" }, + html: "

Hello

", + replyTo: "reply@example.com", + subject: "Projected email", + text: "Hello", + to: ["first@example.com", "second@example.com"], + }); + + expect(draft).toMatchObject({ + attachments: [ + { + content: "AAECAwQ=", + contentId: "image-1", + disposition: "inline", + filename: "image.bin", + size: 5, + type: "application/octet-stream", + }, + ], + bcc: "", + cc: "copy@example.com", + from: "sender@example.com", + headers: [{ name: "X-Test", value: "value" }], + html: "

Hello

", + replyTo: "reply@example.com", + subject: "Projected email", + text: "Hello", + to: "first@example.com, second@example.com", + }); + expect(draft.attachments[0]?.id).toBeTruthy(); + }); + + test("distinguishes success, rejection, and exception", ({ expect }) => { + expect( + getEmailResendFeedback({ + capturedPortion: false, + messageId: "", + outcome: "ok", + }) + ).toEqual({ + description: undefined, + title: "Email resent.", + variant: "success", + }); + expect( + getEmailResendFeedback({ + capturedPortion: true, + messageId: "", + outcome: "ok", + rejectReason: "", + }) + ).toEqual({ + description: + "No rejection reason was provided. Only the captured portion of the original email was available.", + title: "Email was rejected.", + variant: "error", + }); + expect( + getEmailResendFeedback({ + capturedPortion: true, + messageId: "", + outcome: "exception", + }) + ).toEqual({ + description: + "Only the captured portion of the original email was available.", + title: "The email handler threw an exception.", + variant: "error", + }); + }); + + test("does not claim ambiguous peer or network delivery failed", ({ + expect, + }) => { + expect( + getEmailResendErrorFeedback( + { errors: [{ code: 10603, message: "Peer fetch failed" }] }, + 502, + true + ) + ).toEqual({ + description: + "Only the captured portion of the original email was available.", + title: + "The resend result is unknown because the Worker peer became unavailable.", + variant: "error", + }); + expect(getEmailResendNetworkFeedback(false).title).toContain("unknown"); + }); + + test("coalesces refresh bursts and runs one follow-up during a refresh", async ({ + expect, + }) => { + let active = 0; + let maximumActive = 0; + const refreshes = [ + deferred<"success" | "stale">(), + deferred<"success" | "stale">(), + ]; + let refreshIndex = 0; + const coordinator = createInboxRefreshCoordinator({ + currentGeneration: () => 1, + isDisposed: () => false, + refreshFirstPage: async () => { + active++; + maximumActive = Math.max(maximumActive, active); + try { + return await at(refreshes, refreshIndex++).promise; + } finally { + active--; + } + }, + }); + + coordinator.request(1); + coordinator.request(1); + await flushPromises(); + expect(refreshIndex).toBe(1); + coordinator.request(1); + at(refreshes, 0).resolve("success"); + await flushPromises(); + expect(refreshIndex).toBe(2); + expect(maximumActive).toBe(1); + at(refreshes, 1).resolve("success"); + await flushPromises(); + expect(refreshIndex).toBe(2); + }); + + test("follows a failed refresh only for newer dirty work", async ({ + expect, + }) => { + const first = deferred<"success" | "stale">(); + const second = deferred<"success" | "stale">(); + const refreshes = [first, second]; + let refreshIndex = 0; + const coordinator = createInboxRefreshCoordinator({ + currentGeneration: () => 1, + isDisposed: () => false, + refreshFirstPage: () => at(refreshes, refreshIndex++).promise, + }); + + coordinator.request(1); + await flushPromises(); + coordinator.request(1); + first.reject(new Error("refresh failed")); + await flushPromises(); + expect(refreshIndex).toBe(2); + second.resolve("success"); + await flushPromises(); + + const loneFailure = deferred<"success" | "stale">(); + let loneRefreshes = 0; + const loneCoordinator = createInboxRefreshCoordinator({ + currentGeneration: () => 1, + isDisposed: () => false, + refreshFirstPage: () => { + loneRefreshes++; + return loneFailure.promise; + }, + }); + loneCoordinator.request(1); + await flushPromises(); + loneFailure.reject(new Error("refresh failed")); + await flushPromises(); + expect(loneRefreshes).toBe(1); + }); + + test("discards old generations and resumes current dirty work", async ({ + expect, + }) => { + let generation = 1; + let disposed = false; + const first = deferred<"success" | "stale">(); + const second = deferred<"success" | "stale">(); + const refreshes = [first, second]; + let refreshIndex = 0; + const coordinator = createInboxRefreshCoordinator({ + currentGeneration: () => generation, + isDisposed: () => disposed, + refreshFirstPage: () => at(refreshes, refreshIndex++).promise, + }); + + coordinator.request(1); + await flushPromises(); + generation = 2; + coordinator.clear(); + coordinator.request(2); + first.resolve("stale"); + await flushPromises(); + expect(refreshIndex).toBe(2); + second.resolve("success"); + await flushPromises(); + + disposed = true; + coordinator.clear(); + coordinator.request(2); + await flushPromises(); + expect(refreshIndex).toBe(2); + }); +}); diff --git a/packages/local-explorer-ui/src/components/email/EmailList.tsx b/packages/local-explorer-ui/src/components/email/EmailList.tsx index 2cdfd5d54a9..123e8fa8afd 100644 --- a/packages/local-explorer-ui/src/components/email/EmailList.tsx +++ b/packages/local-explorer-ui/src/components/email/EmailList.tsx @@ -5,6 +5,8 @@ import type { CSSProperties, JSX, ReactNode } from "react"; export interface EmailListRow { id: string; + navigationId?: string; + navigable?: boolean; primary: string; secondary: string; secondaryTitle: string; @@ -18,14 +20,15 @@ interface EmailListProps { disabled: boolean; emptyState: ReactNode; error: string | null; - getRow: (item: T) => EmailListRow; + getRow: (item: T, index: number) => EmailListRow; hasNext: boolean; hasPrevious: boolean; items: T[]; onNext: () => void; onPrevious: () => void; onRefresh: () => void; - onRowClick: (id: string) => void; + onRowClick: (id: string, item: T) => void; + renderRowActions?: (item: T, row: EmailListRow) => ReactNode; refreshing: boolean; selectedId?: string | null; style?: CSSProperties; @@ -47,6 +50,7 @@ export function EmailList({ onPrevious, onRefresh, onRowClick, + renderRowActions, refreshing, selectedId, style, @@ -99,19 +103,11 @@ export function EmailList({ ) : (
- {items.map((item) => { - const row = getRow(item); + {items.map((item, index) => { + const row = getRow(item, index); const selected = selectedId === row.id; - return ( - + + ); + const rowClassName = `grid h-12 min-h-12 min-w-0 flex-1 grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-3 px-4 text-left text-sm ${ + selected ? "bg-kumo-fill" : "bg-kumo-base" + }`; + return ( +
+ {row.navigable === false ? ( +
{rowContent}
+ ) : ( + + )} + {renderRowActions ? renderRowActions(item, row) : null} +
); })}
diff --git a/packages/local-explorer-ui/src/components/email/SendTestEmailDialog.tsx b/packages/local-explorer-ui/src/components/email/SendTestEmailDialog.tsx index 9f32eda3531..c0a1589a854 100644 --- a/packages/local-explorer-ui/src/components/email/SendTestEmailDialog.tsx +++ b/packages/local-explorer-ui/src/components/email/SendTestEmailDialog.tsx @@ -9,6 +9,7 @@ import { PaperclipIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react"; import { useCallback, useEffect, + useLayoutEffect, useRef, useState, type ChangeEvent, @@ -24,14 +25,15 @@ import { } from "../../utils/email-headers"; import { formatSize } from "../../utils/format"; import type { EmailSendRequest, EmailSendRoutingError } from "../../api"; -import type { TestEmailDraft } from "./TestEmailDraftsContext"; +import type { TestEmailDraft } from "../../utils/email-resend"; interface SendTestEmailDialogProps { initialDraft?: TestEmailDraft; + onDispatchedSendSettled: (expectedWorkerGeneration: number) => void; onOpenChange: (open: boolean) => void; - onSent: (draft: TestEmailDraft) => void; open: boolean; worker?: string; + workerGeneration: number; } type SelectedAttachment = TestEmailDraft["attachments"][number]; @@ -111,6 +113,7 @@ function parseAddressList(value: string): string[] { let angleDepth = 0; let commentDepth = 0; let escaped = false; + let groupDepth = 0; let quoted = false; function commitAddress(): void { @@ -146,12 +149,22 @@ function parseAddressList(value: string): string[] { angleDepth++; } else if (commentDepth === 0 && character === ">" && angleDepth > 0) { angleDepth--; + } else if (commentDepth === 0 && angleDepth === 0 && character === ":") { + groupDepth++; + } else if ( + commentDepth === 0 && + angleDepth === 0 && + character === ";" && + groupDepth > 0 + ) { + groupDepth--; } } if ( !quoted && angleDepth === 0 && commentDepth === 0 && + groupDepth === 0 && (character === "," || character === "\n" || character === "\r") ) { commitAddress(); @@ -165,10 +178,11 @@ function parseAddressList(value: string): string[] { export function SendTestEmailDialog({ initialDraft, + onDispatchedSendSettled, onOpenChange, - onSent, open, worker, + workerGeneration, }: SendTestEmailDialogProps): JSX.Element { const toast = useKumoToastManager(); const [sending, setSending] = useState(false); @@ -191,6 +205,11 @@ export function SendTestEmailDialog({ const attachmentReadGenerationRef = useRef(0); const fileInputRef = useRef(null); const nextHeaderIdRef = useRef(0); + const sendControllerRef = useRef(undefined); + const sendGuardRef = useRef(false); + const disposedRef = useRef(false); + const workerGenerationRef = useRef(workerGeneration); + workerGenerationRef.current = workerGeneration; const loadDraft = useCallback((draft?: TestEmailDraft) => { setFrom(draft?.from ?? ""); @@ -225,6 +244,17 @@ export function SendTestEmailDialog({ } }, [initialDraft, loadDraft, open]); + useLayoutEffect(() => { + disposedRef.current = false; + return () => { + disposedRef.current = true; + attachmentReadGenerationRef.current += 1; + sendControllerRef.current?.abort(); + sendControllerRef.current = undefined; + sendGuardRef.current = false; + }; + }, []); + async function handleAttachmentsSelected( e: ChangeEvent ): Promise { @@ -245,6 +275,7 @@ export function SendTestEmailDialog({ filename: file.name, type: file.type || "application/octet-stream", content: await readFileAsBase64(file), + id: crypto.randomUUID(), size: file.size, })) ); @@ -320,6 +351,9 @@ export function SendTestEmailDialog({ } async function handleSend(): Promise { + if (sendGuardRef.current) { + return; + } const recipients = parseAddressList(to); const customHeaders = new Map(); const usedHeaderNames = new Set(); @@ -407,31 +441,35 @@ export function SendTestEmailDialog({ } if (attachments.length > 0) { body.attachments = attachments.map( - ({ size: _size, ...attachment }) => attachment + ({ id: _id, size: _size, ...attachment }) => attachment ); } + sendGuardRef.current = true; setSending(true); - const sentDraft: TestEmailDraft = { - from, - to, - cc, - bcc, - replyTo, - subject, - headers: validatedHeaders - .filter((header) => header.name.trim() || header.value) - .map(({ name, value }) => ({ name, value })), - text, - html, - attachments: attachments.map((attachment) => ({ ...attachment })), - }; + const expectedWorkerGeneration = workerGeneration; + const controller = new AbortController(); + sendControllerRef.current = controller; + let didDispatch = false; + function isCurrentRequest(): boolean { + return ( + !disposedRef.current && + !controller.signal.aborted && + sendControllerRef.current === controller && + expectedWorkerGeneration === workerGenerationRef.current + ); + } try { + didDispatch = true; const { error: sendError, response } = await emailSendRouting({ body, query: { worker }, + signal: controller.signal, throwOnError: false, }); + if (!isCurrentRequest()) { + return; + } if (sendError || !response.ok) { toast.add({ title: @@ -443,16 +481,28 @@ export function SendTestEmailDialog({ } } loadDraft(); - onSent(sentDraft); onOpenChange(false); } catch (err) { + if (!isCurrentRequest()) { + return; + } toast.add({ title: err instanceof Error ? err.message : "Failed to send test email.", variant: "error", }); } finally { - setSending(false); + const currentRequest = isCurrentRequest(); + if (sendControllerRef.current === controller) { + sendControllerRef.current = undefined; + sendGuardRef.current = false; + if (currentRequest) { + setSending(false); + } + } + if (didDispatch && currentRequest) { + onDispatchedSendSettled(expectedWorkerGeneration); + } } } @@ -722,7 +772,7 @@ export function SendTestEmailDialog({
{attachments.map((attachment, index) => (
diff --git a/packages/local-explorer-ui/src/components/email/TestEmailDraftsContext.tsx b/packages/local-explorer-ui/src/components/email/TestEmailDraftsContext.tsx deleted file mode 100644 index 92b9272a2c5..00000000000 --- a/packages/local-explorer-ui/src/components/email/TestEmailDraftsContext.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { createContext, useContext, useMemo, useState } from "react"; -import type { EmailSendRequest } from "../../api"; -import type { Dispatch, JSX, PropsWithChildren, SetStateAction } from "react"; - -type AttachmentInput = NonNullable[number]; - -export interface SelectedTestEmailAttachment extends AttachmentInput { - size: number; -} - -export interface TestEmailHeader { - name: string; - value: string; -} - -export interface TestEmailDraft { - from: string; - to: string; - cc: string; - bcc: string; - replyTo: string; - subject: string; - headers: TestEmailHeader[]; - text: string; - html: string; - attachments: SelectedTestEmailAttachment[]; -} - -type TestEmailDrafts = Record; - -interface TestEmailDraftsContextValue { - drafts: TestEmailDrafts; - setDrafts: Dispatch>; -} - -const TestEmailDraftsContext = - createContext(null); - -/** Retains successful test-email drafts while navigating between email routes. */ -export function TestEmailDraftsProvider({ - children, -}: PropsWithChildren): JSX.Element { - const [drafts, setDrafts] = useState({}); - const value = useMemo(() => ({ drafts, setDrafts }), [drafts]); - - return ( - - {children} - - ); -} - -/** Returns the successful test-email drafts retained by the email layout. */ -export function useTestEmailDrafts(): TestEmailDraftsContextValue { - const context = useContext(TestEmailDraftsContext); - if (!context) { - throw new Error( - "useTestEmailDrafts must be used within a TestEmailDraftsProvider" - ); - } - - return context; -} diff --git a/packages/local-explorer-ui/src/routeTree.gen.ts b/packages/local-explorer-ui/src/routeTree.gen.ts index 688eb74a692..37c1a0258d7 100644 --- a/packages/local-explorer-ui/src/routeTree.gen.ts +++ b/packages/local-explorer-ui/src/routeTree.gen.ts @@ -25,7 +25,7 @@ import { Route as R2BucketNameIndexRouteImport } from './routes/r2/$bucketName/i import { Route as EmailRoutingIndexRouteImport } from './routes/email/routing/index' import { Route as DoClassNameIndexRouteImport } from './routes/do/$className/index' import { Route as WorkflowsWorkflowNameInstanceIdRouteImport } from './routes/workflows/$workflowName/$instanceId' -import { Route as EmailRoutingEmailIdRouteImport } from './routes/email/routing/$emailId' +import { Route as EmailRoutingCaptureIdRouteImport } from './routes/email/routing/$captureId' import { Route as DoClassNameObjectIdRouteImport } from './routes/do/$className/$objectId' import { Route as R2BucketNameObjectSplatRouteImport } from './routes/r2/$bucketName/object.$' @@ -111,9 +111,9 @@ const WorkflowsWorkflowNameInstanceIdRoute = path: '/$instanceId', getParentRoute: () => WorkflowsWorkflowNameRoute, } as any) -const EmailRoutingEmailIdRoute = EmailRoutingEmailIdRouteImport.update({ - id: '/$emailId', - path: '/$emailId', +const EmailRoutingCaptureIdRoute = EmailRoutingCaptureIdRouteImport.update({ + id: '/$captureId', + path: '/$captureId', getParentRoute: () => EmailRoutingRoute, } as any) const DoClassNameObjectIdRoute = DoClassNameObjectIdRouteImport.update({ @@ -140,7 +140,7 @@ export interface FileRoutesByFullPath { '/workflows/$workflowName': typeof WorkflowsWorkflowNameRouteWithChildren '/observability/': typeof ObservabilityIndexRoute '/do/$className/$objectId': typeof DoClassNameObjectIdRoute - '/email/routing/$emailId': typeof EmailRoutingEmailIdRoute + '/email/routing/$captureId': typeof EmailRoutingCaptureIdRoute '/workflows/$workflowName/$instanceId': typeof WorkflowsWorkflowNameInstanceIdRoute '/do/$className/': typeof DoClassNameIndexRoute '/email/routing/': typeof EmailRoutingIndexRoute @@ -157,7 +157,7 @@ export interface FileRoutesByTo { '/observability/events': typeof ObservabilityEventsRoute '/observability': typeof ObservabilityIndexRoute '/do/$className/$objectId': typeof DoClassNameObjectIdRoute - '/email/routing/$emailId': typeof EmailRoutingEmailIdRoute + '/email/routing/$captureId': typeof EmailRoutingCaptureIdRoute '/workflows/$workflowName/$instanceId': typeof WorkflowsWorkflowNameInstanceIdRoute '/do/$className': typeof DoClassNameIndexRoute '/email/routing': typeof EmailRoutingIndexRoute @@ -179,7 +179,7 @@ export interface FileRoutesById { '/workflows/$workflowName': typeof WorkflowsWorkflowNameRouteWithChildren '/observability/': typeof ObservabilityIndexRoute '/do/$className/$objectId': typeof DoClassNameObjectIdRoute - '/email/routing/$emailId': typeof EmailRoutingEmailIdRoute + '/email/routing/$captureId': typeof EmailRoutingCaptureIdRoute '/workflows/$workflowName/$instanceId': typeof WorkflowsWorkflowNameInstanceIdRoute '/do/$className/': typeof DoClassNameIndexRoute '/email/routing/': typeof EmailRoutingIndexRoute @@ -202,7 +202,7 @@ export interface FileRouteTypes { | '/workflows/$workflowName' | '/observability/' | '/do/$className/$objectId' - | '/email/routing/$emailId' + | '/email/routing/$captureId' | '/workflows/$workflowName/$instanceId' | '/do/$className/' | '/email/routing/' @@ -219,7 +219,7 @@ export interface FileRouteTypes { | '/observability/events' | '/observability' | '/do/$className/$objectId' - | '/email/routing/$emailId' + | '/email/routing/$captureId' | '/workflows/$workflowName/$instanceId' | '/do/$className' | '/email/routing' @@ -240,7 +240,7 @@ export interface FileRouteTypes { | '/workflows/$workflowName' | '/observability/' | '/do/$className/$objectId' - | '/email/routing/$emailId' + | '/email/routing/$captureId' | '/workflows/$workflowName/$instanceId' | '/do/$className/' | '/email/routing/' @@ -375,11 +375,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WorkflowsWorkflowNameInstanceIdRouteImport parentRoute: typeof WorkflowsWorkflowNameRoute } - '/email/routing/$emailId': { - id: '/email/routing/$emailId' - path: '/$emailId' - fullPath: '/email/routing/$emailId' - preLoaderRoute: typeof EmailRoutingEmailIdRouteImport + '/email/routing/$captureId': { + id: '/email/routing/$captureId' + path: '/$captureId' + fullPath: '/email/routing/$captureId' + preLoaderRoute: typeof EmailRoutingCaptureIdRouteImport parentRoute: typeof EmailRoutingRoute } '/do/$className/$objectId': { @@ -400,12 +400,12 @@ declare module '@tanstack/react-router' { } interface EmailRoutingRouteChildren { - EmailRoutingEmailIdRoute: typeof EmailRoutingEmailIdRoute + EmailRoutingCaptureIdRoute: typeof EmailRoutingCaptureIdRoute EmailRoutingIndexRoute: typeof EmailRoutingIndexRoute } const EmailRoutingRouteChildren: EmailRoutingRouteChildren = { - EmailRoutingEmailIdRoute: EmailRoutingEmailIdRoute, + EmailRoutingCaptureIdRoute: EmailRoutingCaptureIdRoute, EmailRoutingIndexRoute: EmailRoutingIndexRoute, } diff --git a/packages/local-explorer-ui/src/routes/__root.tsx b/packages/local-explorer-ui/src/routes/__root.tsx index 9c4900717cd..aefb293e337 100644 --- a/packages/local-explorer-ui/src/routes/__root.tsx +++ b/packages/local-explorer-ui/src/routes/__root.tsx @@ -44,7 +44,7 @@ function RootLayout() { const matchRoute = useMatchRoute(); const routingDetailParams = matchRoute({ includeSearch: false, - to: "/email/routing/$emailId", + to: "/email/routing/$captureId", }); const [sidebarOpen, setSidebarOpen] = useState(loadSidebarOpenState); diff --git a/packages/local-explorer-ui/src/routes/email.tsx b/packages/local-explorer-ui/src/routes/email.tsx index 252e2c114f5..3cf2aaa0a91 100644 --- a/packages/local-explorer-ui/src/routes/email.tsx +++ b/packages/local-explorer-ui/src/routes/email.tsx @@ -6,12 +6,14 @@ import { useNavigate, } from "@tanstack/react-router"; import { useEffect, type JSX } from "react"; -import { TestEmailDraftsProvider } from "../components/email/TestEmailDraftsContext"; import { getSelectedWorker } from "../components/WorkerSelector"; export const Route = createFileRoute("/email")({ component: EmailLayout, - validateSearch: (search: Record): { worker?: string } => ({ + validateSearch: ( + search: Record + ): { lookup?: "message-id"; worker?: string } => ({ + lookup: search.lookup === "message-id" ? "message-id" : undefined, worker: typeof search.worker === "string" ? search.worker : undefined, }), }); @@ -29,7 +31,7 @@ function EmailLayout(): JSX.Element { }); const routingDetailParams = matchRoute({ includeSearch: false, - to: "/email/routing/$emailId", + to: "/email/routing/$captureId", }); const sendingRouteMatch = matchRoute({ includeSearch: false, @@ -49,19 +51,28 @@ function EmailLayout(): JSX.Element { )?.name ?? ""; useEffect(() => { - if (selectedWorker === "" || search.worker === selectedWorker) { - return; - } - + // Detail URLs identify a specific Worker-owned resource. If that Worker is + // no longer visible, preserve the requested identity so the detail API can + // report it as missing or unavailable instead of targeting the default Worker. if (routingDetailParams) { + if ( + search.lookup === "message-id" || + search.worker !== undefined || + selectedWorker === "" + ) { + return; + } void navigate({ params: routingDetailParams, replace: true, search: (previous) => ({ ...previous, worker: selectedWorker }), - to: "/email/routing/$emailId", + to: "/email/routing/$captureId", }); return; } + if (selectedWorker === "" || search.worker === selectedWorker) { + return; + } if (listRoute) { void navigate({ @@ -70,11 +81,14 @@ function EmailLayout(): JSX.Element { to: listRoute, }); } - }, [listRoute, navigate, routingDetailParams, search.worker, selectedWorker]); + }, [ + listRoute, + navigate, + routingDetailParams, + search.lookup, + search.worker, + selectedWorker, + ]); - return ( - - - - ); + return ; } diff --git a/packages/local-explorer-ui/src/routes/email/routing/$emailId.tsx b/packages/local-explorer-ui/src/routes/email/routing/$captureId.tsx similarity index 78% rename from packages/local-explorer-ui/src/routes/email/routing/$emailId.tsx rename to packages/local-explorer-ui/src/routes/email/routing/$captureId.tsx index b8fdfa75bfd..a0fd3e8ac5f 100644 --- a/packages/local-explorer-ui/src/routes/email/routing/$emailId.tsx +++ b/packages/local-explorer-ui/src/routes/email/routing/$captureId.tsx @@ -18,27 +18,36 @@ import { getSelectedWorker } from "../../../components/WorkerSelector"; import { ConstantsCard } from "../shared/ConstantsCard"; import { InfoFlow } from "../shared/InfoFlow"; import { InfoLoading } from "../shared/InfoLoading"; -import { toEmailId } from "../shared/types"; import type { EmailRoutingDetail } from "../../../api"; import type { InfoEvent, InfoMessage } from "../shared/types"; import type { JSX } from "react"; -export const Route = createFileRoute("/email/routing/$emailId")({ +export const Route = createFileRoute("/email/routing/$captureId")({ component: EmailRoutingDetailView, errorComponent: ResourceError, notFoundComponent: NotFound, pendingComponent: InfoLoading, - loaderDeps: ({ search }) => ({ worker: search.worker }), + validateSearch: ( + search: Record + ): { lookup?: "message-id"; worker?: string } => ({ + lookup: search.lookup === "message-id" ? "message-id" : undefined, + worker: typeof search.worker === "string" ? search.worker : undefined, + }), + loaderDeps: ({ search }) => ({ + lookup: search.lookup, + worker: search.worker, + }), loader: async ({ params, deps }) => { - const workersResponse = await localExplorerListWorkers(); - const worker = getSelectedWorker( - workersResponse.data?.result ?? [], - deps.worker === undefined - ? "" - : `?worker=${encodeURIComponent(deps.worker)}` - )?.name; + let worker = deps.worker; + if (deps.lookup !== "message-id" && worker === undefined) { + const workersResponse = await localExplorerListWorkers(); + worker = getSelectedWorker(workersResponse.data?.result ?? [], "")?.name; + } const response = await emailListRouting({ - query: { email_id: params.emailId, worker }, + query: + deps.lookup === "message-id" + ? { email_id: params.captureId, worker } + : { capture_id: params.captureId, worker: worker ?? "" }, throwOnError: false, }); if (response.response?.status === 404) { @@ -46,7 +55,7 @@ export const Route = createFileRoute("/email/routing/$emailId")({ } const email = response.data?.result; if (response.error || !email || Array.isArray(email)) { - throw new Error(`Failed to load email "${params.emailId}"`); + throw new Error(`Failed to load email "${params.captureId}"`); } const truncated = hasEmailTruncationWarning( response.data?.messages ?? [], @@ -64,10 +73,12 @@ export const Route = createFileRoute("/email/routing/$emailId")({ }, }); -function toInfoMessage(email: EmailRoutingDetail): InfoMessage { - const emailId = toEmailId(email.messageId); +function toInfoMessage( + email: EmailRoutingDetail, + captureId: string +): InfoMessage { const events: InfoEvent[] = email.events.map((event, index) => ({ - id: `${emailId}-${index}`, + id: `${captureId}-${index}`, type: event.type, timestamp: event.timestamp, // `forward`/`reply` events carry a messageId correlating with the full @@ -84,7 +95,7 @@ function toInfoMessage(email: EmailRoutingDetail): InfoMessage { })); return { - id: emailId, + id: captureId, from: email.from, to: email.to, subject: email.subject, @@ -103,7 +114,8 @@ function toInfoMessage(email: EmailRoutingDetail): InfoMessage { function EmailRoutingDetailView(): JSX.Element { const { email, replyTruncated, truncated } = Route.useLoaderData(); - const message = toInfoMessage(email); + const { captureId } = Route.useParams(); + const message = toInfoMessage(email, captureId); const handlerThrew = hasEmailHandlerException(email); return ( diff --git a/packages/local-explorer-ui/src/routes/email/routing/index.tsx b/packages/local-explorer-ui/src/routes/email/routing/index.tsx index eae635d8c4c..9f65db0d4f0 100644 --- a/packages/local-explorer-ui/src/routes/email/routing/index.tsx +++ b/packages/local-explorer-ui/src/routes/email/routing/index.tsx @@ -1,25 +1,27 @@ -import { Button } from "@cloudflare/kumo"; +import { Button, Tooltip } from "@cloudflare/kumo"; import { EnvelopeSimpleIcon, + PaperPlaneRightIcon, PaperPlaneTiltIcon, PencilSimpleIcon, } from "@phosphor-icons/react"; import { createFileRoute } from "@tanstack/react-router"; -import { useCallback, useMemo, useState, type JSX } from "react"; +import { useCallback, useMemo, type JSX } from "react"; import { emailListRouting, localExplorerListWorkers } from "../../../api"; import { Breadcrumbs } from "../../../components/Breadcrumbs"; import { EmailList } from "../../../components/email/EmailList"; import { EMAIL_PAGE_SIZE } from "../../../components/email/EmailPagination"; import { SendTestEmailDialog } from "../../../components/email/SendTestEmailDialog"; -import { useTestEmailDrafts } from "../../../components/email/TestEmailDraftsContext"; import { ResourceError } from "../../../components/ResourceError"; import { getSelectedWorker } from "../../../components/WorkerSelector"; import { timeAgo } from "../../../components/workflows/helpers"; import { formatEmailAddress } from "../../../utils/format"; -import { toEmailId } from "../shared/types"; import { useCursorPaginatedList } from "../shared/useCursorPaginatedList"; -import type { EmailRoutingItem } from "../../../api"; -import type { TestEmailDraft } from "../../../components/email/TestEmailDraftsContext"; +import { + getEmailRoutingActionKey, + useRoutingEmailActions, +} from "../shared/useRoutingEmailActions"; +import type { RoutingEmail } from "../shared/useRoutingEmailActions"; export const Route = createFileRoute("/email/routing/")({ component: EmailRoutingView, @@ -35,7 +37,14 @@ export const Route = createFileRoute("/email/routing/")({ )?.name; const response = await emailListRouting({ query: { per_page: EMAIL_PAGE_SIZE, worker }, + throwOnError: false, }); + if (response.error || !response.response.ok) { + throw new Error( + response.error?.errors?.[0]?.message ?? + "Failed to load received emails." + ); + } const emails = response.data?.result; return { emails: Array.isArray(emails) ? emails : [], @@ -51,16 +60,19 @@ function EmailRoutingView(): JSX.Element { const loaderData = Route.useLoaderData(); const navigate = Route.useNavigate(); const { worker } = loaderData; - const [dialogOpen, setDialogOpen] = useState(false); - const [dialogDraft, setDialogDraft] = useState(); - const { drafts, setDrafts } = useTestEmailDrafts(); - const lastSentDraft = worker ? drafts[worker] : undefined; const fetchEmails = useCallback( async (cursor?: string) => { const response = await emailListRouting({ query: { cursor, per_page: EMAIL_PAGE_SIZE, worker }, + throwOnError: false, }); + if (response.error || !response.response.ok) { + throw new Error( + response.error?.errors?.[0]?.message ?? + "Failed to load received emails." + ); + } const result = response.data?.result; return { items: Array.isArray(result) ? result : [], @@ -84,8 +96,9 @@ function EmailRoutingView(): JSX.Element { paging, previousPage, refresh, + refreshFirstPage, refreshing, - } = useCursorPaginatedList({ + } = useCursorPaginatedList({ fetchPage: fetchEmails, initialPage, pageErrorMessages: { @@ -94,6 +107,7 @@ function EmailRoutingView(): JSX.Element { refresh: "Failed to refresh received emails.", }, }); + const routingActions = useRoutingEmailActions({ refreshFirstPage, worker }); return (
@@ -106,84 +120,135 @@ function EmailRoutingView(): JSX.Element {
- - -
+ } className="flex-1" disabled={paging || refreshing} emptyState={ <> - No emails received yet. Use “Send Test Email” to + No emails received yet. Use “Send test email” to deliver one. Email capture only works when the selected Worker has an email() handler configured. } error={refreshError} - getRow={(email) => ({ - id: toEmailId(email.messageId), - primary: email.subject || "(no subject)", - secondary: `${formatEmailAddress(email.from)} → ${formatEmailAddress(email.to)}`, - secondaryTitle: `From: ${formatEmailAddress(email.from)}; To: ${formatEmailAddress(email.to)}`, - timestamp: timeAgo(email.receivedAt) || "—", - warning: - email.outcome === "exception" - ? "Email processing exception" - : undefined, - })} + getRow={(email, index) => { + const captureId = email.captureId; + const rowWorker = email.worker; + return { + id: + captureId && rowWorker + ? getEmailRoutingActionKey(rowWorker, captureId) + : `summary\u0000${rowWorker ?? ""}\u0000${email.messageId}\u0000${email.receivedAt}\u0000${index}`, + navigable: Boolean(rowWorker), + navigationId: captureId ?? email.messageId, + primary: email.subject || "(no subject)", + secondary: `${formatEmailAddress(email.from)} → ${formatEmailAddress(email.to)}`, + secondaryTitle: `From: ${formatEmailAddress(email.from)}; To: ${formatEmailAddress(email.to)}`, + timestamp: timeAgo(email.receivedAt) || "—", + warning: + email.outcome === "exception" + ? "Email processing exception" + : undefined, + }; + }} hasNext={hasNext} hasPrevious={hasPrevious} items={emails} onNext={() => void nextPage()} onPrevious={() => void previousPage()} onRefresh={() => void refresh()} - onRowClick={(emailId) => { + onRowClick={(routingId, email) => { void navigate({ - params: { emailId }, - search: (previous) => previous, - to: "/email/routing/$emailId", + params: { captureId: routingId }, + search: (previous) => ({ + ...previous, + lookup: email.captureId ? undefined : "message-id", + worker: email.worker, + }), + to: "/email/routing/$captureId", }); }} refreshing={refreshing} + renderRowActions={(email) => { + const captureId = email.captureId; + const rowWorker = email.worker; + if (!captureId || !rowWorker) { + return null; + } + const actionState = routingActions.getRowActionState(email); + const loading = actionState !== "idle"; + const editAvailable = email.editAndResendAvailable === true; + const editExplanation = editAvailable + ? "Edit and resend" + : (email.editAndResendUnavailableReason ?? + "This capture cannot be edited and resent."); + const resendExplanation = email.capturedPortion + ? "Resend. Only the captured portion of the original email is available." + : "Resend"; + return ( +
+ + + + + + +
+ ); + }} />
{ - if (worker) { - setDrafts((current) => ({ ...current, [worker]: draft })); - } - void refresh(); - }} - open={dialogOpen} - worker={worker} + initialDraft={routingActions.dialogDraft} + key={routingActions.workerGeneration} + onDispatchedSendSettled={routingActions.requestInboxRefresh} + onOpenChange={routingActions.handleDialogOpenChange} + open={routingActions.dialogOpen} + worker={routingActions.dialogWorker} + workerGeneration={routingActions.workerGeneration} />
); diff --git a/packages/local-explorer-ui/src/routes/email/sending.tsx b/packages/local-explorer-ui/src/routes/email/sending.tsx index e1cfb5b8c64..811d4d6fc45 100644 --- a/packages/local-explorer-ui/src/routes/email/sending.tsx +++ b/packages/local-explorer-ui/src/routes/email/sending.tsx @@ -41,7 +41,13 @@ export const Route = createFileRoute("/email/sending")({ )?.name; const response = await emailListSending({ query: { per_page: EMAIL_PAGE_SIZE, worker }, + throwOnError: false, }); + if (response.error || !response.response.ok) { + throw new Error( + response.error?.errors?.[0]?.message ?? "Failed to load sent emails." + ); + } const emails = response.data?.result; return { emails: Array.isArray(emails) ? emails : [], @@ -100,7 +106,13 @@ function EmailSendingView(): JSX.Element { async (cursor?: string) => { const response = await emailListSending({ query: { cursor, per_page: EMAIL_PAGE_SIZE, worker }, + throwOnError: false, }); + if (response.error || !response.response.ok) { + throw new Error( + response.error?.errors?.[0]?.message ?? "Failed to load sent emails." + ); + } const result = response.data?.result; return { items: Array.isArray(result) ? result : [], diff --git a/packages/local-explorer-ui/src/routes/email/shared/useCursorPaginatedList.ts b/packages/local-explorer-ui/src/routes/email/shared/useCursorPaginatedList.ts index f6ce778a98c..1d0934fa44b 100644 --- a/packages/local-explorer-ui/src/routes/email/shared/useCursorPaginatedList.ts +++ b/packages/local-explorer-ui/src/routes/email/shared/useCursorPaginatedList.ts @@ -17,6 +17,8 @@ interface CursorPaginatedListOptions { }; } +export type CursorPageLoadResult = "success" | "stale"; + interface CursorPaginatedList { error: string | null; hasNext: boolean; @@ -26,9 +28,14 @@ interface CursorPaginatedList { paging: boolean; previousPage: () => Promise; refresh: () => Promise; + refreshFirstPage: () => Promise; refreshing: boolean; } +function errorMessage(cause: unknown, fallback: string): string { + return cause instanceof Error ? cause.message : fallback; +} + /** * Manages cursor navigation, refreshes, and stale-request protection for a list. * @@ -53,70 +60,108 @@ export function useCursorPaginatedList({ const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const request = useRef(0); + const pagingRequest = useRef(undefined); + const refreshingRequest = useRef(undefined); useEffect(() => { request.current += 1; + pagingRequest.current = undefined; + refreshingRequest.current = undefined; setItems(initialPage.items); setCurrentCursor(undefined); setNextCursor(initialPage.nextCursor); setPreviousCursors([]); + setPaging(false); + setRefreshing(false); setError(null); }, [initialPage]); const loadPage = useCallback( - async (cursor?: string): Promise => { + async ( + cursor: string | undefined, + kind: "page" | "refresh", + resetNavigation: boolean, + minimumDelay = false + ): Promise => { const requestId = request.current + 1; request.current = requestId; - let page: CursorPage; + setError(null); + if (kind === "page") { + pagingRequest.current = requestId; + setPaging(true); + } else { + pagingRequest.current = undefined; + setPaging(false); + refreshingRequest.current = requestId; + setRefreshing(true); + } + try { - page = await fetchPage(cursor); + const pageRequest = fetchPage(cursor); + const page = minimumDelay + ? await withMinimumDelay(pageRequest) + : await pageRequest; + if (requestId !== request.current) { + return "stale"; + } + setItems(page.items); + setNextCursor(page.nextCursor); + if (resetNavigation) { + setCurrentCursor(undefined); + setPreviousCursors([]); + onPageChange?.(); + } + return "success"; } catch (cause) { if (requestId !== request.current) { - return false; + return "stale"; } + const fallback = + kind === "refresh" + ? pageErrorMessages.refresh + : pageErrorMessages.next; + setError(errorMessage(cause, fallback)); throw cause; + } finally { + if (pagingRequest.current === requestId) { + pagingRequest.current = undefined; + setPaging(false); + } + if (refreshingRequest.current === requestId) { + refreshingRequest.current = undefined; + setRefreshing(false); + } } - if (requestId !== request.current) { - return false; - } - setItems(page.items); - setNextCursor(page.nextCursor); - return true; }, - [fetchPage] + [fetchPage, onPageChange, pageErrorMessages.next, pageErrorMessages.refresh] ); const refresh = useCallback(async (): Promise => { - setRefreshing(true); - setError(null); try { - await withMinimumDelay(loadPage(currentCursor)); - } catch (cause) { - setError( - cause instanceof Error ? cause.message : pageErrorMessages.refresh - ); - } finally { - setRefreshing(false); + await loadPage(currentCursor, "refresh", false, true); + } catch { + // loadPage owns the visible list error. } - }, [currentCursor, loadPage, pageErrorMessages.refresh]); + }, [currentCursor, loadPage]); + + const refreshFirstPage = + useCallback(async (): Promise => { + return loadPage(undefined, "refresh", true); + }, [loadPage]); async function nextPage(): Promise { if (!nextCursor) { return; } - setPaging(true); - setError(null); try { - if (!(await loadPage(nextCursor))) { + if ((await loadPage(nextCursor, "page", false)) === "stale") { return; } setPreviousCursors((cursors) => [...cursors, currentCursor]); setCurrentCursor(nextCursor); onPageChange?.(); } catch (cause) { - setError(cause instanceof Error ? cause.message : pageErrorMessages.next); - } finally { - setPaging(false); + setError(errorMessage(cause, pageErrorMessages.next)); } } @@ -125,21 +170,15 @@ export function useCursorPaginatedList({ if (previousCursors.length === 0) { return; } - setPaging(true); - setError(null); try { - if (!(await loadPage(previousCursor))) { + if ((await loadPage(previousCursor, "page", false)) === "stale") { return; } setPreviousCursors((cursors) => cursors.slice(0, -1)); setCurrentCursor(previousCursor); onPageChange?.(); } catch (cause) { - setError( - cause instanceof Error ? cause.message : pageErrorMessages.previous - ); - } finally { - setPaging(false); + setError(errorMessage(cause, pageErrorMessages.previous)); } } @@ -152,6 +191,7 @@ export function useCursorPaginatedList({ paging, previousPage, refresh, + refreshFirstPage, refreshing, }; } diff --git a/packages/local-explorer-ui/src/routes/email/shared/useRoutingEmailActions.ts b/packages/local-explorer-ui/src/routes/email/shared/useRoutingEmailActions.ts new file mode 100644 index 00000000000..c05e4e70cfc --- /dev/null +++ b/packages/local-explorer-ui/src/routes/email/shared/useRoutingEmailActions.ts @@ -0,0 +1,320 @@ +import { useKumoToastManager } from "@cloudflare/kumo"; +import { useLayoutEffect, useRef, useState } from "react"; +import { emailResendDraftRouting, emailResendRouting } from "../../../api"; +import { + createInboxRefreshCoordinator, + getEmailResendErrorFeedback, + getEmailResendFeedback, + getEmailResendNetworkFeedback, + toTestEmailDraft, +} from "../../../utils/email-resend"; +import type { EmailRoutingItem } from "../../../api"; +import type { TestEmailDraft } from "../../../utils/email-resend"; + +export type RoutingEmail = Omit< + EmailRoutingItem, + "capturedPortion" | "editAndResendAvailable" +> & { + capturedPortion?: boolean; + editAndResendAvailable?: boolean; +}; + +export type EmailRoutingActionState = "idle" | "projecting" | "resending"; + +interface ActiveAction { + controller: AbortController; + key: string; +} + +interface UseRoutingEmailActionsOptions { + refreshFirstPage: () => Promise<"stale" | "success">; + worker?: string; +} + +interface RoutingEmailActions { + dialogDraft?: TestEmailDraft; + dialogOpen: boolean; + dialogWorker?: string; + editAndResend: (email: RoutingEmail) => Promise; + getRowActionState: (email: RoutingEmail) => EmailRoutingActionState; + handleDialogOpenChange: (open: boolean) => void; + openBlankComposer: () => void; + requestInboxRefresh: (expectedGeneration: number) => void; + resend: (email: RoutingEmail) => Promise; + workerGeneration: number; +} + +/** Returns the operational identity for one Routing row. */ +export function getEmailRoutingActionKey( + worker: string, + captureId: string +): string { + return `${worker}\u0000${captureId}`; +} + +/** Owns the request, dialog, and refresh lifecycle for Routing email actions. */ +export function useRoutingEmailActions({ + refreshFirstPage, + worker, +}: UseRoutingEmailActionsOptions): RoutingEmailActions { + const toast = useKumoToastManager(); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogDraft, setDialogDraft] = useState(); + const [dialogWorker, setDialogWorker] = useState(); + const [activeActions, setActiveActions] = useState< + Map + >(() => new Map()); + const [workerGeneration, setWorkerGeneration] = useState(0); + const activeActionsRef = useRef>( + new Map() + ); + const projectionRef = useRef(undefined); + const resendControllersRef = useRef>(new Map()); + const lifecycleTokenRef = useRef(0); + const workerGenerationRef = useRef(0); + const disposedRef = useRef(false); + const refreshFirstPageRef = useRef(refreshFirstPage); + refreshFirstPageRef.current = refreshFirstPage; + const refreshCoordinatorRef = useRef< + ReturnType | undefined + >(undefined); + if (refreshCoordinatorRef.current === undefined) { + refreshCoordinatorRef.current = createInboxRefreshCoordinator({ + currentGeneration: () => workerGenerationRef.current, + isDisposed: () => disposedRef.current, + refreshFirstPage: () => refreshFirstPageRef.current(), + }); + } + const requestInboxRefresh = refreshCoordinatorRef.current.request; + + function updateActiveActions( + next: Map + ): void { + activeActionsRef.current = next; + setActiveActions(new Map(next)); + } + + function removeActiveAction(key: string): void { + if (!activeActionsRef.current.has(key)) { + return; + } + const next = new Map(activeActionsRef.current); + next.delete(key); + updateActiveActions(next); + } + + function cancelProjection(): void { + const action = projectionRef.current; + if (!action) { + return; + } + projectionRef.current = undefined; + action.controller.abort(); + removeActiveAction(action.key); + } + + function invalidateActions(commitState = true): void { + projectionRef.current?.controller.abort(); + projectionRef.current = undefined; + for (const action of resendControllersRef.current.values()) { + action.controller.abort(); + } + resendControllersRef.current.clear(); + activeActionsRef.current = new Map(); + if (commitState) { + setActiveActions(new Map()); + setDialogOpen(false); + setDialogDraft(undefined); + setDialogWorker(undefined); + } + } + + useLayoutEffect(() => { + const token = lifecycleTokenRef.current + 1; + lifecycleTokenRef.current = token; + disposedRef.current = false; + refreshCoordinatorRef.current?.clear(); + workerGenerationRef.current += 1; + setWorkerGeneration(workerGenerationRef.current); + invalidateActions(); + + return () => { + if (lifecycleTokenRef.current !== token) { + return; + } + disposedRef.current = true; + refreshCoordinatorRef.current?.clear(); + workerGenerationRef.current += 1; + invalidateActions(false); + }; + }, [worker]); + + function openBlankComposer(): void { + cancelProjection(); + setDialogDraft(undefined); + setDialogWorker(worker); + setDialogOpen(true); + } + + function handleDialogOpenChange(open: boolean): void { + setDialogOpen(open); + if (!open) { + setDialogDraft(undefined); + setDialogWorker(undefined); + } + } + + async function editAndResend(email: RoutingEmail): Promise { + const captureId = email.captureId; + const rowWorker = email.worker; + if (!captureId || !rowWorker || email.editAndResendAvailable !== true) { + return; + } + const key = getEmailRoutingActionKey(rowWorker, captureId); + if (activeActionsRef.current.has(key)) { + return; + } + + cancelProjection(); + setDialogOpen(false); + setDialogDraft(undefined); + setDialogWorker(undefined); + const generation = workerGenerationRef.current; + const controller = new AbortController(); + const action = { controller, key }; + projectionRef.current = action; + updateActiveActions( + new Map(activeActionsRef.current).set(key, "projecting") + ); + try { + const response = await emailResendDraftRouting({ + query: { capture_id: captureId, worker: rowWorker }, + signal: controller.signal, + throwOnError: false, + }); + if ( + projectionRef.current !== action || + generation !== workerGenerationRef.current + ) { + return; + } + const projection = response.data?.result; + if (response.error || !response.response.ok || !projection) { + toast.add({ + title: + response.error?.errors?.[0]?.message ?? + "Failed to load the email draft.", + variant: "error", + }); + return; + } + setDialogDraft(toTestEmailDraft(projection)); + setDialogWorker(rowWorker); + setDialogOpen(true); + } catch (cause) { + if ( + projectionRef.current === action && + generation === workerGenerationRef.current && + !controller.signal.aborted + ) { + toast.add({ + title: + cause instanceof Error + ? cause.message + : "Failed to load the email draft.", + variant: "error", + }); + } + } finally { + if (projectionRef.current === action) { + projectionRef.current = undefined; + removeActiveAction(key); + } + } + } + + async function resend(email: RoutingEmail): Promise { + const captureId = email.captureId; + const rowWorker = email.worker; + if (!captureId || !rowWorker) { + return; + } + const key = getEmailRoutingActionKey(rowWorker, captureId); + if (activeActionsRef.current.has(key)) { + return; + } + + const generation = workerGenerationRef.current; + const controller = new AbortController(); + const action = { controller, key }; + resendControllersRef.current.set(key, action); + updateActiveActions( + new Map(activeActionsRef.current).set(key, "resending") + ); + let didDispatch = false; + try { + didDispatch = true; + const response = await emailResendRouting({ + query: { capture_id: captureId, worker: rowWorker }, + signal: controller.signal, + throwOnError: false, + }); + if ( + resendControllersRef.current.get(key) !== action || + generation !== workerGenerationRef.current + ) { + return; + } + const result = response.data?.result; + const feedback = + response.error || !response.response.ok || !result + ? getEmailResendErrorFeedback( + response.error, + response.response.status, + email.capturedPortion === true + ) + : getEmailResendFeedback(result); + toast.add(feedback); + } catch { + if ( + resendControllersRef.current.get(key) === action && + generation === workerGenerationRef.current && + !controller.signal.aborted + ) { + toast.add( + getEmailResendNetworkFeedback(email.capturedPortion === true) + ); + } + } finally { + if (resendControllersRef.current.get(key) === action) { + resendControllersRef.current.delete(key); + removeActiveAction(key); + } + if (didDispatch && generation === workerGenerationRef.current) { + requestInboxRefresh(generation); + } + } + } + + function getRowActionState(email: RoutingEmail): EmailRoutingActionState { + const captureId = email.captureId; + const rowWorker = email.worker; + return captureId && rowWorker + ? (activeActions.get(getEmailRoutingActionKey(rowWorker, captureId)) ?? + "idle") + : "idle"; + } + + return { + dialogDraft, + dialogOpen, + dialogWorker, + editAndResend, + getRowActionState, + handleDialogOpenChange, + openBlankComposer, + requestInboxRefresh, + resend, + workerGeneration, + }; +} diff --git a/packages/local-explorer-ui/src/utils/email-resend.ts b/packages/local-explorer-ui/src/utils/email-resend.ts new file mode 100644 index 00000000000..7a2a492cbdc --- /dev/null +++ b/packages/local-explorer-ui/src/utils/email-resend.ts @@ -0,0 +1,198 @@ +import type { EmailResendRoutingResponse, EmailSendRequest } from "../api"; + +type EmailResendResult = NonNullable; + +type AttachmentInput = NonNullable[number]; + +export interface SelectedTestEmailAttachment extends AttachmentInput { + id: string; + size: number; +} + +export interface TestEmailDraft { + from: string; + to: string; + cc: string; + bcc: string; + replyTo: string; + subject: string; + headers: Array<{ name: string; value: string }>; + text: string; + html: string; + attachments: SelectedTestEmailAttachment[]; +} + +export interface EmailResendFeedback { + description?: string; + title: string; + variant: "error" | "success"; +} + +interface InboxRefreshCoordinatorOptions { + currentGeneration: () => number; + isDisposed: () => boolean; + refreshFirstPage: () => Promise<"stale" | "success">; +} + +export interface InboxRefreshCoordinator { + clear: () => void; + request: (expectedGeneration: number) => void; +} + +interface EmailApiError { + errors?: Array<{ code?: number; message?: string }>; +} + +const CAPTURED_PORTION_MESSAGE = + "Only the captured portion of the original email was available."; + +function decodedBase64Size(value: string): number { + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + return Math.floor((value.length * 3) / 4) - padding; +} + +/** Converts a server projection to state owned by the existing composer. */ +export function toTestEmailDraft(draft: EmailSendRequest): TestEmailDraft { + return { + from: draft.from, + to: draft.to.join(", "), + cc: draft.cc?.join(", ") ?? "", + bcc: "", + replyTo: draft.replyTo ?? "", + subject: draft.subject, + headers: Object.entries(draft.headers ?? {}).map(([name, value]) => ({ + name, + value, + })), + text: draft.text ?? "", + html: draft.html ?? "", + attachments: (draft.attachments ?? []).map((attachment) => ({ + ...attachment, + id: crypto.randomUUID(), + size: decodedBase64Size(attachment.content), + })), + }; +} + +/** Maps immediate-resend results to concise, outcome-specific feedback. */ +export function getEmailResendFeedback( + result: EmailResendResult +): EmailResendFeedback { + const description = result.capturedPortion + ? CAPTURED_PORTION_MESSAGE + : undefined; + if (Object.hasOwn(result, "rejectReason")) { + return { + description: [ + result.rejectReason || "No rejection reason was provided.", + description, + ] + .filter(Boolean) + .join(" "), + title: "Email was rejected.", + variant: "error", + }; + } + if (result.outcome === "exception") { + return { + description, + title: "The email handler threw an exception.", + variant: "error", + }; + } + return { description, title: "Email resent.", variant: "success" }; +} + +/** Maps an API failure without claiming an ambiguous resend was not delivered. */ +export function getEmailResendErrorFeedback( + error: EmailApiError | undefined, + status: number | undefined, + sourceCapturedPortion: boolean +): EmailResendFeedback { + const apiError = error?.errors?.[0]; + const description = sourceCapturedPortion + ? CAPTURED_PORTION_MESSAGE + : undefined; + if (status === 502 || apiError?.code === 10603) { + return { + description, + title: + "The resend result is unknown because the Worker peer became unavailable.", + variant: "error", + }; + } + return { + description, + title: apiError?.message ?? "Failed to resend the email.", + variant: "error", + }; +} + +/** Returns network feedback that acknowledges delivery may already have happened. */ +export function getEmailResendNetworkFeedback( + sourceCapturedPortion: boolean +): EmailResendFeedback { + return { + description: sourceCapturedPortion ? CAPTURED_PORTION_MESSAGE : undefined, + title: "The resend result is unknown because the request was interrupted.", + variant: "error", + }; +} + +/** + * Coalesces send-driven inbox refreshes into one constant-memory dirty loop. + */ +export function createInboxRefreshCoordinator({ + currentGeneration, + isDisposed, + refreshFirstPage, +}: InboxRefreshCoordinatorOptions): InboxRefreshCoordinator { + let dirty = false; + let running: Promise | undefined; + + async function drain(expectedGeneration: number): Promise { + while ( + dirty && + !isDisposed() && + expectedGeneration === currentGeneration() + ) { + dirty = false; + try { + if ((await refreshFirstPage()) === "stale") { + return; + } + } catch { + return; + } + } + } + + function request(expectedGeneration: number): void { + if (isDisposed() || expectedGeneration !== currentGeneration()) { + return; + } + dirty = true; + if (running !== undefined) { + return; + } + + const task = Promise.resolve().then(() => drain(expectedGeneration)); + const tracked = task.finally(() => { + if (running !== tracked) { + return; + } + running = undefined; + if (!isDisposed() && dirty) { + request(currentGeneration()); + } + }); + running = tracked; + } + + return { + clear: () => { + dirty = false; + }, + request, + }; +} diff --git a/packages/miniflare/scripts/email-openapi.ts b/packages/miniflare/scripts/email-openapi.ts deleted file mode 100644 index da56c490c0b..00000000000 --- a/packages/miniflare/scripts/email-openapi.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { z } from "zod"; -import { - zEmailAttachment, - zEmailBase, - zEmailHandlerEvent, - zEmailHandlerForward, - zEmailHandlerReplyApi, - zEmailRoutingDetail, - zEmailRoutingItem, - zEmailSendingDetail, - zEmailSendingItem, - zEmailSendRequest, -} from "../src/workers/email/contracts"; - -const EMAIL_SCHEMAS = { - "email_handler-event": zEmailHandlerEvent, - "email_handler-forward": zEmailHandlerForward, - "email_handler-reply": zEmailHandlerReplyApi, - email_base: zEmailBase, - "email_routing-item": zEmailRoutingItem, - "email_routing-detail": zEmailRoutingDetail, - "email_send-request": zEmailSendRequest, - email_attachment: zEmailAttachment, - "email_sending-item": zEmailSendingItem, - "email_sending-detail": zEmailSendingDetail, -}; - -const emailSchemaRegistry = z.registry<{ id: string }>(); -for (const [id, schema] of Object.entries(EMAIL_SCHEMAS)) { - emailSchemaRegistry.add(schema, { id }); -} - -const { schemas: emailOpenApiSchemas } = z.toJSONSchema(emailSchemaRegistry, { - target: "openapi-3.0", - unrepresentable: "any", - uri: (id) => `#/components/schemas/${id}`, -}); - -export const EMAIL_OPENAPI_SCHEMAS = Object.fromEntries( - Object.entries(emailOpenApiSchemas).map(([id, schema]) => { - const { $id: _$id, $schema: _$schema, ...openApiSchema } = schema; - return [id, openApiSchema]; - }) -); diff --git a/packages/miniflare/scripts/openapi-filter-config.ts b/packages/miniflare/scripts/openapi-filter-config.ts index 1847226569a..7b21d875a80 100644 --- a/packages/miniflare/scripts/openapi-filter-config.ts +++ b/packages/miniflare/scripts/openapi-filter-config.ts @@ -1,4 +1,3 @@ -import { EMAIL_OPENAPI_SCHEMAS } from "./email-openapi"; import type { FilterConfig } from "./filter-openapi"; /** @@ -633,7 +632,7 @@ const config = { "/local/email/routing": { get: { description: - "Lists emails received by any email() handler during this dev session. Use the optional `worker` query parameter to filter by worker, or `email_id` to return one email's details.", + "Lists emails received by any email() handler during this dev session. Use `capture_id` with `worker` for canonical exact-capture details, or `email_id` for compatibility Message-ID lookup. The two identifiers are mutually exclusive.", operationId: "email-list-routing", parameters: [ { @@ -648,7 +647,14 @@ const config = { name: "email_id", schema: { type: "string" }, description: - "Return the details for this email instead of a paginated list.", + "Compatibility lookup by RFC Message-ID. Returns the newest match and accepts bracketed or bracket-stripped values.", + }, + { + in: "query", + name: "capture_id", + schema: { type: "string", format: "uuid" }, + description: + "Canonical identifier for one captured delivery. Requires `worker` and never falls back to Message-ID lookup.", }, { in: "query", @@ -725,6 +731,138 @@ const config = { tags: ["Email"], }, }, + "/local/email/routing/resend": { + post: { + description: + "Replays the stored bytes of one exact Routing capture to the same Worker's email() handler with a new Message-ID.", + operationId: "email-resend-routing", + parameters: [ + { + in: "query", + name: "worker", + required: true, + schema: { type: "string", minLength: 1 }, + description: "Worker that owns the exact Routing capture.", + }, + { + in: "query", + name: "capture_id", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Opaque identifier for the exact captured delivery.", + }, + ], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + type: "object", + properties: { + result: { + type: "object", + properties: { + messageId: { type: "string" }, + outcome: { + type: "string", + enum: ["ok", "exception"], + }, + rejectReason: { type: "string" }, + capturedPortion: { type: "boolean" }, + }, + required: [ + "messageId", + "outcome", + "capturedPortion", + ], + }, + }, + }, + ], + }, + }, + }, + description: "Email resend result.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Email resend failure.", + }, + }, + summary: "Resend Received Email", + tags: ["Email"], + }, + }, + "/local/email/routing/resend/draft": { + get: { + description: + "Projects one complete composer-originated Routing capture back into structured composer fields.", + operationId: "email-resend-draft-routing", + parameters: [ + { + in: "query", + name: "worker", + required: true, + schema: { type: "string", minLength: 1 }, + description: "Worker that owns the exact Routing capture.", + }, + { + in: "query", + name: "capture_id", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Opaque identifier for the exact captured delivery.", + }, + ], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + type: "object", + properties: { + result: { + $ref: "#/components/schemas/email_send-request", + }, + }, + }, + ], + }, + }, + }, + description: "Composer projection response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Composer projection failure.", + }, + }, + summary: "Get Received Email Resend Draft", + tags: ["Email"], + }, + }, "/local/email/routing/send": { post: { description: @@ -2279,7 +2417,677 @@ const config = { }, required: ["columns", "rows"], }, - ...EMAIL_OPENAPI_SCHEMAS, + "email_handler-event": { + oneOf: [ + { + type: "object", + properties: { + type: { + type: "string", + enum: ["received", "reject", "unhandled"], + }, + timestamp: { + type: "string", + description: "ISO 8601 timestamp of when the event occurred.", + }, + }, + required: ["type", "timestamp"], + additionalProperties: false, + }, + { + type: "object", + properties: { + type: { + type: "string", + enum: ["forward", "reply"], + }, + timestamp: { + type: "string", + description: "ISO 8601 timestamp of when the event occurred.", + }, + messageId: { + type: "string", + description: + "Correlates with the matching `forwards`/`replies` entry.", + }, + }, + required: ["type", "timestamp", "messageId"], + additionalProperties: false, + }, + ], + 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.", + }, + "email_handler-forward": { + type: "object", + properties: { + messageId: { + type: "string", + }, + recipient: { + type: "string", + description: "Envelope recipient the message was forwarded to.", + }, + headers: { + type: "array", + items: { + type: "array", + items: { + anyOf: [ + { + type: "string", + }, + { + type: "string", + }, + ], + }, + minItems: 2, + maxItems: 2, + }, + description: "Headers added to the forwarded message.", + }, + }, + required: ["messageId", "recipient", "headers"], + additionalProperties: false, + }, + "email_handler-reply": { + type: "object", + properties: { + messageId: { + type: "string", + }, + sender: { + type: "string", + description: "Address the reply was sent from.", + }, + raw: { + type: "string", + description: + "Raw MIME content of the reply. Omitted from the routing list; present on the detail response.", + }, + rawBase64: { + type: "string", + description: "Lossless base64 representation of the reply MIME.", + }, + }, + required: ["messageId", "sender"], + additionalProperties: false, + }, + email_base: { + type: "object", + properties: { + worker: { + type: "string", + description: "Worker associated with the email, if known.", + }, + from: { + type: "string", + description: "Envelope MAIL FROM address.", + }, + subject: { + type: "string", + }, + messageId: { + type: "string", + description: "RFC Message-ID header value carried by the email.", + }, + attachments: { + type: "array", + items: { + $ref: "#/components/schemas/email_attachment", + }, + description: + "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME.", + }, + }, + required: ["from", "subject", "messageId", "attachments"], + additionalProperties: false, + }, + "email_routing-item": { + type: "object", + properties: { + worker: { + type: "string", + description: "Worker that handled this captured delivery.", + }, + from: { + type: "string", + description: "Envelope MAIL FROM address.", + }, + subject: { + type: "string", + }, + messageId: { + type: "string", + description: + "RFC Message-ID header value. This is message content and compatibility lookup material; captureId identifies the Routing record.", + }, + attachments: { + type: "array", + items: { + $ref: "#/components/schemas/email_attachment", + }, + description: + "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME.", + }, + captureId: { + type: "string", + format: "uuid", + pattern: + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + description: "Opaque identifier for this exact captured delivery.", + }, + editAndResendAvailable: { + type: "boolean", + description: + "Whether this capture can be projected into the email composer.", + }, + editAndResendUnavailableReason: { + type: "string", + }, + capturedPortion: { + type: "boolean", + description: + "Whether this capture contains only a portion of the original message.", + }, + to: { + type: "string", + description: "Envelope RCPT TO address.", + }, + cc: { + type: "array", + items: { + type: "string", + }, + }, + headers: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + headerEntries: { + type: "array", + items: { + type: "array", + items: { + anyOf: [ + { + type: "string", + }, + { + type: "string", + }, + ], + }, + minItems: 2, + maxItems: 2, + }, + description: + "Email headers as ordered name/value pairs, including duplicates.", + }, + receivedAt: { + type: "string", + }, + rawSize: { + type: "number", + }, + outcome: { + type: "string", + enum: ["ok", "exception"], + description: "Whether the handler ran to completion or threw.", + }, + rejectReason: { + type: "string", + description: + "Reason passed to setReject(), if the handler rejected the message.", + }, + forwards: { + type: "array", + items: { + $ref: "#/components/schemas/email_handler-forward", + }, + }, + replies: { + type: "array", + items: { + $ref: "#/components/schemas/email_handler-reply", + }, + }, + events: { + type: "array", + items: { + $ref: "#/components/schemas/email_handler-event", + }, + }, + }, + required: [ + "from", + "subject", + "messageId", + "attachments", + "to", + "receivedAt", + "rawSize", + "outcome", + "forwards", + "replies", + "events", + ], + additionalProperties: false, + }, + "email_routing-detail": { + type: "object", + properties: { + worker: { + type: "string", + }, + from: { + type: "string", + description: "Envelope MAIL FROM address.", + }, + subject: { + type: "string", + }, + messageId: { + type: "string", + description: + "RFC Message-ID header value. This is message content and compatibility lookup material; captureId identifies the Routing record.", + }, + attachments: { + type: "array", + items: { + $ref: "#/components/schemas/email_attachment", + }, + description: + "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME.", + }, + captureId: { + type: "string", + format: "uuid", + pattern: + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }, + editAndResendAvailable: { + type: "boolean", + }, + editAndResendUnavailableReason: { + type: "string", + }, + capturedPortion: { + type: "boolean", + }, + to: { + type: "string", + description: "Envelope RCPT TO address.", + }, + cc: { + type: "array", + items: { + type: "string", + }, + }, + headers: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + headerEntries: { + type: "array", + items: { + type: "array", + items: { + anyOf: [ + { + type: "string", + }, + { + type: "string", + }, + ], + }, + minItems: 2, + maxItems: 2, + }, + description: + "Email headers as ordered name/value pairs, including duplicates.", + }, + receivedAt: { + type: "string", + }, + rawSize: { + type: "number", + }, + outcome: { + type: "string", + enum: ["ok", "exception"], + description: "Whether the handler ran to completion or threw.", + }, + rejectReason: { + type: "string", + description: + "Reason passed to setReject(), if the handler rejected the message.", + }, + forwards: { + type: "array", + items: { + $ref: "#/components/schemas/email_handler-forward", + }, + }, + replies: { + type: "array", + items: { + $ref: "#/components/schemas/email_handler-reply", + }, + }, + events: { + type: "array", + items: { + $ref: "#/components/schemas/email_handler-event", + }, + }, + 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.", + }, + rawBase64: { + type: "string", + description: "Lossless base64 representation of the received MIME.", + }, + }, + required: [ + "worker", + "from", + "subject", + "messageId", + "attachments", + "captureId", + "editAndResendAvailable", + "capturedPortion", + "to", + "receivedAt", + "rawSize", + "outcome", + "forwards", + "replies", + "events", + "raw", + ], + additionalProperties: false, + }, + "email_send-request": { + type: "object", + properties: { + from: { + type: "string", + description: "Sender address.", + }, + to: { + minItems: 1, + type: "array", + items: { + type: "string", + }, + description: "Recipient addresses.", + }, + cc: { + type: "array", + items: { + type: "string", + }, + }, + bcc: { + type: "array", + items: { + type: "string", + }, + }, + replyTo: { + type: "string", + }, + subject: { + type: "string", + }, + text: { + type: "string", + description: "Plain text body.", + }, + html: { + type: "string", + description: "HTML body.", + }, + headers: { + type: "object", + additionalProperties: { + type: "string", + }, + description: "Custom headers to include on the message.", + }, + attachments: { + type: "array", + items: { + type: "object", + properties: { + filename: { + type: "string", + description: "Name the attachment is presented under.", + }, + type: { + type: "string", + description: + "MIME type of the attachment, e.g. 'application/pdf'.", + }, + content: { + type: "string", + description: + "Attachment content, base64-encoded. MessageBuilder takes raw bytes here, but this endpoint accepts JSON so the bytes must be base64-encoded.", + }, + contentId: { + type: "string", + description: "Content-ID for an inline attachment.", + }, + disposition: { + type: "string", + enum: ["inline", "attachment"], + description: + "How the attachment is presented. Defaults to 'attachment'.", + }, + }, + required: ["filename", "type", "content"], + additionalProperties: false, + }, + description: + "Attachments to include on the message, mirroring the MessageBuilder `attachments` entries accepted by a send_email binding. Adding any attachment composes the message as multipart/mixed.", + }, + }, + required: ["from", "to", "subject"], + additionalProperties: false, + description: + "Fields for composing a test email, mirroring MessageBuilder.", + }, + email_attachment: { + type: "object", + properties: { + filename: { + type: "string", + }, + contentType: { + type: "string", + }, + disposition: { + type: "string", + enum: ["inline", "attachment"], + }, + size: { + type: "number", + }, + }, + required: ["filename", "contentType", "disposition", "size"], + additionalProperties: false, + description: + "Metadata describing an attachment on a captured email, without its content.", + }, + "email_sending-item": { + type: "object", + properties: { + worker: { + type: "string", + description: "Worker associated with the email, if known.", + }, + from: { + type: "string", + description: "Envelope MAIL FROM address.", + }, + subject: { + type: "string", + }, + messageId: { + type: "string", + description: + "RFC Message-ID header value that identifies this Sending record for detail lookup.", + }, + attachments: { + type: "array", + items: { + $ref: "#/components/schemas/email_attachment", + }, + description: + "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME.", + }, + to: { + type: "array", + items: { + type: "string", + }, + }, + cc: { + type: "array", + items: { + type: "string", + }, + }, + bcc: { + type: "array", + items: { + type: "string", + }, + }, + replyTo: { + type: "string", + }, + sentAt: { + type: "string", + }, + headers: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + }, + required: [ + "from", + "subject", + "messageId", + "attachments", + "to", + "sentAt", + ], + additionalProperties: false, + }, + "email_sending-detail": { + type: "object", + properties: { + worker: { + type: "string", + description: "Worker associated with the email, if known.", + }, + from: { + type: "string", + description: "Envelope MAIL FROM address.", + }, + subject: { + type: "string", + }, + messageId: { + type: "string", + description: + "RFC Message-ID header value that identifies this Sending record for detail lookup.", + }, + attachments: { + type: "array", + items: { + $ref: "#/components/schemas/email_attachment", + }, + description: + "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME.", + }, + to: { + type: "array", + items: { + type: "string", + }, + }, + cc: { + type: "array", + items: { + type: "string", + }, + }, + bcc: { + type: "array", + items: { + type: "string", + }, + }, + replyTo: { + type: "string", + }, + sentAt: { + type: "string", + }, + headers: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + text: { + type: "string", + }, + html: { + type: "string", + }, + raw: { + type: "string", + description: + "Raw MIME content, present when sent via the EmailMessage API.", + }, + rawBase64: { + type: "string", + description: "Lossless base64 representation of sent MIME.", + }, + }, + required: [ + "from", + "subject", + "messageId", + "attachments", + "to", + "sentAt", + ], + additionalProperties: false, + }, }, }, } satisfies FilterConfig; diff --git a/packages/miniflare/src/workers/core/email.ts b/packages/miniflare/src/workers/core/email.ts index ceb119cced0..eac1f2be5d1 100644 --- a/packages/miniflare/src/workers/core/email.ts +++ b/packages/miniflare/src/workers/core/email.ts @@ -15,10 +15,12 @@ import { import { logEmailToLoopback, storeEmailTempFile } from "../email/loopback"; import { messageIdToStorageId, synthesizeMessageId } from "../email/message-id"; import { buildReplyFromMessageBuilder } from "../email/mime"; +import { commitReceivedCapture } from "../email/received-capture"; import { isEmailReplyable, validateReply } from "../email/validate"; import { CoreBindings } from "./constants"; import type { MiniflareEmailMessage } from "../email/email.worker"; import type { + EmailCaptureOrigin, EmailHandlerEvent, EmailHandlerForward, EmailHandlerReply, @@ -38,6 +40,11 @@ type Env = { [CoreBindings.SERVICE_EMAIL_STORE]: EmailStoreService; }; +export interface EmailCaptureContext { + origin?: EmailCaptureOrigin; + capturedPortion?: boolean; +} + function renderEmailHeaders(headers: Headers | undefined) { return headers ? `\n headers:\n${[...headers.entries()].map(([k, v]) => ` ${escapeLogValue(k)}: ${escapeLogValue(v)}`).join("\n")}` @@ -64,7 +71,8 @@ export async function handleEmail( service: Fetcher, workerName: string, env: Env, - ctx: ExecutionContext + ctx: ExecutionContext, + captureContext: EmailCaptureContext = {} ): Promise { const events: EmailHandlerEvent[] = []; const forwards: EmailHandlerForward[] = []; @@ -182,6 +190,9 @@ export async function handleEmail( "bcc", ]); const metadata: StoredRoutingEmailMetadata = { + origin: captureContext.origin ?? "unknown", + capturedPortion: + (captureContext.capturedPortion ?? false) || capturedRaw.truncated, worker: workerName, from: storedFrom, to: storedTo, @@ -209,27 +220,16 @@ export async function handleEmail( events, ...(capturedRaw.truncated ? { captureTruncated: true } : {}), }; - const captureId = crypto.randomUUID(); - try { - await store.storeReceivedBody(captureId, 0, rawBase64); - for (const [index] of replies.entries()) { - const replyRawBase64 = capturedReplyRawBase64[index]; - if (replyRawBase64 === undefined) { - throw new Error( - `Received email ${metadata.messageId} has no captured reply body at index ${index}` - ); - } - await store.storeReceivedBody(captureId, index + 1, replyRawBase64); + const replyBodies = replies.map((_, index) => { + const replyRawBase64 = capturedReplyRawBase64[index]; + if (replyRawBase64 === undefined) { + throw new Error( + `Received email ${metadata.messageId} has no captured reply body at index ${index}` + ); } - await store.storeReceivedMetadata( - captureId, - replies.length + 1, - metadata - ); - } catch (error) { - await store.discardReceived(captureId).catch(() => undefined); - throw error; - } + return replyRawBase64; + }); + await commitReceivedCapture(store, metadata, [rawBase64, ...replyBodies]); } catch (error) { stored = false; try { diff --git a/packages/miniflare/src/workers/email/address.ts b/packages/miniflare/src/workers/email/address.ts index 4e829e59f9c..c04a1e009fa 100644 --- a/packages/miniflare/src/workers/email/address.ts +++ b/packages/miniflare/src/workers/email/address.ts @@ -5,10 +5,20 @@ function quoteDisplayName(name: string): string { return `"${name.replace(/["\\]/gu, (character) => `\\${character}`)}"`; } -export function formatParsedAddress(address: { +interface ParsedAddress { address?: string; + group?: ParsedAddress[]; name?: string; -}): string { +} + +export function formatParsedAddress(address: ParsedAddress): string { + if (address.group !== undefined) { + const members = address.group.map(formatParsedAddress).join(", "); + if (address.name === undefined || address.name === "") { + return members; + } + return `${quoteDisplayName(address.name)}:${members === "" ? "" : ` ${members}`};`; + } const email = address.address ?? ""; return address.name === undefined || address.name === "" ? email diff --git a/packages/miniflare/src/workers/email/contracts.ts b/packages/miniflare/src/workers/email/contracts.ts index c6cc87a493c..89677b61e17 100644 --- a/packages/miniflare/src/workers/email/contracts.ts +++ b/packages/miniflare/src/workers/email/contracts.ts @@ -111,140 +111,5 @@ export const zEmailHandlerResult = z.object({ ), }) satisfies z.ZodType; -export const zEmailAttachment = z - .object({ - filename: z.string(), - contentType: z.string(), - disposition: z.enum(["inline", "attachment"]), - size: z.number(), - }) - .describe( - "Metadata describing an attachment on a captured email, without its content." - ); - -export type EmailAttachment = z.infer; - -export const zEmailBase = z.object({ - worker: z - .string() - .describe("Worker associated with the email, if known.") - .optional(), - from: z.string().describe("Envelope MAIL FROM address."), - subject: z.string(), - messageId: z - .string() - .describe( - "RFC Message-ID header value. Identifies the email in the store." - ), - attachments: z - .array(zEmailAttachment) - .describe( - "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME." - ), -}); - -export const zEmailRoutingItem = zEmailBase.extend({ - to: z.string().describe("Envelope RCPT TO address."), - cc: z.array(z.string()).optional(), - headers: z.record(z.string(), z.string()).optional(), - headerEntries: zEmailHeaders.optional(), - receivedAt: z.string(), - rawSize: z.number(), - outcome: z - .enum(["ok", "exception"]) - .describe("Whether the handler ran to completion or threw."), - rejectReason: z - .string() - .describe( - "Reason passed to setReject(), if the handler rejected the message." - ) - .optional(), - forwards: z.array(zEmailHandlerForward), - replies: z.array(zEmailHandlerReplyApi), - events: z.array(zEmailHandlerEvent), -}); - -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() - .describe("Lossless base64 representation of the received MIME.") - .optional(), -}); - -export type EmailRoutingDetail = z.infer; - -const zEmailSendAttachment = z.object({ - filename: z.string().describe("Name the attachment is presented under."), - type: z - .string() - .describe("MIME type of the attachment, e.g. 'application/pdf'."), - content: z - .string() - .describe( - "Attachment content, base64-encoded. MessageBuilder takes raw bytes here, but this endpoint accepts JSON so the bytes must be base64-encoded." - ), - contentId: z - .string() - .describe("Content-ID for an inline attachment.") - .optional(), - disposition: z - .enum(["inline", "attachment"]) - .describe("How the attachment is presented. Defaults to 'attachment'.") - .optional(), -}); - -export const zEmailSendRequest = z - .object({ - from: z.string().describe("Sender address."), - to: z.array(z.string()).min(1).describe("Recipient addresses."), - cc: z.array(z.string()).optional(), - bcc: z.array(z.string()).optional(), - replyTo: z.string().optional(), - subject: z.string(), - text: z.string().describe("Plain text body.").optional(), - html: z.string().describe("HTML body.").optional(), - headers: z - .record(z.string(), z.string()) - .describe("Custom headers to include on the message.") - .optional(), - attachments: z - .array(zEmailSendAttachment) - .describe( - "Attachments to include on the message, mirroring the MessageBuilder `attachments` entries accepted by a send_email binding. Adding any attachment composes the message as multipart/mixed." - ) - .optional(), - }) - .describe("Fields for composing a test email, mirroring MessageBuilder."); - -export type EmailSendRequest = z.infer; - -export const zEmailSendingItem = zEmailBase.extend({ - to: z.array(z.string()), - cc: z.array(z.string()).optional(), - bcc: z.array(z.string()).optional(), - replyTo: z.string().optional(), - sentAt: z.string(), - headers: z.record(z.string(), z.string()).optional(), -}); - -export type EmailSendingItem = z.infer; - -export const zEmailSendingDetail = zEmailSendingItem.extend({ - text: z.string().optional(), - html: z.string().optional(), - raw: z - .string() - .describe("Raw MIME content, present when sent via the EmailMessage API.") - .optional(), - rawBase64: z - .string() - .describe("Lossless base64 representation of sent MIME.") - .optional(), -}); - -export type EmailSendingDetail = z.infer; +export const zEmailCaptureOrigin = z.enum(["composer", "unknown"]); +export type EmailCaptureOrigin = z.infer; diff --git a/packages/miniflare/src/workers/email/email-store.ts b/packages/miniflare/src/workers/email/email-store.ts index af12cec6371..0c08996703a 100644 --- a/packages/miniflare/src/workers/email/email-store.ts +++ b/packages/miniflare/src/workers/email/email-store.ts @@ -16,23 +16,22 @@ * directory). */ import { DurableObject } from "cloudflare:workers"; -import { z } from "zod"; import { base64ToBytes, bytesToBase64, MAX_EMAIL_ROW_VALUE_BYTES, } from "./capture"; -import { - zEmailBase, - zEmailHeaders, - zEmailHandlerEvent, - zEmailHandlerForward, - zEmailHandlerReplyApi, - zEmailSendingDetail, -} from "./contracts"; import { messageIdToStorageId } from "./message-id"; +import { missingReceivedCaptureBody } from "./received-capture"; +import { + zStoredRoutingEmailListMetadata, + zStoredRoutingEmailMetadata, + zStoredRoutingEmailSummary, + zStoredSendingEmail, +} from "./storage"; import type { EmailListPage, + ReceivedCaptureOperationLookup, StoredRoutingEmail, StoredRoutingEmailMetadata, StoredRoutingEmailSummary, @@ -43,7 +42,15 @@ import type { export type { StoredSendingEmail }; function decodeCapturedRaw(rawBase64: string, truncated: boolean): string { - const bytes = base64ToBytes(rawBase64); + let bytes: Uint8Array; + try { + bytes = base64ToBytes(rawBase64); + } catch { + // Preserve the stored Base64 for operation-specific validation. A corrupt + // dev-only capture must not prevent callers from returning a structured + // Local Explorer error. + return ""; + } return new TextDecoder().decode( truncated ? trimIncompleteUtf8Suffix(bytes) : bytes ); @@ -78,7 +85,7 @@ function trimIncompleteUtf8Suffix(bytes: Uint8Array): Uint8Array { } function materialiseReceivedEmail( - email: StoredRoutingEmailMetadata, + email: StoredRoutingEmailMetadata & { captureId: string }, rawBase64: string, replyRawBase64: Map ): StoredRoutingEmail { @@ -130,6 +137,11 @@ const SCHEMA = [ kind, created_at DESC, seq DESC )`, `CREATE INDEX IF NOT EXISTS emails_by_kind_id ON emails (kind, id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS received_emails_by_id ON emails (id) + WHERE kind = 'received'`, + `CREATE INDEX IF NOT EXISTS received_emails_by_message_id_seq ON emails ( + trim(json_extract(data, '$.messageId'), '<>'), seq DESC + ) WHERE kind = 'received'`, `CREATE INDEX IF NOT EXISTS emails_by_kind_worker_seq ON emails ( kind, json_extract(data, '$.worker'), seq DESC )`, @@ -142,44 +154,11 @@ const SCHEMA = [ raw_base64 TEXT NOT NULL, PRIMARY KEY (capture_id, part) )`, + `CREATE TABLE IF NOT EXISTS received_email_capture_attempts ( + capture_id TEXT PRIMARY KEY + )`, ]; -const zStoredEmailReply = zEmailHandlerReplyApi.omit({ - raw: true, - rawBase64: true, -}); -const zStoredEmailReplyMetadata = zStoredEmailReply.extend({ - captureTruncated: z.boolean().optional(), -}); -export const zStoredRoutingEmailSummary = zEmailBase.extend({ - to: z.string(), - cc: z.array(z.string()).optional(), - headers: z.record(z.string(), z.string()).optional(), - headerEntries: zEmailHeaders.optional(), - receivedAt: z.string(), - rawSize: z.number(), - outcome: z.enum(["ok", "exception"]), - rejectReason: z.string().optional(), - forwards: z.array(zEmailHandlerForward), - replies: z.array(zStoredEmailReply), - events: z.array(zEmailHandlerEvent), -}); -const zStoredRoutingEmailMetadata = zStoredRoutingEmailSummary.extend({ - captureTruncated: z.boolean().optional(), - replies: z.array(zStoredEmailReplyMetadata), -}); -export const zStoredRoutingEmail = zStoredRoutingEmailMetadata.extend({ - raw: z.string(), - rawBase64: z.string(), - replies: z.array( - zEmailHandlerReplyApi.extend({ - raw: z.string(), - rawBase64: z.string(), - captureTruncated: z.boolean().optional(), - }) - ), -}); - type EmailTable = "received" | "sent"; type EmailCursor = { createdAt: string; seq: number }; const encoder = new TextEncoder(); @@ -196,17 +175,17 @@ function createStatements(kind: EmailTable) { return { insert: `INSERT INTO emails (kind, id, created_at, data) VALUES ('${kind}', ?, ?, ?) RETURNING seq`, - list: `SELECT seq, created_at, data FROM emails + list: `SELECT id, seq, created_at, data FROM emails WHERE kind = '${kind}' ORDER BY created_at DESC, seq DESC LIMIT ?`, - listForWorker: `SELECT seq, created_at, data FROM emails + listForWorker: `SELECT id, seq, created_at, data FROM emails WHERE kind = '${kind}' AND json_extract(data, '$.worker') = ? ORDER BY created_at DESC, seq DESC LIMIT ?`, - listAfter: `SELECT seq, created_at, data FROM emails + listAfter: `SELECT id, seq, created_at, data FROM emails WHERE kind = '${kind}' AND (created_at < ? OR (created_at = ? AND seq < ?)) ORDER BY created_at DESC, seq DESC LIMIT ?`, - listAfterForWorker: `SELECT seq, created_at, data FROM emails + listAfterForWorker: `SELECT id, seq, created_at, data FROM emails WHERE kind = '${kind}' AND (created_at < ? OR (created_at = ? AND seq < ?)) AND json_extract(data, '$.worker') = ? @@ -225,6 +204,21 @@ const STATEMENTS = { sent: createStatements("sent"), insertReceivedBody: `INSERT INTO received_email_bodies (capture_id, part, raw_base64) VALUES (?, ?, ?)`, + beginReceivedCapture: `INSERT OR IGNORE INTO received_email_capture_attempts + (capture_id) VALUES (?) RETURNING capture_id`, + findAnyReceivedCapture: `SELECT id FROM emails WHERE kind = 'received' + AND id = ? LIMIT 1`, + findReceivedCaptureAttempt: `SELECT capture_id + FROM received_email_capture_attempts WHERE capture_id = ?`, + findReceivedCapture: `SELECT data FROM emails WHERE kind = 'received' + AND id = ? AND json_extract(data, '$.worker') = ? LIMIT 1`, + findReceivedMessage: `SELECT id, data FROM emails WHERE kind = 'received' + AND trim(json_extract(data, '$.messageId'), '<>') = ? + ORDER BY seq DESC LIMIT 1`, + findReceivedMessageForWorker: `SELECT id, data FROM emails + WHERE kind = 'received' + AND trim(json_extract(data, '$.messageId'), '<>') = ? + AND json_extract(data, '$.worker') = ? ORDER BY seq DESC LIMIT 1`, countReceivedBodies: `SELECT COUNT(*) AS count, MIN(part) AS first_part, MAX(part) AS last_part FROM received_email_bodies WHERE capture_id = ?`, findReceivedBodies: `SELECT part, raw_base64 FROM received_email_bodies @@ -232,11 +226,12 @@ const STATEMENTS = { discardReceivedBodies: "DELETE FROM received_email_bodies WHERE capture_id = ?", discardReceivedMetadata: - "DELETE FROM emails WHERE kind = 'received' AND json_extract(data, '$.bodyId') = ?", + "DELETE FROM received_email_capture_attempts WHERE capture_id = ?", insertMetadata: `INSERT OR IGNORE INTO email_store_metadata (key, value) VALUES (?, ?)`, findMetadata: "SELECT value FROM email_store_metadata WHERE key = ?", clearReceivedBodies: "DELETE FROM received_email_bodies", + clearReceivedCaptureAttempts: "DELETE FROM received_email_capture_attempts", clear: "DELETE FROM emails", } as const; @@ -334,7 +329,7 @@ export class EmailStore extends DurableObject { /** Newest-first cursor page of records from a table. */ #list( table: EmailTable, - parse: (data: string) => T, + parse: (data: string, id: string) => T, cursor: string | undefined, limit: number | undefined, worker: string | undefined @@ -343,7 +338,12 @@ export class EmailStore extends DurableObject { const rows = cursor === undefined ? this.sql - .exec<{ seq: number; created_at: string; data: string }>( + .exec<{ + id: string; + seq: number; + created_at: string; + data: string; + }>( worker === undefined ? STATEMENTS[table].list : STATEMENTS[table].listForWorker, @@ -355,7 +355,12 @@ export class EmailStore extends DurableObject { : (() => { const decoded = decodeCursor(cursor); return this.sql - .exec<{ seq: number; created_at: string; data: string }>( + .exec<{ + id: string; + seq: number; + created_at: string; + data: string; + }>( worker === undefined ? STATEMENTS[table].listAfter : STATEMENTS[table].listAfterForWorker, @@ -380,7 +385,7 @@ export class EmailStore extends DurableObject { const pageRows = rows.slice(0, pageSize); const last = pageRows.at(-1); return { - items: pageRows.map(({ data }) => parse(data)), + items: pageRows.map(({ data, id }) => parse(data, id)), hasMore, ...(hasMore && last !== undefined ? { @@ -406,10 +411,41 @@ export class EmailStore extends DurableObject { return row === undefined ? undefined : (JSON.parse(row.data) as T); } + beginReceivedCapture(captureId: string): boolean { + return this.ctx.storage.transactionSync(() => { + const existing = this.sql + .exec<{ id: string }>(STATEMENTS.findAnyReceivedCapture, captureId) + .toArray()[0]; + const bodies = this.sql + .exec<{ count: number }>(STATEMENTS.countReceivedBodies, captureId) + .toArray()[0]; + if (existing !== undefined || (bodies?.count ?? 0) !== 0) { + return false; + } + return ( + this.sql + .exec<{ capture_id: string }>( + STATEMENTS.beginReceivedCapture, + captureId + ) + .toArray()[0] !== undefined + ); + }); + } + storeReceivedBody(captureId: string, part: number, rawBase64: string): void { if (!Number.isSafeInteger(part) || part < 0) { throw new RangeError("Invalid received email body part"); } + const attempt = this.sql + .exec<{ capture_id: string }>( + STATEMENTS.findReceivedCaptureAttempt, + captureId + ) + .toArray()[0]; + if (attempt === undefined) { + throw new Error("Received email capture attempt is unavailable"); + } assertEmailRowValueFits(rawBase64, "Received email body"); this.sql.exec(STATEMENTS.insertReceivedBody, captureId, part, rawBase64); } @@ -423,6 +459,15 @@ export class EmailStore extends DurableObject { throw new RangeError("Invalid received email body count"); } this.ctx.storage.transactionSync(() => { + const attempt = this.sql + .exec<{ capture_id: string }>( + STATEMENTS.findReceivedCaptureAttempt, + captureId + ) + .toArray()[0]; + if (attempt === undefined) { + throw new Error("Received email capture attempt is unavailable"); + } const bodies = this.sql .exec<{ count: number; @@ -440,57 +485,52 @@ export class EmailStore extends DurableObject { `Received email ${email.messageId} has incomplete captured bodies` ); } - this.#insert( - "received", - messageIdToStorageId(email.messageId), - email.receivedAt, - { ...email, bodyId: captureId } - ); + this.#insert("received", captureId, email.receivedAt, email); + this.sql.exec(STATEMENTS.discardReceivedMetadata, captureId); }); } discardReceived(captureId: string): void { this.ctx.storage.transactionSync(() => { + const attempt = this.sql + .exec<{ capture_id: string }>( + STATEMENTS.findReceivedCaptureAttempt, + captureId + ) + .toArray()[0]; + if (attempt === undefined) { + return; + } this.sql.exec(STATEMENTS.discardReceivedBodies, captureId); this.sql.exec(STATEMENTS.discardReceivedMetadata, captureId); }); } - findReceived(id: string, worker?: string): StoredRoutingEmail | undefined { - const row = this.sql - .exec<{ data: string }>( - worker === undefined - ? STATEMENTS.received.find - : STATEMENTS.received.findForWorker, - ...(worker === undefined ? [id] : [id, worker]) - ) - .toArray()[0]; + #materialiseReceived( + row: { id: string; data: string } | undefined + ): StoredRoutingEmail | undefined { if (row === undefined) { return undefined; } const stored = JSON.parse(row.data) as unknown; - const bodyId = - typeof stored === "object" && - stored !== null && - "bodyId" in stored && - typeof stored.bodyId === "string" - ? stored.bodyId - : undefined; - if (bodyId === undefined) { - throw new Error(`Received email ${id} has no body identifier`); - } const bodies = this.sql .exec<{ part: number; raw_base64: string }>( STATEMENTS.findReceivedBodies, - bodyId + row.id ) .toArray(); const rawBase64 = bodies.find(({ part }) => part === 0)?.raw_base64; if (rawBase64 === undefined) { - throw new Error(`Received email ${id} has no captured body`); + throw new Error(`Received email ${row.id} has no captured body`); } + const metadata = zStoredRoutingEmailMetadata.parse(stored); return materialiseReceivedEmail( - zStoredRoutingEmailMetadata.parse(stored), + { + ...metadata, + captureId: row.id, + capturedPortion: + metadata.capturedPortion ?? metadata.captureTruncated === true, + }, rawBase64, new Map( bodies @@ -500,6 +540,65 @@ export class EmailStore extends DurableObject { ); } + findReceivedByCaptureId( + captureId: string, + worker: string + ): StoredRoutingEmail | undefined { + const row = this.sql + .exec<{ data: string }>(STATEMENTS.findReceivedCapture, captureId, worker) + .toArray()[0]; + return this.#materialiseReceived( + row === undefined ? undefined : { id: captureId, data: row.data } + ); + } + + findReceivedForOperation( + captureId: string, + worker: string + ): ReceivedCaptureOperationLookup { + const row = this.sql + .exec<{ data: string }>(STATEMENTS.findReceivedCapture, captureId, worker) + .toArray()[0]; + if (row === undefined) { + return { found: false }; + } + const metadata = zStoredRoutingEmailMetadata.parse(JSON.parse(row.data)); + try { + const email = this.#materialiseReceived({ + id: captureId, + data: row.data, + }); + if (email === undefined) { + return { found: false }; + } + return { + found: true, + capturedPortion: + email.capturedPortion ?? email.captureTruncated === true, + email, + }; + } catch { + return missingReceivedCaptureBody(metadata); + } + } + + findReceivedByMessageId( + messageId: string, + worker?: string + ): StoredRoutingEmail | undefined { + const row = this.sql + .exec<{ id: string; data: string }>( + worker === undefined + ? STATEMENTS.findReceivedMessage + : STATEMENTS.findReceivedMessageForWorker, + ...(worker === undefined + ? [messageIdToStorageId(messageId)] + : [messageIdToStorageId(messageId), worker]) + ) + .toArray()[0]; + return this.#materialiseReceived(row); + } + listReceived( cursor?: string, limit?: number, @@ -507,7 +606,15 @@ export class EmailStore extends DurableObject { ): EmailListPage { return this.#list( "received", - (data) => zStoredRoutingEmailSummary.parse(JSON.parse(data)), + (data, captureId) => { + const email = zStoredRoutingEmailListMetadata.parse(JSON.parse(data)); + return zStoredRoutingEmailSummary.parse({ + ...email, + captureId, + capturedPortion: + email.capturedPortion ?? email.captureTruncated === true, + }); + }, cursor, limit, worker @@ -535,7 +642,7 @@ export class EmailStore extends DurableObject { ): EmailListPage { return this.#list( "sent", - (data) => getSentSummary(zEmailSendingDetail.parse(JSON.parse(data))), + (data) => getSentSummary(zStoredSendingEmail.parse(JSON.parse(data))), cursor, limit, worker @@ -545,6 +652,7 @@ export class EmailStore extends DurableObject { clear(): void { this.ctx.storage.transactionSync(() => { this.sql.exec(STATEMENTS.clearReceivedBodies); + this.sql.exec(STATEMENTS.clearReceivedCaptureAttempts); this.sql.exec(STATEMENTS.clear); }); } diff --git a/packages/miniflare/src/workers/email/email-store.worker.ts b/packages/miniflare/src/workers/email/email-store.worker.ts index 6641c12c73c..79dd588ec3c 100644 --- a/packages/miniflare/src/workers/email/email-store.worker.ts +++ b/packages/miniflare/src/workers/email/email-store.worker.ts @@ -6,11 +6,8 @@ * loopback server (see email-store.ts for why that matters). */ import { WorkerEntrypoint } from "cloudflare:workers"; -import { - EmailStore, - zStoredRoutingEmail, - zStoredRoutingEmailSummary, -} from "./email-store"; +import { EmailStore } from "./email-store"; +import { zStoredRoutingEmail, zStoredRoutingEmailSummary } from "./storage"; import type { EmailListPage, StoredRoutingEmail, @@ -38,6 +35,10 @@ export default class EmailStoreHost extends WorkerEntrypoint { return await this.#store().getSourceId(); } + async beginReceivedCapture(captureId: string): Promise { + return await this.#store().beginReceivedCapture(captureId); + } + async storeReceivedBody( captureId: string, part: number, @@ -62,11 +63,35 @@ export default class EmailStoreHost extends WorkerEntrypoint { await this.#store().discardReceived(captureId); } - async findReceived( - id: string, + async findReceivedByCaptureId( + captureId: string, + worker: string + ): Promise { + const email = await this.#store().findReceivedByCaptureId( + captureId, + worker + ); + return email === undefined ? undefined : zStoredRoutingEmail.parse(email); + } + + async findReceivedForOperation(captureId: string, worker: string) { + const result = await this.#store().findReceivedForOperation( + captureId, + worker + ); + return result.found && result.email !== undefined + ? { ...result, email: zStoredRoutingEmail.parse(result.email) } + : result; + } + + async findReceivedByMessageId( + messageId: string, worker?: string ): Promise { - const email = await this.#store().findReceived(id, worker); + const email = await this.#store().findReceivedByMessageId( + messageId, + worker + ); return email === undefined ? undefined : zStoredRoutingEmail.parse(email); } diff --git a/packages/miniflare/src/workers/email/message-id.ts b/packages/miniflare/src/workers/email/message-id.ts index cfd97231295..78441279acf 100644 --- a/packages/miniflare/src/workers/email/message-id.ts +++ b/packages/miniflare/src/workers/email/message-id.ts @@ -5,6 +5,67 @@ const ID_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const MESSAGE_ID_DOMAIN_LABEL = + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/u; +const MESSAGE_ID_DOMAIN_LITERAL = + /^\[(?:[\x21-\x5a\x5e-\x7e]|\\[\x20-\x7e])+\]$/u; + +function extractTrailingDomainLiteral(senderEmail: string): string | undefined { + const literalStart = senderEmail.lastIndexOf("@["); + if (literalStart === -1) { + return undefined; + } + const literal = senderEmail.slice(literalStart + 1); + return MESSAGE_ID_DOMAIN_LITERAL.test(literal) ? literal : undefined; +} + +function normalizeDnsDomain(value: string): string | undefined { + if (value === "" || /[\s/:?#@\[\]\\<>%]/u.test(value)) { + return undefined; + } + let hostname: string; + try { + const url = new URL(`http://${value}`); + if ( + url.username !== "" || + url.password !== "" || + url.port !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" + ) { + return undefined; + } + hostname = url.hostname; + } catch { + return undefined; + } + + const unqualified = hostname.endsWith(".") ? hostname.slice(0, -1) : hostname; + if ( + hostname.length > 254 || + unqualified === "" || + !unqualified + .split(".") + .every((label) => MESSAGE_ID_DOMAIN_LABEL.test(label)) + ) { + return undefined; + } + return hostname; +} + +function getMessageIdDomain(senderEmail: string): string { + const literal = extractTrailingDomainLiteral(senderEmail); + if (literal !== undefined) { + return literal; + } + const separator = senderEmail.lastIndexOf("@"); + const domain = + separator === -1 + ? undefined + : normalizeDnsDomain(senderEmail.slice(separator + 1)); + return domain ?? "localhost"; +} /** * Builds a Message-ID in the shape the production `send_email` binding returns: @@ -16,7 +77,7 @@ export function synthesizeMessageId(senderEmail: string): string { bytes, (byte) => ID_ALPHABET[byte % ID_ALPHABET.length] ).join(""); - const domain = senderEmail.slice(senderEmail.lastIndexOf("@") + 1); + const domain = getMessageIdDomain(senderEmail); return `<${id}@${domain}>`; } @@ -41,48 +102,136 @@ export function setMessageIdHeader( throw new Error("could not find end of email headers"); } - const lineEnding = usesCrlf ? "\r\n" : "\n"; - const header = new TextDecoder().decode(rawEmail.subarray(0, headerEnd)); - const lines = header.split(/\r?\n/u); - const normalizedLines: string[] = []; - let foundMessageId = false; - let skippingContinuation = false; - - for (const line of lines) { - if (/^[ \t]/u.test(line)) { - if (!skippingContinuation) { - normalizedLines.push(line); + const fields = findHeaderFields(rawEmail, headerEnd); + const lastField = fields.at(-1); + if (lastField !== undefined) { + lastField.end += usesCrlf ? 2 : 1; + } + const messageIdFields = fields.filter(({ start, nameEnd }) => + asciiEqualsIgnoreCase(rawEmail.subarray(start, nameEnd), "message-id") + ); + const replacement = new TextEncoder().encode(`Message-ID: ${messageId}`); + if (messageIdFields.length === 0) { + const lineEnding = usesCrlf + ? new Uint8Array([13, 10]) + : new Uint8Array([10]); + return concatenateBytes([ + replacement, + ...(headerEnd === 0 ? [] : [lineEnding]), + rawEmail, + ]); + } + + const chunks: Uint8Array[] = []; + let retainedOffset = 0; + for (const [index, field] of messageIdFields.entries()) { + chunks.push(rawEmail.subarray(retainedOffset, field.start)); + if (index === 0) { + chunks.push(replacement); + const terminator = getFieldTerminator(rawEmail, field.start, field.end); + if (terminator !== undefined) { + chunks.push(terminator); } - continue; } + retainedOffset = field.end; + } + chunks.push(rawEmail.subarray(retainedOffset)); + return concatenateBytes(chunks); +} - skippingContinuation = /^message-id\s*:/iu.test(line); - if (skippingContinuation) { - if (!foundMessageId) { - normalizedLines.push(`Message-ID: ${messageId}`); - foundMessageId = true; +interface HeaderFieldRange { + start: number; + nameEnd: number; + end: number; +} + +function findHeaderFields( + rawEmail: Uint8Array, + headerEnd: number +): HeaderFieldRange[] { + const fields: HeaderFieldRange[] = []; + let hasActiveField = false; + let offset = 0; + while (offset < headerEnd) { + let lineEnd = offset; + while (lineEnd < headerEnd && rawEmail[lineEnd] !== 10) { + lineEnd++; + } + const contentEnd = + lineEnd > offset && rawEmail[lineEnd - 1] === 13 ? lineEnd - 1 : lineEnd; + const continuation = rawEmail[offset] === 32 || rawEmail[offset] === 9; + if (continuation) { + if (!hasActiveField) { + throw new Error("email header block contains an invalid continuation"); + } + } else { + const previous = fields.at(-1); + if (previous !== undefined && hasActiveField) { + previous.end = offset; + } + hasActiveField = false; + let colon = offset; + while (colon < contentEnd && rawEmail[colon] !== 58) { + colon++; + } + if (colon >= contentEnd) { + throw new Error("email header block contains an invalid field"); + } + let nameEnd = colon; + while ( + nameEnd > offset && + (rawEmail[nameEnd - 1] === 32 || rawEmail[nameEnd - 1] === 9) + ) { + nameEnd--; } - continue; + if (nameEnd === offset) { + throw new Error("email header block contains an invalid field name"); + } + fields.push({ start: offset, nameEnd, end: headerEnd }); + hasActiveField = true; + } + offset = lineEnd < headerEnd ? lineEnd + 1 : headerEnd; + } + return fields; +} + +function asciiEqualsIgnoreCase(bytes: Uint8Array, expected: string): boolean { + if (bytes.byteLength !== expected.length) { + return false; + } + for (let index = 0; index < bytes.byteLength; index++) { + const byte = bytes[index]; + const lower = byte >= 65 && byte <= 90 ? byte + 32 : byte; + if (lower !== expected.charCodeAt(index)) { + return false; } - normalizedLines.push(line); } + return true; +} - if (!foundMessageId) { - normalizedLines.unshift(`Message-ID: ${messageId}`); +function getFieldTerminator( + bytes: Uint8Array, + start: number, + end: number +): Uint8Array | undefined { + if (end > start && bytes[end - 1] === 10) { + return end - start >= 2 && bytes[end - 2] === 13 + ? bytes.subarray(end - 2, end) + : bytes.subarray(end - 1, end); } + return undefined; +} - const encodedHeaders = new TextEncoder().encode( - normalizedLines.join(lineEnding) +function concatenateBytes(chunks: Uint8Array[]): Uint8Array { + const result = new Uint8Array( + chunks.reduce((total, chunk) => total + chunk.byteLength, 0) ); - const separator = usesCrlf ? crlfSeparator : lfSeparator; - const body = rawEmail.subarray(headerEnd + separator.byteLength); - const normalizedEmail = new Uint8Array( - encodedHeaders.byteLength + separator.byteLength + body.byteLength - ); - normalizedEmail.set(encodedHeaders); - normalizedEmail.set(separator, encodedHeaders.byteLength); - normalizedEmail.set(body, encodedHeaders.byteLength + separator.byteLength); - return normalizedEmail; + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; } function findSequence(bytes: Uint8Array, sequence: Uint8Array): number { diff --git a/packages/miniflare/src/workers/email/mime.ts b/packages/miniflare/src/workers/email/mime.ts index edaeeebccb9..f1ee31fc20f 100644 --- a/packages/miniflare/src/workers/email/mime.ts +++ b/packages/miniflare/src/workers/email/mime.ts @@ -1,3 +1,4 @@ +import PostalMime from "postal-mime"; import { extractEmailAddress, formatEmailAddress } from "./address"; import { bytesToBase64 } from "./capture"; import { @@ -31,6 +32,327 @@ export interface MimeMessage { attachments?: MimeAttachment[]; } +interface ParsedMimeHeaders { + entries: Array<[string, string]>; + byName: Map; +} + +interface ParsedMimePart { + headers: ParsedMimeHeaders; + body: string; +} + +/** + * Projects MIME emitted by {@link buildMimeMessage} back into composer fields. + * Arbitrary MIME is intentionally rejected: provenance authorises projection, + * while this structural check prevents malformed captures becoming partial + * drafts. + */ +export async function projectComposerMime( + raw: Uint8Array +): Promise { + const source = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: false, + }).decode(raw); + const top = parseMimePart(source); + const parsed = await PostalMime.parse(raw); + const from = requireSingleHeader(top.headers, "from"); + const to = requireSingleHeader(top.headers, "to"); + const cc = getOptionalSingleHeader(top.headers, "cc"); + const replyTo = getOptionalSingleHeader(top.headers, "reply-to"); + const subject = requireSingleHeader(top.headers, "subject"); + requireSingleHeader(top.headers, "message-id"); + requireSingleHeader(top.headers, "date"); + requireSingleHeader(top.headers, "mime-version"); + const contentType = requireSingleHeader(top.headers, "content-type"); + if (parsed.from.address === undefined || parsed.to === undefined) { + throw new Error("composer MIME is missing required addresses"); + } + if (from === "" || to === "" || subject !== (parsed.subject ?? "")) { + throw new Error("composer MIME headers could not be projected"); + } + + // The composer writes validated address strings directly into these headers. + // Keep that text authoritative because parser normalization can alter quoted + // local parts; original To/Cc array boundaries are not retained in MIME. + const projected: MimeMessage = { + from, + to: [to], + subject: parsed.subject ?? "", + }; + if (cc !== undefined) { + projected.cc = [cc]; + } + if (replyTo !== undefined) { + projected.replyTo = replyTo; + } + + const customHeaders = Object.fromEntries( + top.headers.entries.filter( + ([name]) => !isManagedEmailHeaderName(name.toLowerCase()) + ) + ); + if (Object.keys(customHeaders).length > 0) { + projected.headers = customHeaders; + } + + const topType = parseParameterizedHeader(contentType); + let attachmentParts: ParsedMimePart[] = []; + if (topType.value === "multipart/mixed") { + const boundary = requireParameter(topType, "boundary"); + const parts = parseMultipart(top.body, boundary); + if (parts.length < 2) { + throw new Error("composer multipart/mixed structure is invalid"); + } + projectBodyPart(parts[0], projected); + attachmentParts = parts.slice(1); + } else { + projectBodyPart(top, projected); + } + + const parsedAttachments = parsed.attachments ?? []; + if (attachmentParts.length !== parsedAttachments.length) { + throw new Error("composer attachment count does not match parsed MIME"); + } + if (attachmentParts.length > 0) { + projected.attachments = attachmentParts.map((part, index) => { + const parserAttachment = parsedAttachments[index]; + if (parserAttachment === undefined) { + throw new Error("composer attachment order is invalid"); + } + return projectAttachment(part, parserAttachment); + }); + } + return projected; +} + +function parseMimePart(source: string): ParsedMimePart { + const separator = source.indexOf("\r\n\r\n"); + if (separator === -1) { + throw new Error("composer MIME has no header/body separator"); + } + return { + headers: parseMimeHeaders(source.slice(0, separator)), + body: source.slice(separator + 4), + }; +} + +function parseMimeHeaders(source: string): ParsedMimeHeaders { + const entries: Array<[string, string]> = []; + for (const line of source.split("\r\n")) { + if (/^[ \t]/u.test(line)) { + const previous = entries.at(-1); + if (previous === undefined) { + throw new Error("composer MIME has an invalid folded header"); + } + previous[1] += `\n${line.slice(1)}`; + continue; + } + const separator = line.indexOf(":"); + if (separator <= 0) { + throw new Error("composer MIME has an invalid header"); + } + entries.push([line.slice(0, separator), line.slice(separator + 1).trim()]); + } + const byName = new Map(); + for (const [name, value] of entries) { + const normalized = name.toLowerCase(); + byName.set(normalized, [...(byName.get(normalized) ?? []), value]); + } + return { entries, byName }; +} + +function requireSingleHeader(headers: ParsedMimeHeaders, name: string): string { + const values = headers.byName.get(name); + if (values?.length !== 1) { + throw new Error(`composer MIME requires one ${name} header`); + } + return values[0] ?? ""; +} + +function getOptionalSingleHeader( + headers: ParsedMimeHeaders, + name: string +): string | undefined { + const values = headers.byName.get(name); + if (values === undefined) { + return undefined; + } + if (values.length !== 1) { + throw new Error(`composer MIME allows at most one ${name} header`); + } + return values[0] ?? ""; +} + +function projectBodyPart(part: ParsedMimePart, projected: MimeMessage): void { + const contentType = parseParameterizedHeader( + requireSingleHeader(part.headers, "content-type") + ); + if (contentType.value === "text/plain") { + projected.text = part.body; + return; + } + if (contentType.value === "text/html") { + projected.html = part.body; + return; + } + if (contentType.value !== "multipart/alternative") { + throw new Error("composer MIME has an unsupported body structure"); + } + const parts = parseMultipart( + part.body, + requireParameter(contentType, "boundary") + ); + if (parts.length !== 2) { + throw new Error("composer multipart/alternative structure is invalid"); + } + const plainType = parseParameterizedHeader( + requireSingleHeader(parts[0]?.headers ?? part.headers, "content-type") + ).value; + const htmlType = parseParameterizedHeader( + requireSingleHeader(parts[1]?.headers ?? part.headers, "content-type") + ).value; + if (plainType !== "text/plain" || htmlType !== "text/html") { + throw new Error("composer multipart/alternative order is invalid"); + } + projected.text = parts[0]?.body; + projected.html = parts[1]?.body; +} + +function parseMultipart(source: string, boundary: string): ParsedMimePart[] { + const marker = `--${boundary}`; + if (!source.startsWith(`${marker}\r\n`)) { + throw new Error("composer MIME multipart preamble is invalid"); + } + const parts: ParsedMimePart[] = []; + let offset = marker.length + 2; + for (;;) { + const next = source.indexOf(`\r\n${marker}`, offset); + if (next === -1) { + throw new Error("composer MIME multipart terminator is missing"); + } + parts.push(parseMimePart(source.slice(offset, next))); + offset = next + 2 + marker.length; + if (source.startsWith("--", offset)) { + const epilogue = source.slice(offset + 2); + if (epilogue !== "" && epilogue !== "\r\n") { + throw new Error("composer MIME multipart epilogue is invalid"); + } + return parts; + } + if (!source.startsWith("\r\n", offset)) { + throw new Error("composer MIME multipart boundary is invalid"); + } + offset += 2; + } +} + +interface ParameterizedHeader { + value: string; + parameters: Map; +} + +function parseParameterizedHeader(value: string): ParameterizedHeader { + const segments = value.match(/(?:[^;"\\]|\\.|"(?:\\.|[^"])*")+/gu); + if (segments === null || segments.length === 0) { + throw new Error("composer MIME has an invalid parameterized header"); + } + const parameters = new Map(); + for (const segment of segments.slice(1)) { + const equals = segment.indexOf("="); + if (equals <= 0) { + throw new Error("composer MIME has an invalid header parameter"); + } + const name = segment.slice(0, equals).trim().toLowerCase(); + let parameter = segment.slice(equals + 1).trim(); + if (parameter.startsWith('"') && parameter.endsWith('"')) { + parameter = parameter.slice(1, -1).replace(/\\(["\\])/gu, "$1"); + } + if (parameters.has(name)) { + throw new Error("composer MIME has a duplicate header parameter"); + } + parameters.set(name, parameter); + } + return { value: segments[0]?.trim().toLowerCase() ?? "", parameters }; +} + +function requireParameter(header: ParameterizedHeader, name: string): string { + const value = header.parameters.get(name); + if (value === undefined || value === "") { + throw new Error(`composer MIME requires a ${name} parameter`); + } + return value; +} + +function projectAttachment( + part: ParsedMimePart, + parsed: NonNullable[number] +): MimeAttachment { + const contentType = parseParameterizedHeader( + requireSingleHeader(part.headers, "content-type") + ); + const disposition = parseParameterizedHeader( + requireSingleHeader(part.headers, "content-disposition") + ); + if ( + contentType.value === "" || + (disposition.value !== "inline" && disposition.value !== "attachment") || + requireSingleHeader( + part.headers, + "content-transfer-encoding" + ).toLowerCase() !== "base64" + ) { + throw new Error("composer attachment metadata is invalid"); + } + const contentTypeFilename = requireParameter(contentType, "name"); + const filename = requireParameter(disposition, "filename"); + const normalized = normalizeBase64(part.body); + if ( + normalized === undefined || + filename !== contentTypeFilename || + parsed.filename !== filename || + parsed.mimeType?.toLowerCase() !== contentType.value || + (parsed.disposition === "inline" ? "inline" : "attachment") !== + disposition.value + ) { + throw new Error("composer attachment does not match parsed MIME"); + } + const contentIdHeader = part.headers.byName.get("content-id"); + const contentId = contentIdHeader?.[0]; + if ((contentIdHeader?.length ?? 0) > 1) { + throw new Error("composer attachment has duplicate Content-ID headers"); + } + const normalizedContentId = contentId?.replace(/^<|>$/gu, ""); + const parsedContentId = parsed.contentId?.replace(/^<|>$/gu, ""); + if (normalizedContentId !== parsedContentId) { + throw new Error( + "composer attachment Content-ID does not match parsed MIME" + ); + } + const decoded = Uint8Array.from(atob(normalized), (character) => + character.charCodeAt(0) + ); + if (typeof parsed.content !== "string") { + const parserBytes = new Uint8Array(parsed.content); + if ( + parserBytes.byteLength !== decoded.byteLength || + parserBytes.some((byte, index) => byte !== decoded[index]) + ) { + throw new Error("composer attachment bytes do not match parsed MIME"); + } + } + return { + filename, + type: contentType.value, + disposition: disposition.value, + ...(normalizedContentId === undefined + ? {} + : { contentId: normalizedContentId }), + content: normalized, + }; +} + export function buildMimeMessage( message: MimeMessage, messageId: string, diff --git a/packages/miniflare/src/workers/email/received-capture.ts b/packages/miniflare/src/workers/email/received-capture.ts new file mode 100644 index 00000000000..88ab0a1a22d --- /dev/null +++ b/packages/miniflare/src/workers/email/received-capture.ts @@ -0,0 +1,60 @@ +import type { + EmailStoreService, + ReceivedCaptureOperationLookup, + StoredRoutingEmailMetadata, +} from "./storage"; + +type ReceivedCaptureStore = Pick< + EmailStoreService, + | "beginReceivedCapture" + | "discardReceived" + | "storeReceivedBody" + | "storeReceivedMetadata" +>; + +/** + * Commits a capture under a newly allocated UUID, publishing metadata only + * after every body row has been stored. + */ +export async function commitReceivedCapture( + store: ReceivedCaptureStore, + metadata: StoredRoutingEmailMetadata, + bodyRawBase64: string[], + createCaptureId: () => string = () => crypto.randomUUID() +): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const captureId = createCaptureId(); + if (!(await store.beginReceivedCapture(captureId))) { + continue; + } + try { + for (const [part, rawBase64] of bodyRawBase64.entries()) { + await store.storeReceivedBody(captureId, part, rawBase64); + } + await store.storeReceivedMetadata( + captureId, + bodyRawBase64.length, + metadata + ); + return captureId; + } catch (error) { + await store.discardReceived(captureId).catch(() => undefined); + throw error; + } + } + throw new Error("Failed to allocate a unique received email capture ID"); +} + +/** Preserves capture completeness when its separately stored MIME is missing. */ +export function missingReceivedCaptureBody( + metadata: Pick< + StoredRoutingEmailMetadata, + "capturedPortion" | "captureTruncated" + > +): ReceivedCaptureOperationLookup { + return { + found: true, + capturedPortion: + metadata.capturedPortion ?? metadata.captureTruncated === true, + }; +} diff --git a/packages/miniflare/src/workers/email/storage.ts b/packages/miniflare/src/workers/email/storage.ts index 0364b9fce8b..4de39418e0d 100644 --- a/packages/miniflare/src/workers/email/storage.ts +++ b/packages/miniflare/src/workers/email/storage.ts @@ -13,63 +13,106 @@ // `forward`/`reply` event (correlated by `messageId`). This lets consumers // render a timeline while still having the details on hand. -import type { - EmailAttachment, - EmailHandlerForward, - EmailHandlerReply, - EmailHandlerResult, - EmailRoutingDetail, - EmailRoutingItem, - EmailSendingDetail, - EmailSendingItem, +import { z } from "zod"; +import { + zEmailCaptureOrigin, + zEmailHeaders, + zEmailHandlerEvent, + zEmailHandlerForward, + zEmailHandlerReplyApi, } from "./contracts"; export type { + EmailCaptureOrigin, EmailHandlerEvent, EmailHandlerForward, EmailHandlerReply, EmailHandlerResult, } from "./contracts"; -interface StoredCaptureMetadata { - captureTruncated?: boolean; -} - -type StoredEmailHandlerReply = EmailHandlerReply & StoredCaptureMetadata; - -export type StoredRoutingEmail = Omit< - EmailRoutingDetail, - "forwards" | "replies" -> & - Omit & - StoredCaptureMetadata & { - replies: StoredEmailHandlerReply[]; - }; - -export type StoredRoutingEmailMetadata = Omit< - StoredRoutingEmail, - "raw" | "rawBase64" | "replies" -> & { - // Raw bodies are stored in separate rows, so the metadata record carries - // only reply envelope fields. - replies: Array< - Omit - >; -}; - -export type StoredRoutingEmailSummary = Omit< - EmailRoutingItem, - "forwards" | "replies" -> & { - forwards: EmailHandlerForward[]; - replies: Array>; -}; +const zStoredEmailReply = zEmailHandlerReplyApi.omit({ + raw: true, + rawBase64: true, +}); +const zStoredEmailReplyMetadata = zStoredEmailReply.extend({ + captureTruncated: z.boolean().optional(), +}); +const zStoredEmailAttachment = z.object({ + filename: z.string(), + contentType: z.string(), + disposition: z.enum(["inline", "attachment"]), + size: z.number(), +}); +const zStoredEmailBase = z.object({ + worker: z.string().optional(), + from: z.string(), + subject: z.string(), + messageId: z.string(), + attachments: z.array(zStoredEmailAttachment), +}); +export const zStoredRoutingEmailListMetadata = zStoredEmailBase.extend({ + worker: z.string(), + to: z.string(), + cc: z.array(z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), + headerEntries: zEmailHeaders.optional(), + receivedAt: z.string(), + rawSize: z.number(), + outcome: z.enum(["ok", "exception"]), + rejectReason: z.string().optional(), + forwards: z.array(zEmailHandlerForward), + replies: z.array(zStoredEmailReply), + events: z.array(zEmailHandlerEvent), + origin: zEmailCaptureOrigin.optional(), + captureTruncated: z.boolean().optional(), + capturedPortion: z.boolean().optional(), +}); +export const zStoredRoutingEmailSummary = + zStoredRoutingEmailListMetadata.extend({ captureId: z.uuid() }); +export const zStoredRoutingEmailMetadata = + zStoredRoutingEmailListMetadata.extend({ + replies: z.array(zStoredEmailReplyMetadata), + }); +export const zStoredRoutingEmail = zStoredRoutingEmailMetadata.extend({ + captureId: z.uuid(), + raw: z.string(), + rawBase64: z.string(), + replies: z.array( + zEmailHandlerReplyApi.extend({ + raw: z.string(), + rawBase64: z.string(), + captureTruncated: z.boolean().optional(), + }) + ), +}); +export const zStoredSendingEmailSummary = zStoredEmailBase.extend({ + to: z.array(z.string()), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + replyTo: z.string().optional(), + sentAt: z.string(), + headers: z.record(z.string(), z.string()).optional(), +}); +export const zStoredSendingEmail = zStoredSendingEmailSummary.extend({ + text: z.string().optional(), + html: z.string().optional(), + raw: z.string().optional(), + rawBase64: z.string().optional(), + captureTruncated: z.boolean().optional(), +}); -export type StoredEmailAttachment = EmailAttachment; - -export type StoredSendingEmail = EmailSendingDetail & StoredCaptureMetadata; - -export type StoredSendingEmailSummary = EmailSendingItem; +export type StoredEmailAttachment = z.infer; +export type StoredRoutingEmailSummary = z.infer< + typeof zStoredRoutingEmailSummary +>; +export type StoredRoutingEmailMetadata = z.infer< + typeof zStoredRoutingEmailMetadata +>; +export type StoredRoutingEmail = z.infer; +export type StoredSendingEmailSummary = z.infer< + typeof zStoredSendingEmailSummary +>; +export type StoredSendingEmail = z.infer; export interface EmailListPage { items: T[]; @@ -77,6 +120,14 @@ export interface EmailListPage { hasMore: boolean; } +export type ReceivedCaptureOperationLookup = + | { found: false } + | { + found: true; + capturedPortion: boolean; + email?: StoredRoutingEmail; + }; + /** * RPC surface of the email store host worker (see email-store.worker.ts). Used * to type the `SERVICE_EMAIL_STORE` service binding in the workers that @@ -85,6 +136,7 @@ export interface EmailListPage { */ export interface EmailStoreService { getSourceId(): Promise; + beginReceivedCapture(captureId: string): Promise; storeReceivedBody( captureId: string, part: number, @@ -96,9 +148,19 @@ export interface EmailStoreService { email: StoredRoutingEmailMetadata ): Promise; discardReceived(captureId: string): Promise; - /** Looks up a received email by local storage ID and optional worker. */ - findReceived( - id: string, + /** Looks up one exact received capture. */ + findReceivedByCaptureId( + captureId: string, + worker: string + ): Promise; + /** Loads an exact capture while preserving metadata if its MIME is absent. */ + findReceivedForOperation( + captureId: string, + worker: string + ): Promise; + /** Compatibility lookup for the newest received email with this Message-ID. */ + findReceivedByMessageId( + messageId: string, worker?: string ): Promise; listReceived( diff --git a/packages/miniflare/src/workers/local-explorer/email-contracts.ts b/packages/miniflare/src/workers/local-explorer/email-contracts.ts new file mode 100644 index 00000000000..46d07da24e6 --- /dev/null +++ b/packages/miniflare/src/workers/local-explorer/email-contracts.ts @@ -0,0 +1,15 @@ +import type { EmailRoutingItem } from "./generated"; + +/** Normalizes capability fields omitted by older Local Explorer peers. */ +export function normalizeEmailRoutingItemCapabilities( + item: EmailRoutingItem +): EmailRoutingItem & { + editAndResendAvailable: boolean; + capturedPortion: boolean; +} { + return { + ...item, + editAndResendAvailable: item.editAndResendAvailable ?? false, + capturedPortion: item.capturedPortion ?? false, + }; +} diff --git a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts index ad8efb308d8..4504da894da 100644 --- a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts +++ b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts @@ -3,6 +3,7 @@ import { Hono } from "hono/tiny"; import mime from "mime"; +import { z } from "zod"; import { CorePaths } from "../core"; import { fetchFromPeer, getPeerUrlsIfAggregating } from "./aggregation"; import { errorResponse, validateQuery, validateRequestBody } from "./common"; @@ -30,9 +31,12 @@ import { listD1Databases, rawD1Database } from "./resources/d1"; import { listDONamespaces, listDOObjects, queryDOSqlite } from "./resources/do"; import { getReceivedEmail, + getReceivedEmailByCaptureId, + getResendDraft, getSentEmail, listReceivedEmails, listSentEmails, + resendCapturedEmail, sendTestEmail, } from "./resources/email"; import { @@ -399,14 +403,58 @@ app.post("/api/local/observability/clear", (c) => clearTraces(c)); // Email Endpoints // ============================================================================ +const zEmailRoutingQuery = zEmailListRoutingData.shape.query + .unwrap() + .extend({ capture_id: z.uuid().optional() }) + .superRefine((query, context) => { + if (query.capture_id !== undefined && query.email_id !== undefined) { + context.addIssue({ + code: "custom", + message: "capture_id and email_id are mutually exclusive", + }); + } + if ( + query.capture_id !== undefined && + (query.worker === undefined || query.worker.trim() === "") + ) { + context.addIssue({ + code: "custom", + path: ["worker"], + message: "Worker is required with capture_id", + }); + } + }); + +const zEmailCaptureOperationQuery = z.object({ + worker: z.string().trim().min(1), + capture_id: z.uuid(), +}); + +app.get("/api/local/email/routing", validateQuery(zEmailRoutingQuery), (c) => { + const query = c.req.valid("query"); + if (query.capture_id !== undefined) { + return getReceivedEmailByCaptureId(c, query.capture_id, query.worker ?? ""); + } + return query.email_id === undefined + ? listReceivedEmails(c, query) + : getReceivedEmail(c, query.email_id, query.worker); +}); + +app.post( + "/api/local/email/routing/resend", + validateQuery(zEmailCaptureOperationQuery), + (c) => { + const query = c.req.valid("query"); + return resendCapturedEmail(c, query.worker, query.capture_id); + } +); + app.get( - "/api/local/email/routing", - validateQuery(zEmailListRoutingData.shape.query.unwrap()), + "/api/local/email/routing/resend/draft", + validateQuery(zEmailCaptureOperationQuery), (c) => { const query = c.req.valid("query"); - return query.email_id === undefined - ? listReceivedEmails(c, query) - : getReceivedEmail(c, query.email_id, query.worker); + return getResendDraft(c, query.worker, query.capture_id); } ); diff --git a/packages/miniflare/src/workers/local-explorer/generated/index.ts b/packages/miniflare/src/workers/local-explorer/generated/index.ts index d636ff44abf..44af14446b2 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/index.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/index.ts @@ -61,6 +61,16 @@ export type { EmailListSendingErrors, EmailListSendingResponse, EmailListSendingResponses, + EmailResendDraftRoutingData, + EmailResendDraftRoutingError, + EmailResendDraftRoutingErrors, + EmailResendDraftRoutingResponse, + EmailResendDraftRoutingResponses, + EmailResendRoutingData, + EmailResendRoutingError, + EmailResendRoutingErrors, + EmailResendRoutingResponse, + EmailResendRoutingResponses, EmailRoutingDetail, EmailRoutingItem, EmailSendingDetail, 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 e4970fcc632..0fd01676921 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts @@ -851,7 +851,7 @@ export type EmailBase = { from: string; subject: string; /** - * RFC Message-ID header value. Identifies the email in the store. + * RFC Message-ID header value carried by the email. */ messageId: string; /** @@ -862,7 +862,7 @@ export type EmailBase = { export type EmailRoutingItem = { /** - * Worker associated with the email, if known. + * Worker that handled this captured delivery. */ worker?: string; /** @@ -871,13 +871,26 @@ export type EmailRoutingItem = { from: string; subject: string; /** - * RFC Message-ID header value. Identifies the email in the store. + * RFC Message-ID header value. This is message content and compatibility lookup material; captureId identifies the Routing record. */ messageId: string; /** * Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME. */ attachments: Array; + /** + * Opaque identifier for this exact captured delivery. + */ + captureId?: string; + /** + * Whether this capture can be projected into the email composer. + */ + editAndResendAvailable?: boolean; + editAndResendUnavailableReason?: string; + /** + * Whether this capture contains only a portion of the original message. + */ + capturedPortion?: boolean; /** * Envelope RCPT TO address. */ @@ -906,23 +919,24 @@ export type EmailRoutingItem = { }; export type EmailRoutingDetail = { - /** - * Worker associated with the email, if known. - */ - worker?: string; + worker: string; /** * Envelope MAIL FROM address. */ from: string; subject: string; /** - * RFC Message-ID header value. Identifies the email in the store. + * RFC Message-ID header value. This is message content and compatibility lookup material; captureId identifies the Routing record. */ messageId: string; /** * Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME. */ attachments: Array; + captureId: string; + editAndResendAvailable: boolean; + editAndResendUnavailableReason?: string; + capturedPortion: boolean; /** * Envelope RCPT TO address. */ @@ -1044,7 +1058,7 @@ export type EmailSendingItem = { from: string; subject: string; /** - * RFC Message-ID header value. Identifies the email in the store. + * RFC Message-ID header value that identifies this Sending record for detail lookup. */ messageId: string; /** @@ -1072,7 +1086,7 @@ export type EmailSendingDetail = { from: string; subject: string; /** - * RFC Message-ID header value. Identifies the email in the store. + * RFC Message-ID header value that identifies this Sending record for detail lookup. */ messageId: string; /** @@ -1796,9 +1810,13 @@ export type EmailListRoutingData = { */ worker?: string; /** - * Return the details for this email instead of a paginated list. + * Compatibility lookup by RFC Message-ID. Returns the newest match and accepts bracketed or bracket-stripped values. */ email_id?: string; + /** + * Canonical identifier for one captured delivery. Requires `worker` and never falls back to Message-ID lookup. + */ + capture_id?: string; /** * Opaque cursor for the next page of emails. */ @@ -1839,6 +1857,87 @@ export type EmailListRoutingResponses = { export type EmailListRoutingResponse = EmailListRoutingResponses[keyof EmailListRoutingResponses]; +export type EmailResendRoutingData = { + body?: never; + path?: never; + query: { + /** + * Worker that owns the exact Routing capture. + */ + worker: string; + /** + * Opaque identifier for the exact captured delivery. + */ + capture_id: string; + }; + url: "/local/email/routing/resend"; +}; + +export type EmailResendRoutingErrors = { + /** + * Email resend failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type EmailResendRoutingError = + EmailResendRoutingErrors[keyof EmailResendRoutingErrors]; + +export type EmailResendRoutingResponses = { + /** + * Email resend result. + */ + 200: WorkersApiResponseCommon & { + result?: { + messageId: string; + outcome: "ok" | "exception"; + rejectReason?: string; + capturedPortion: boolean; + }; + }; +}; + +export type EmailResendRoutingResponse = + EmailResendRoutingResponses[keyof EmailResendRoutingResponses]; + +export type EmailResendDraftRoutingData = { + body?: never; + path?: never; + query: { + /** + * Worker that owns the exact Routing capture. + */ + worker: string; + /** + * Opaque identifier for the exact captured delivery. + */ + capture_id: string; + }; + url: "/local/email/routing/resend/draft"; +}; + +export type EmailResendDraftRoutingErrors = { + /** + * Composer projection failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type EmailResendDraftRoutingError = + EmailResendDraftRoutingErrors[keyof EmailResendDraftRoutingErrors]; + +export type EmailResendDraftRoutingResponses = { + /** + * Composer projection response. + */ + 200: WorkersApiResponseCommon & { + result?: EmailSendRequest; + }; +}; + +export type EmailResendDraftRoutingResponse = + EmailResendDraftRoutingResponses[keyof EmailResendDraftRoutingResponses]; + export type EmailSendRoutingData = { body: EmailSendRequest; path?: never; 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 e7056d5fe12..0da05e152e5 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts @@ -611,6 +611,15 @@ export const zEmailRoutingItem = z.object({ subject: z.string(), messageId: z.string(), attachments: z.array(zEmailAttachment), + captureId: z + .uuid() + .regex( + /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/ + ) + .optional(), + editAndResendAvailable: z.boolean().optional(), + editAndResendUnavailableReason: z.string().optional(), + capturedPortion: z.boolean().optional(), to: z.string(), cc: z.array(z.string()).optional(), headers: z.record(z.string(), z.string()).optional(), @@ -625,11 +634,19 @@ export const zEmailRoutingItem = z.object({ }); export const zEmailRoutingDetail = z.object({ - worker: z.string().optional(), + worker: z.string(), from: z.string(), subject: z.string(), messageId: z.string(), attachments: z.array(zEmailAttachment), + captureId: z + .uuid() + .regex( + /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/ + ), + editAndResendAvailable: z.boolean(), + editAndResendUnavailableReason: z.string().optional(), + capturedPortion: z.boolean(), to: z.string(), cc: z.array(z.string()).optional(), headers: z.record(z.string(), z.string()).optional(), @@ -1086,6 +1103,7 @@ export const zEmailListRoutingData = z.object({ .object({ worker: z.string().optional(), email_id: z.string().optional(), + capture_id: z.uuid().optional(), cursor: z.string().optional(), per_page: z.int().gte(1).lte(100).optional().default(25), }) @@ -1111,6 +1129,49 @@ export const zEmailListRoutingResponse = zWorkersApiResponseCommon.and( }) ); +export const zEmailResendRoutingData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.object({ + worker: z.string().min(1), + capture_id: z.uuid(), + }), +}); + +/** + * Email resend result. + */ +export const zEmailResendRoutingResponse = zWorkersApiResponseCommon.and( + z.object({ + result: z + .object({ + messageId: z.string(), + outcome: z.enum(["ok", "exception"]), + rejectReason: z.string().optional(), + capturedPortion: z.boolean(), + }) + .optional(), + }) +); + +export const zEmailResendDraftRoutingData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.object({ + worker: z.string().min(1), + capture_id: z.uuid(), + }), +}); + +/** + * Composer projection response. + */ +export const zEmailResendDraftRoutingResponse = zWorkersApiResponseCommon.and( + z.object({ + result: zEmailSendRequest.optional(), + }) +); + export const zEmailSendRoutingData = z.object({ body: zEmailSendRequest, path: z.never().optional(), diff --git a/packages/miniflare/src/workers/local-explorer/openapi.local.json b/packages/miniflare/src/workers/local-explorer/openapi.local.json index d0d72a1bb32..42229989e4a 100644 --- a/packages/miniflare/src/workers/local-explorer/openapi.local.json +++ b/packages/miniflare/src/workers/local-explorer/openapi.local.json @@ -1284,7 +1284,7 @@ }, "/local/email/routing": { "get": { - "description": "Lists emails received by any email() handler during this dev session. Use the optional `worker` query parameter to filter by worker, or `email_id` to return one email's details.", + "description": "Lists emails received by any email() handler during this dev session. Use `capture_id` with `worker` for canonical exact-capture details, or `email_id` for compatibility Message-ID lookup. The two identifiers are mutually exclusive.", "operationId": "email-list-routing", "parameters": [ { @@ -1301,7 +1301,16 @@ "schema": { "type": "string" }, - "description": "Return the details for this email instead of a paginated list." + "description": "Compatibility lookup by RFC Message-ID. Returns the newest match and accepts bracketed or bracket-stripped values." + }, + { + "in": "query", + "name": "capture_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Canonical identifier for one captured delivery. Requires `worker` and never falls back to Message-ID lookup." }, { "in": "query", @@ -1388,6 +1397,154 @@ "tags": ["Email"] } }, + "/local/email/routing/resend": { + "post": { + "description": "Replays the stored bytes of one exact Routing capture to the same Worker's email() handler with a new Message-ID.", + "operationId": "email-resend-routing", + "parameters": [ + { + "in": "query", + "name": "worker", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Worker that owns the exact Routing capture." + }, + { + "in": "query", + "name": "capture_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Opaque identifier for the exact captured delivery." + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "outcome": { + "type": "string", + "enum": ["ok", "exception"] + }, + "rejectReason": { + "type": "string" + }, + "capturedPortion": { + "type": "boolean" + } + }, + "required": [ + "messageId", + "outcome", + "capturedPortion" + ] + } + } + } + ] + } + } + }, + "description": "Email resend result." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Email resend failure." + } + }, + "summary": "Resend Received Email", + "tags": ["Email"] + } + }, + "/local/email/routing/resend/draft": { + "get": { + "description": "Projects one complete composer-originated Routing capture back into structured composer fields.", + "operationId": "email-resend-draft-routing", + "parameters": [ + { + "in": "query", + "name": "worker", + "required": true, + "schema": { + "type": "string", + "minLength": 1 + }, + "description": "Worker that owns the exact Routing capture." + }, + { + "in": "query", + "name": "capture_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Opaque identifier for the exact captured delivery." + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "type": "object", + "properties": { + "result": { + "$ref": "#/components/schemas/email_send-request" + } + } + } + ] + } + } + }, + "description": "Composer projection response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Composer projection failure." + } + }, + "summary": "Get Received Email Resend Draft", + "tags": ["Email"] + } + }, "/local/email/routing/send": { "post": { "description": "Sends a test email to trigger the worker's email() handler. Only the first `to` address is used as the envelope recipient; any additional to and cc addresses appear only in the composed MIME headers. bcc addresses are accepted but, by convention, are not written into the composed message.", @@ -3945,7 +4102,7 @@ }, "messageId": { "type": "string", - "description": "RFC Message-ID header value. Identifies the email in the store." + "description": "RFC Message-ID header value carried by the email." }, "attachments": { "type": "array", @@ -3963,7 +4120,7 @@ "properties": { "worker": { "type": "string", - "description": "Worker associated with the email, if known." + "description": "Worker that handled this captured delivery." }, "from": { "type": "string", @@ -3974,7 +4131,7 @@ }, "messageId": { "type": "string", - "description": "RFC Message-ID header value. Identifies the email in the store." + "description": "RFC Message-ID header value. This is message content and compatibility lookup material; captureId identifies the Routing record." }, "attachments": { "type": "array", @@ -3983,6 +4140,23 @@ }, "description": "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME." }, + "captureId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Opaque identifier for this exact captured delivery." + }, + "editAndResendAvailable": { + "type": "boolean", + "description": "Whether this capture can be projected into the email composer." + }, + "editAndResendUnavailableReason": { + "type": "string" + }, + "capturedPortion": { + "type": "boolean", + "description": "Whether this capture contains only a portion of the original message." + }, "to": { "type": "string", "description": "Envelope RCPT TO address." @@ -4071,8 +4245,7 @@ "type": "object", "properties": { "worker": { - "type": "string", - "description": "Worker associated with the email, if known." + "type": "string" }, "from": { "type": "string", @@ -4083,7 +4256,7 @@ }, "messageId": { "type": "string", - "description": "RFC Message-ID header value. Identifies the email in the store." + "description": "RFC Message-ID header value. This is message content and compatibility lookup material; captureId identifies the Routing record." }, "attachments": { "type": "array", @@ -4092,6 +4265,20 @@ }, "description": "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME." }, + "captureId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "editAndResendAvailable": { + "type": "boolean" + }, + "editAndResendUnavailableReason": { + "type": "string" + }, + "capturedPortion": { + "type": "boolean" + }, "to": { "type": "string", "description": "Envelope RCPT TO address." @@ -4178,10 +4365,14 @@ } }, "required": [ + "worker", "from", "subject", "messageId", "attachments", + "captureId", + "editAndResendAvailable", + "capturedPortion", "to", "receivedAt", "rawSize", @@ -4315,7 +4506,7 @@ }, "messageId": { "type": "string", - "description": "RFC Message-ID header value. Identifies the email in the store." + "description": "RFC Message-ID header value that identifies this Sending record for detail lookup." }, "attachments": { "type": "array", @@ -4381,7 +4572,7 @@ }, "messageId": { "type": "string", - "description": "RFC Message-ID header value. Identifies the email in the store." + "description": "RFC Message-ID header value that identifies this Sending record for detail lookup." }, "attachments": { "type": "array", diff --git a/packages/miniflare/src/workers/local-explorer/resources/email.ts b/packages/miniflare/src/workers/local-explorer/resources/email.ts index 1f791ee5aca..19d23d5893e 100644 --- a/packages/miniflare/src/workers/local-explorer/resources/email.ts +++ b/packages/miniflare/src/workers/local-explorer/resources/email.ts @@ -1,16 +1,10 @@ -import PostalMime, { decodeWords } from "postal-mime"; +import PostalMime, { addressParser, decodeWords } from "postal-mime"; import { z } from "zod"; import { EMAIL_STORE_SERVICE_NAME } from "../../../plugins/core/constants"; import { CoreBindings, CorePaths } from "../../core"; import { handleEmail } from "../../core/email"; import { base64ToBytes, bytesToBase64 } from "../../email/capture"; -import { - zEmailHandlerResult, - zEmailRoutingDetail, - zEmailRoutingItem, - zEmailSendingDetail, - zEmailSendingItem, -} from "../../email/contracts"; +import { zEmailHandlerResult } from "../../email/contracts"; import { hasControlCharacters, hasInvalidHeaderValueCharacters, @@ -21,33 +15,94 @@ import { import { extractAddressFromString, messageIdToStorageId, + setMessageIdHeader, synthesizeMessageId, } from "../../email/message-id"; -import { buildMimeMessage } from "../../email/mime"; +import { buildMimeMessage, projectComposerMime } from "../../email/mime"; import { fetchFromPeer, getPeerEntrypoint, getPeerUrlsIfAggregating, } from "../aggregation"; import { errorResponse, wrapResponse } from "../common"; -import { zLocalExplorerListWorkersResponse } from "../generated/zod.gen"; -import type { - EmailRoutingItem, - EmailSendingItem, - EmailSendRequest, -} from "../../email/contracts"; +import { normalizeEmailRoutingItemCapabilities } from "../email-contracts"; +import { + zEmailRoutingDetail, + zEmailRoutingItem, + zEmailSendRequest, + zEmailSendingDetail, + zEmailSendingItem, + zLocalExplorerListWorkersResponse, +} from "../generated/zod.gen"; import type { EmailListPage, EmailStoreService, + ReceivedCaptureOperationLookup, StoredRoutingEmail, + StoredRoutingEmailSummary, } from "../../email/storage"; import type { AppContext } from "../common"; +import type { + EmailRoutingItem, + EmailSendingItem, + EmailSendRequest, +} from "../generated"; import type { zEmailListRoutingData } from "../generated/zod.gen"; const EMAIL_ERROR_NOT_FOUND = 10601; const EMAIL_ERROR_SEND_FAILED = 10602; const EMAIL_ERROR_PEER_UNAVAILABLE = 10603; const EMAIL_WARNING_CAPTURE_TRUNCATED = 10604; +const CAPTURED_PORTION_EDIT_REASON = + "This email contains only a captured portion of the original message and cannot be edited safely."; +const UNKNOWN_ORIGIN_EDIT_REASON = + "Only emails created by the test email composer can be edited and resent."; + +function getCapturedPortion(email: { + capturedPortion?: boolean; + captureTruncated?: boolean; +}): boolean { + return email.capturedPortion ?? email.captureTruncated === true; +} + +function getEditAndResendUnavailableReason(email: { + origin?: "composer" | "unknown"; + capturedPortion?: boolean; + captureTruncated?: boolean; +}): string | undefined { + if (getCapturedPortion(email)) { + return CAPTURED_PORTION_EDIT_REASON; + } + if (email.origin !== "composer") { + return UNKNOWN_ORIGIN_EDIT_REASON; + } + return undefined; +} + +function toPublicRoutingItem( + email: StoredRoutingEmailSummary +): EmailRoutingItem { + const { + origin, + captureTruncated: _captureTruncated, + capturedPortion: storedCapturedPortion, + ...item + } = email; + const capturedPortion = getCapturedPortion(email); + const editAndResendUnavailableReason = getEditAndResendUnavailableReason({ + origin, + capturedPortion: storedCapturedPortion, + captureTruncated: email.captureTruncated, + }); + return zEmailRoutingItem.parse({ + ...item, + capturedPortion, + editAndResendAvailable: editAndResendUnavailableReason === undefined, + ...(editAndResendUnavailableReason === undefined + ? {} + : { editAndResendUnavailableReason }), + }); +} function getEmailStore(c: AppContext): EmailStoreService { return c.env[CoreBindings.SERVICE_EMAIL_STORE]; @@ -622,6 +677,18 @@ function validateEmailRequest(body: EmailSendRequest): string | undefined { return undefined; } +function getFirstMailboxAddress(values: string[]): string { + try { + return ( + addressParser(values.join(", "), { flatten: true }).find( + (address) => address.address !== undefined && address.address !== "" + )?.address ?? "" + ); + } catch { + return ""; + } +} + type EmailListDescriptor = { resource: EmailCursorResource; basePath: string; @@ -637,13 +704,19 @@ type EmailListDescriptor = { const receivedEmailListDescriptor: EmailListDescriptor = { resource: "routing", basePath: "/local/email/routing", - itemSchema: zEmailRoutingItem, + itemSchema: zEmailRoutingItem.transform( + normalizeEmailRoutingItemCapabilities + ), async listStorePage(store, cursor, limit, worker) { using result = (await store.listReceived(cursor, limit, worker)) as Awaited< ReturnType > & Disposable; - return structuredClone(result); + const page = structuredClone(result); + return { + ...page, + items: page.items.map(toPublicRoutingItem), + }; }, }; @@ -785,23 +858,76 @@ export async function getReceivedEmail( worker?: string ): Promise { const store = getEmailStore(c); - using email = (await store.findReceived( - messageIdToStorageId(emailId), - worker - )) as (StoredRoutingEmail & Disposable) | undefined; + using email = (await store.findReceivedByMessageId(emailId, worker)) as + | (StoredRoutingEmail & Disposable) + | undefined; if (!email) { // The email may have been captured by a worker in another Miniflare // instance; look it up there before giving up. return getReceivedEmailFromPeers(c, emailId, worker); } - // When a worker is requested, only return the email if it belongs to it so - // selecting a worker never leaks another worker's messages. - if (worker !== undefined && email.worker !== worker) { - return getReceivedEmailFromPeers(c, emailId, worker); + return renderReceivedEmail(c, email); +} + +/** Returns one exact received capture after resolving its current Worker owner. */ +export async function getReceivedEmailByCaptureId( + c: AppContext, + captureId: string, + worker: string +): Promise { + if (!isLocalWorker(c, worker)) { + const ownerLookup = await findWorkerOwner( + c, + await getPeerUrlsIfAggregating(c), + worker + ); + if (ownerLookup.owner !== null) { + const params = new URLSearchParams({ + capture_id: captureId, + worker, + }); + const response = await fetchFromPeer( + ownerLookup.owner, + `/local/email/routing?${params}` + ); + return response ?? peerUnavailableResponse(worker); + } + if (ownerLookup.unavailable) { + return peerUnavailableResponse(worker); + } + return receivedCaptureNotFound(captureId); } + + using email = (await getEmailStore(c).findReceivedByCaptureId( + captureId, + worker + )) as (StoredRoutingEmail & Disposable) | undefined; + return email === undefined + ? receivedCaptureNotFound(captureId) + : renderReceivedEmail(c, email); +} + +function receivedCaptureNotFound(captureId: string): Response { + return errorResponse( + 404, + EMAIL_ERROR_NOT_FOUND, + `Email capture '${captureId}' not found.` + ); +} + +async function renderReceivedEmail( + c: AppContext, + email: StoredRoutingEmail +): Promise { // 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; + const { + origin, + captureTruncated, + capturedPortion: storedCapturedPortion, + replies: storedReplies, + ...storedEmail + } = email; let body: { text?: string; html?: string } = {}; if (email.rawBase64 !== undefined) { try { @@ -820,6 +946,23 @@ export async function getReceivedEmail( const decoded = { ...storedEmail, ...body, + capturedPortion: getCapturedPortion(email), + editAndResendAvailable: + getEditAndResendUnavailableReason(email) === undefined, + ...(getEditAndResendUnavailableReason({ + origin, + capturedPortion: storedCapturedPortion, + captureTruncated, + }) === undefined + ? {} + : { + editAndResendUnavailableReason: + getEditAndResendUnavailableReason({ + origin, + capturedPortion: storedCapturedPortion, + captureTruncated, + }) ?? "", + }), replies: storedReplies.map( ({ captureTruncated: _captureTruncated, ...reply }) => ({ ...reply, @@ -948,9 +1091,13 @@ async function deliverTestEmail( from: string; to: string; id: string; - mime: string; + mime: string | Uint8Array; worker: string; - } + }, + captureContext: { + origin?: "composer" | "unknown"; + capturedPortion?: boolean; + } = {} ): Promise { const { from, to, id, mime, worker } = email; @@ -979,7 +1126,8 @@ async function deliverTestEmail( // Hono's `executionCtx` and workerd's `ExecutionContext` differ only by // the `@cloudflare/workers-types` version in scope; `handleEmail` uses // only `waitUntil`, which both provide. - c.executionCtx as unknown as ExecutionContext + c.executionCtx as unknown as ExecutionContext, + captureContext ); } @@ -1031,7 +1179,7 @@ export async function sendTestEmail( } const from = extractAddressFromString(body.from); - const to = extractAddressFromString(body.to[0] ?? ""); + const to = getFirstMailboxAddress(body.to); if (!to) { return errorResponse(400, 10000, "At least one recipient is required."); @@ -1043,7 +1191,11 @@ export async function sendTestEmail( const id = messageIdToStorageId(messageId); const mime = buildMimeMessage(body, messageId); - const response = await deliverTestEmail(c, { from, to, id, mime, worker }); + const response = await deliverTestEmail( + c, + { from, to, id, mime, worker }, + { origin: "composer" } + ); if (response === undefined) { return errorResponse( 400, @@ -1097,6 +1249,278 @@ export async function sendTestEmail( ); } +function capturedPortionWarning() { + return { + code: EMAIL_WARNING_CAPTURE_TRUNCATED, + message: + "Only the captured portion of the original email was available for resend.", + }; +} + +function emailOperationError( + status: number, + code: number, + message: string, + capturedPortion: boolean +): Response { + return Response.json( + { + success: false, + errors: [{ code, message }], + messages: capturedPortion ? [capturedPortionWarning()] : [], + result: null, + }, + { status } + ); +} + +async function forwardEmailCaptureOperation( + c: AppContext, + worker: string, + captureId: string, + path: string, + method: "GET" | "POST" +): Promise { + if (isLocalWorker(c, worker)) { + return undefined; + } + const ownerLookup = await findWorkerOwner( + c, + await getPeerUrlsIfAggregating(c), + worker + ); + if (ownerLookup.owner !== null) { + const params = new URLSearchParams({ capture_id: captureId, worker }); + const response = await fetchFromPeer( + ownerLookup.owner, + `${path}?${params}`, + { method } + ); + return response ?? peerUnavailableResponse(worker); + } + return ownerLookup.unavailable + ? peerUnavailableResponse(worker) + : emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + `Worker '${worker}' is not available in this dev session.`, + false + ); +} + +async function loadReceivedCaptureForOperation( + c: AppContext, + worker: string, + captureId: string +): Promise { + using result = (await getEmailStore(c).findReceivedForOperation( + captureId, + worker + )) as ReceivedCaptureOperationLookup & Disposable; + return structuredClone(result); +} + +/** Projects one eligible received capture into the structured email composer. */ +export async function getResendDraft( + c: AppContext, + worker: string, + captureId: string +): Promise { + const forwarded = await forwardEmailCaptureOperation( + c, + worker, + captureId, + "/local/email/routing/resend/draft", + "GET" + ); + if (forwarded !== undefined) { + return forwarded; + } + + let lookup: ReceivedCaptureOperationLookup; + try { + lookup = await loadReceivedCaptureForOperation(c, worker, captureId); + } catch (error) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + `Stored email cannot be loaded: ${error instanceof Error ? error.message : String(error)}`, + false + ); + } + if (!lookup.found) { + return receivedCaptureNotFound(captureId); + } + const email = lookup.email; + if (email === undefined) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + "Stored email cannot be projected into the composer: captured MIME is unavailable.", + lookup.capturedPortion + ); + } + const capturedPortion = getCapturedPortion(email); + const unavailableReason = getEditAndResendUnavailableReason(email); + if (unavailableReason !== undefined) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + unavailableReason, + capturedPortion + ); + } + if (email.rawBase64 === undefined) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + "Stored email cannot be projected into the composer: captured MIME is unavailable.", + capturedPortion + ); + } + + try { + const projected = zEmailSendRequest.parse({ + ...(await projectComposerMime(base64ToBytes(email.rawBase64))), + bcc: [], + }); + return c.json(wrapResponse(projected)); + } catch (error) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + `Stored email cannot be projected into the composer: ${error instanceof Error ? error.message : String(error)}`, + capturedPortion + ); + } +} + +/** Replays one received capture to the same Worker's email handler. */ +export async function resendCapturedEmail( + c: AppContext, + worker: string, + captureId: string +): Promise { + const forwarded = await forwardEmailCaptureOperation( + c, + worker, + captureId, + "/local/email/routing/resend", + "POST" + ); + if (forwarded !== undefined) { + return forwarded; + } + + let lookup: ReceivedCaptureOperationLookup; + try { + lookup = await loadReceivedCaptureForOperation(c, worker, captureId); + } catch (error) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + `Stored email cannot be loaded: ${error instanceof Error ? error.message : String(error)}`, + false + ); + } + if (!lookup.found) { + return receivedCaptureNotFound(captureId); + } + const email = lookup.email; + if (email === undefined) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + "Stored email cannot be resent: captured MIME is unavailable.", + lookup.capturedPortion + ); + } + const capturedPortion = getCapturedPortion(email); + if (email.rawBase64 === undefined) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + "Stored email cannot be resent: captured MIME is unavailable.", + capturedPortion + ); + } + let mime: Uint8Array; + let messageId: string; + try { + const source = base64ToBytes(email.rawBase64); + messageId = synthesizeMessageId(extractAddressFromString(email.from)); + mime = setMessageIdHeader(source, messageId); + } catch (error) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + `Stored email cannot be resent: ${error instanceof Error ? error.message : String(error)}`, + capturedPortion + ); + } + + const response = await deliverTestEmail( + c, + { + from: email.from, + to: email.to, + id: messageIdToStorageId(messageId), + mime, + worker, + }, + { + origin: email.origin ?? "unknown", + capturedPortion, + } + ); + if (response === undefined) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + `Worker '${worker}' is not available in this dev session.`, + capturedPortion + ); + } + if (response.status >= 400 && response.status < 500) { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + (await response.text()) || "Stored email could not be delivered.", + capturedPortion + ); + } + const contentType = response.headers.get("Content-Type") ?? ""; + if (!contentType.includes("application/json")) { + await response.text(); + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + `Worker '${worker}' does not export an email() handler.`, + capturedPortion + ); + } + const result = zEmailHandlerResult.parse(await response.json()); + if (result.events.length === 1 && result.events[0]?.type === "unhandled") { + return emailOperationError( + 400, + EMAIL_ERROR_SEND_FAILED, + `Worker '${worker}' does not export an email() handler.`, + capturedPortion + ); + } + return c.json({ + ...wrapResponse({ + messageId, + outcome: result.outcome, + capturedPortion, + ...(result.rejectReason === undefined + ? {} + : { rejectReason: result.rejectReason }), + }), + messages: capturedPortion ? [capturedPortionWarning()] : [], + }); +} + export async function listSentEmails( c: AppContext, query: EmailListQuery diff --git a/packages/miniflare/src/workers/local-explorer/route-names.ts b/packages/miniflare/src/workers/local-explorer/route-names.ts index 90e0e318005..14de35f3926 100644 --- a/packages/miniflare/src/workers/local-explorer/route-names.ts +++ b/packages/miniflare/src/workers/local-explorer/route-names.ts @@ -34,6 +34,8 @@ const ROUTE_PATTERNS: [RegExp, string][] = [ [/^\/workflows$/, "workflows.list"], [/^\/local\/observability\/query$/, "observability.query"], [/^\/local\/observability\/clear$/, "observability.clear"], + [/^\/local\/email\/routing\/resend\/draft$/, "email.routing.resend.draft"], + [/^\/local\/email\/routing\/resend$/, "email.routing.resend"], [/^\/local\/email\/routing\/send$/, "email.routing.send"], [/^\/local\/email\/routing$/, "email.routing.list"], [/^\/local\/email\/sending$/, "email.sending.list"], diff --git a/packages/miniflare/test/plugins/local-explorer/email.spec.ts b/packages/miniflare/test/plugins/local-explorer/email.spec.ts index de99c08a7e4..afe14514ac3 100644 --- a/packages/miniflare/test/plugins/local-explorer/email.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/email.spec.ts @@ -21,8 +21,22 @@ import { MAX_EMAIL_ROW_VALUE_BYTES, MAX_PRODUCTION_EMAIL_BYTES, } from "../../../src/workers/email/capture"; +import { + setMessageIdHeader, + synthesizeMessageId, +} from "../../../src/workers/email/message-id"; +import { + buildMimeMessage, + projectComposerMime, +} from "../../../src/workers/email/mime"; +import { + commitReceivedCapture, + missingReceivedCaptureBody, +} from "../../../src/workers/email/received-capture"; +import { normalizeEmailRoutingItemCapabilities } from "../../../src/workers/local-explorer/email-contracts"; import { zEmailRoutingDetail, + zEmailRoutingItem, zEmailSendingDetail, zEmailListRoutingResponse, zEmailListSendingResponse, @@ -36,7 +50,10 @@ import { waitForWorkersInRegistry, } from "../../test-shared"; import { expectValidResponse } from "./helpers"; -import type { EmailStoreService } from "../../../src/workers/email/storage"; +import type { + EmailStoreService, + StoredRoutingEmailMetadata, +} from "../../../src/workers/email/storage"; import type { MiniflareOptions } from "miniflare"; const BASE_URL = `http://localhost${CorePaths.EXPLORER}/api`; @@ -119,27 +136,39 @@ async function storeReceivedEmail( subject: string; receivedAt?: string; text?: string; + origin?: "composer" | "unknown"; + capturedPortion?: boolean; + captureTruncated?: boolean; + raw?: string; } -): Promise { +): Promise { const store = (await instance._getProxyClient()).env[ CoreBindings.SERVICE_EMAIL_STORE ] as unknown as EmailStoreService; const captureId = crypto.randomUUID(); - const raw = [ - "From: sender@example.com", - "To: recipient@example.com", - `Message-ID: ${email.messageId}`, - `Subject: ${email.subject}`, - "Content-Type: text/plain", - "", - email.text ?? email.subject, - ].join("\r\n"); + const raw = + email.raw ?? + [ + "From: sender@example.com", + "To: recipient@example.com", + `Message-ID: ${email.messageId}`, + `Subject: ${email.subject}`, + "Content-Type: text/plain", + "", + email.text ?? email.subject, + ].join("\r\n"); + if (!(await store.beginReceivedCapture(captureId))) { + throw new Error("Expected a unique test capture ID"); + } await store.storeReceivedBody( captureId, 0, Buffer.from(raw).toString("base64") ); await store.storeReceivedMetadata(captureId, 1, { + origin: email.origin ?? "unknown", + capturedPortion: email.capturedPortion ?? email.captureTruncated === true, + ...(email.captureTruncated ? { captureTruncated: true } : {}), worker: email.worker, messageId: email.messageId, from: "sender@example.com", @@ -160,6 +189,7 @@ async function storeReceivedEmail( replies: [], events: [{ type: "received", timestamp: new Date().toISOString() }], }); + return captureId; } async function expectExplorerApiResponse( @@ -213,6 +243,30 @@ async function sendRoutingTestEmail( return messageId; } +async function findRoutingCaptureId( + instance: Miniflare, + worker: string, + messageId: string +): Promise { + const response = await dispatchExplorerApi( + instance, + `/local/email/routing?worker=${encodeURIComponent(worker)}` + ); + if (!response.ok) { + throw new Error(await response.text()); + } + const body = (await response.json()) as { + result: Array<{ captureId?: string; messageId: string }>; + }; + const captureId = body.result.find( + (email) => email.messageId === messageId + )?.captureId; + if (captureId === undefined) { + throw new Error(`Capture for ${messageId} was not listed`); + } + return captureId; +} + const EMAIL_WORKER = dedent /* javascript */ ` import { EmailMessage } from "cloudflare:email"; @@ -354,6 +408,400 @@ function emailPeerOptions( }; } +function createStoredRoutingMetadata(): StoredRoutingEmailMetadata { + return { + origin: "unknown", + capturedPortion: false, + worker: WORKER_NAME, + messageId: "", + from: "sender@example.com", + to: "recipient@example.com", + subject: "Capture helper", + attachments: [], + rawSize: 3, + receivedAt: "2026-01-01T00:00:00.000Z", + outcome: "ok", + forwards: [], + replies: [], + events: [{ type: "received", timestamp: "2026-01-01T00:00:00.000Z" }], + }; +} + +describe("email resend Message-ID replacement", () => { + test("normalizes capability flags omitted by an older peer", ({ expect }) => { + const olderPeerItem = zEmailRoutingItem.parse({ + worker: WORKER_NAME, + from: "sender@example.com", + to: "recipient@example.com", + subject: "Older peer", + messageId: "", + attachments: [], + receivedAt: "2026-01-01T00:00:00.000Z", + rawSize: 0, + outcome: "ok", + forwards: [], + replies: [], + events: [], + }); + expect(normalizeEmailRoutingItemCapabilities(olderPeerItem)).toMatchObject({ + editAndResendAvailable: false, + capturedPortion: false, + }); + }); + + test("retries UUID collisions without writing or cleaning colliding captures", async ({ + expect, + }) => { + const beginReceivedCapture = vi.fn( + async (captureId: string) => captureId === "unique-id" + ); + const storeReceivedBody = vi.fn(async () => undefined); + const storeReceivedMetadata = vi.fn(async () => undefined); + const discardReceived = vi.fn(async () => undefined); + const ids = ["collision-one", "collision-two", "unique-id"]; + const captureId = await commitReceivedCapture( + { + beginReceivedCapture, + storeReceivedBody, + storeReceivedMetadata, + discardReceived, + }, + createStoredRoutingMetadata(), + ["primary", "reply"], + () => ids.shift() ?? "unexpected" + ); + + expect(captureId).toBe("unique-id"); + expect(beginReceivedCapture).toHaveBeenCalledTimes(3); + expect(storeReceivedBody.mock.calls).toEqual([ + ["unique-id", 0, "primary"], + ["unique-id", 1, "reply"], + ]); + expect(storeReceivedMetadata).toHaveBeenCalledWith( + "unique-id", + 2, + expect.objectContaining({ messageId: "" }) + ); + expect(discardReceived).not.toHaveBeenCalled(); + }); + + test("cleans only a reserved failed attempt", async ({ expect }) => { + const failure = new Error("body write failed"); + const discardReceived = vi.fn(async () => undefined); + await expect( + commitReceivedCapture( + { + beginReceivedCapture: async () => true, + storeReceivedBody: async () => { + throw failure; + }, + storeReceivedMetadata: async () => undefined, + discardReceived, + }, + createStoredRoutingMetadata(), + ["primary"], + () => "failed-attempt" + ) + ).rejects.toBe(failure); + expect(discardReceived).toHaveBeenCalledExactlyOnceWith("failed-attempt"); + }); + + test("preserves captured-portion state when the primary body is missing", ({ + expect, + }) => { + expect( + missingReceivedCaptureBody({ + capturedPortion: true, + captureTruncated: false, + }) + ).toEqual({ found: true, capturedPortion: true }); + expect(missingReceivedCaptureBody({ captureTruncated: true })).toEqual({ + found: true, + capturedPortion: true, + }); + }); + + for (const { name, raw, expected } of [ + { + name: "inserts a missing CRLF header", + raw: "From: sender@example.com\r\n\r\nbody", + expected: + "Message-ID: \r\nFrom: sender@example.com\r\n\r\nbody", + }, + { + name: "replaces folded and duplicate headers", + raw: "From: sender@example.com\r\nMessage-ID: \r\n\tcontinued\r\nX-Test: retained\r\nmessage-id: \r\n\r\nbody Message-ID: retained", + expected: + "From: sender@example.com\r\nMessage-ID: \r\nX-Test: retained\r\n\r\nbody Message-ID: retained", + }, + { + name: "preserves LF line endings and an empty body", + raw: "From: sender@example.com\nMessage-ID: \n\n", + expected: + "From: sender@example.com\nMessage-ID: \n\n", + }, + ]) { + test(name, ({ expect }) => { + expect( + new TextDecoder().decode( + setMessageIdHeader( + new TextEncoder().encode(raw), + "" + ) + ) + ).toBe(expected); + }); + } + + test("preserves non-UTF-8 header and body bytes", ({ expect }) => { + const header = Buffer.from( + "X-Binary: \r\nMessage-ID: \r\n\r\n", + "latin1" + ); + header[10] = 0xff; + const body = Buffer.from([ + 0, 255, 77, 101, 115, 115, 97, 103, 101, 45, 73, 68, + ]); + const replaced = setMessageIdHeader( + new Uint8Array(Buffer.concat([header, body])), + "" + ); + expect(Buffer.from(replaced).subarray(-body.length)).toEqual(body); + expect(replaced).toContain(0xff); + }); + + test("rejects MIME without a header/body boundary", ({ expect }) => { + expect(() => + setMessageIdHeader( + new TextEncoder().encode("Message-ID: "), + "" + ) + ).toThrow("could not find end of email headers"); + }); + + test("rejects malformed lines outside the Message-ID field", ({ expect }) => { + expect(() => + setMessageIdHeader( + new TextEncoder().encode( + "Message-ID: \r\nnot-a-header\r\n\r\nbody" + ), + "" + ) + ).toThrow("invalid field"); + }); + + test("projects ordered duplicate-name attachments from their raw Base64", async ({ + expect, + }) => { + const messageId = ""; + const raw = buildMimeMessage( + { + from: '"Sender" ', + to: ["recipient@example.com"], + subject: "Projection", + text: "Plain", + html: "

HTML

", + headers: { "X-Custom": "value" }, + attachments: [ + { + filename: "same.bin", + type: "application/octet-stream", + disposition: "inline", + contentId: "inline-id", + content: "AAE=", + }, + { + filename: "same.bin", + type: "text/plain", + disposition: "attachment", + content: "dGV4dA==", + }, + ], + }, + messageId + ); + const projection = await projectComposerMime(new TextEncoder().encode(raw)); + expect(projection).toMatchObject({ + text: "Plain", + html: "

HTML

", + headers: { "X-Custom": "value" }, + attachments: [ + { + filename: "same.bin", + type: "application/octet-stream", + disposition: "inline", + contentId: "inline-id", + content: "AAE=", + }, + { + filename: "same.bin", + type: "text/plain", + disposition: "attachment", + content: "dGV4dA==", + }, + ], + }); + + for (const malformed of [ + raw.replace( + "Content-Transfer-Encoding: base64", + "Content-Transfer-Encoding: quoted-printable" + ), + raw.replace( + 'Content-Disposition: inline; filename="same.bin"', + 'Content-Disposition: inline; filename="different.bin"' + ), + raw.replace("\r\nAAE=\r\n", "\r\n***=\r\n"), + ]) { + await expect( + projectComposerMime(new TextEncoder().encode(malformed)) + ).rejects.toThrow(); + } + }); + + test("preserves address groups in projected composer fields", async ({ + expect, + }) => { + const raw = buildMimeMessage( + { + from: "sender@example.com", + to: ["Friends: a@example.com, b@example.com;"], + cc: ["Reviewers: c@example.com, d@example.com;"], + replyTo: "Replies: reply@example.com;", + subject: "Grouped addresses", + text: "Body", + }, + "" + ); + + await expect( + projectComposerMime(new TextEncoder().encode(raw)) + ).resolves.toMatchObject({ + to: ["Friends: a@example.com, b@example.com;"], + cc: ["Reviewers: c@example.com, d@example.com;"], + replyTo: "Replies: reply@example.com;", + }); + }); + + test("preserves composer address header text through projection", async ({ + expect, + }) => { + const addresses = { + from: '"last,first"@example.com', + to: ['"to,last"@example.com', "second@example.com"], + cc: ['"cc,last"@example.com', "copy@example.com"], + replyTo: '"reply,last"@example.com', + }; + const raw = buildMimeMessage( + { + ...addresses, + subject: "Address projection", + text: "Body", + }, + "" + ); + + const projection = await projectComposerMime(new TextEncoder().encode(raw)); + expect(projection).toMatchObject({ + from: addresses.from, + to: [addresses.to.join(", ")], + cc: [addresses.cc.join(", ")], + replyTo: addresses.replyTo, + }); + + const rebuilt = buildMimeMessage( + projection, + "" + ); + for (const header of ["From", "To", "Cc", "Reply-To"]) { + const originalHeader = raw + .split("\r\n") + .find((line) => line.startsWith(`${header}: `)); + if (originalHeader === undefined) { + throw new Error(`Missing ${header} header in test message`); + } + expect(rebuilt.split("\r\n")).toContain(originalHeader); + } + }); + + test("preserves multiline custom headers through projection", async ({ + expect, + }) => { + const raw = buildMimeMessage( + { + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Multiline header projection", + headers: { "X-Multiline": "first line\nsecond line" }, + text: "Body", + }, + "" + ); + + const projection = await projectComposerMime(new TextEncoder().encode(raw)); + expect(projection.headers).toEqual({ + "X-Multiline": "first line\nsecond line", + }); + + const rebuilt = buildMimeMessage( + projection, + "" + ); + expect(rebuilt).toContain("X-Multiline: first line\r\n second line"); + expect(rebuilt).not.toMatch(/^second line:/mu); + }); + + test("rejects duplicate optional composer address headers", async ({ + expect, + }) => { + const raw = buildMimeMessage( + { + from: "sender@example.com", + to: ["recipient@example.com"], + cc: ["copy@example.com"], + replyTo: "reply@example.com", + subject: "Duplicate headers", + text: "Body", + }, + "" + ); + + for (const header of ["Cc", "Reply-To"]) { + const malformed = raw.replace( + `${header}: `, + `${header}: duplicate@example.com\r\n${header}: ` + ); + await expect( + projectComposerMime(new TextEncoder().encode(malformed)) + ).rejects.toThrow(`at most one ${header.toLowerCase()} header`); + } + }); + + test("normalizes and preserves valid Message-ID domains", ({ expect }) => { + for (const [sender, domain] of [ + ["sender@example.com", "example.com"], + ['"local@part"@example.com', "example.com"], + ["sender@example.com.", "example.com."], + ["sender@例え.テスト", "xn--r8jz45g.xn--zckzah"], + ["sender@[127.0.0.1]", "[127.0.0.1]"], + ["sender@[IPv6:2001:db8::1]", "[IPv6:2001:db8::1]"], + ["sender@[relay@example]", "[relay@example]"], + ] as const) { + const messageId = synthesizeMessageId(sender); + expect(messageId).toMatch(/^<[A-Za-z0-9]{36}@/u); + expect(messageId.endsWith(`@${domain}>`)).toBe(true); + } + }); + + test("uses a safe Message-ID domain for unusable senders", ({ expect }) => { + for (const sender of ["<>", "sender@", "sender@not a domain"]) { + expect(synthesizeMessageId(sender)).toMatch( + /^<[A-Za-z0-9]{36}@localhost>$/u + ); + } + }); +}); + const NO_EMAIL_HANDLER_WORKER_NAME = "no-email-handler-worker"; const NO_EMAIL_HANDLER_WORKER = dedent /* javascript */ ` export default { @@ -402,6 +850,490 @@ describe("Local Explorer email API", () => { await disposeWithRetry(mf); }); + test("uses capture IDs for exact Routing lookup and keeps Message-ID compatibility", async ({ + expect, + }) => { + const messageId = ""; + const olderCaptureId = await storeReceivedEmail(mf, { + worker: WORKER_NAME, + messageId, + subject: "Older duplicate", + receivedAt: "2026-01-01T00:00:00.000Z", + }); + const newerCaptureId = await storeReceivedEmail(mf, { + worker: WORKER_NAME, + messageId, + subject: "Newer duplicate", + receivedAt: "2026-01-02T00:00:00.000Z", + }); + + for (const [captureId, subject] of [ + [olderCaptureId, "Older duplicate"], + [newerCaptureId, "Newer duplicate"], + ] as const) { + const response = await dispatchExplorerApi( + mf, + `/local/email/routing?${new URLSearchParams({ + capture_id: captureId, + worker: WORKER_NAME, + })}` + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + result: { captureId: string; subject: string }; + }; + expect(body.result).toMatchObject({ captureId, subject }); + } + + const compatibility = await dispatchExplorerApi( + mf, + `/local/email/routing?${new URLSearchParams({ email_id: messageId })}` + ); + expect(compatibility.status).toBe(200); + expect(await compatibility.json()).toMatchObject({ + result: { captureId: newerCaptureId, subject: "Newer duplicate" }, + }); + const store = (await mf._getProxyClient()).env[ + CoreBindings.SERVICE_EMAIL_STORE + ] as unknown as EmailStoreService; + expect(await store.beginReceivedCapture(olderCaptureId)).toBe(false); + await store.discardReceived(olderCaptureId); + const existing = await store.findReceivedByCaptureId( + olderCaptureId, + WORKER_NAME + ); + expect(existing?.subject).toBe("Older duplicate"); + + const invalidQueries = [ + `capture_id=${olderCaptureId}`, + `capture_id=${olderCaptureId}&worker=`, + `capture_id=not-a-uuid&worker=${WORKER_NAME}`, + `capture_id=${olderCaptureId}&email_id=${encodeURIComponent(messageId)}&worker=${WORKER_NAME}`, + ]; + for (const query of invalidQueries) { + const invalid = await dispatchExplorerApi( + mf, + `/local/email/routing?${query}` + ); + expect(invalid.status, await invalid.text()).toBe(400); + } + const emptyWorker = await dispatchExplorerApi( + mf, + "/local/email/routing?worker=" + ); + expect(emptyWorker.status, await emptyWorker.clone().text()).toBe(200); + expect(await emptyWorker.json()).toMatchObject({ result: [] }); + const mismatch = await dispatchExplorerApi( + mf, + `/local/email/routing?capture_id=${olderCaptureId}&worker=wrong-worker` + ); + expect(mismatch.status, await mismatch.text()).toBe(404); + }); + + test("projects composer captures and directly resends the exact capture", async ({ + expect, + }) => { + const attachmentContent = Buffer.from([0, 1, 127, 128, 255]).toString( + "base64" + ); + const request = { + from: '"Sender, Name" <"last,first"@example.com>', + to: ['"Friends": recipient@example.com, second@example.com;'], + cc: ["copy@example.com"], + bcc: ["hidden@example.com"], + replyTo: "reply@example.com", + subject: "Composer projection", + text: "Plain body", + html: "

HTML body

", + headers: { "X-Custom": "custom value" }, + attachments: [ + { + filename: "binary.dat", + type: "application/octet-stream", + content: attachmentContent, + disposition: "inline" as const, + contentId: "binary-id", + }, + ], + }; + const sendResponse = await dispatchExplorerApi( + mf, + `/local/email/routing/send?worker=${WORKER_NAME}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + } + ); + expect(sendResponse.status).toBe(200); + const send = (await sendResponse.json()) as { + result: { messageId: string }; + }; + const listResponse = await dispatchExplorerApi( + mf, + `/local/email/routing?worker=${WORKER_NAME}` + ); + const list = (await listResponse.json()) as { + result: Array<{ + captureId?: string; + messageId: string; + editAndResendAvailable?: boolean; + capturedPortion?: boolean; + }>; + }; + const source = list.result.find( + (email) => email.messageId === send.result.messageId + ); + expect(source).toMatchObject({ + captureId: expect.any(String), + editAndResendAvailable: true, + capturedPortion: false, + }); + const captureId = String(source?.captureId); + + const draftResponse = await dispatchExplorerApi( + mf, + `/local/email/routing/resend/draft?${new URLSearchParams({ + worker: WORKER_NAME, + capture_id: captureId, + })}` + ); + const draftBody = await draftResponse.text(); + expect(draftResponse.status, draftBody).toBe(200); + expect(JSON.parse(draftBody)).toMatchObject({ + result: { + ...request, + bcc: [], + attachments: [{ content: attachmentContent }], + }, + }); + + const resendResponse = await dispatchExplorerApi( + mf, + `/local/email/routing/resend?${new URLSearchParams({ + worker: WORKER_NAME, + capture_id: captureId, + })}`, + { method: "POST" } + ); + const resendBody = await resendResponse.text(); + expect(resendResponse.status, resendBody).toBe(200); + const resend = JSON.parse(resendBody) as { + result: { messageId: string; capturedPortion: boolean }; + }; + expect(resend.result).toMatchObject({ capturedPortion: false }); + expect(resend.result.messageId).not.toBe(send.result.messageId); + const sourceDetail = await dispatchExplorerApi( + mf, + `/local/email/routing?capture_id=${captureId}&worker=${WORKER_NAME}` + ); + expect(await sourceDetail.json()).toMatchObject({ + result: { + messageId: send.result.messageId, + to: "recipient@example.com", + }, + }); + }); + + test("resends a null reverse-path with a valid Message-ID", async ({ + expect, + }) => { + const sourceMessageId = ""; + const raw = [ + "From: postmaster@example.com", + "To: recipient@example.com", + `Message-ID: ${sourceMessageId}`, + "Subject: Null reverse-path", + "Content-Type: text/plain", + "", + "Body", + ].join("\r\n"); + const received = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?" + + new URLSearchParams({ + from: "<>", + to: "recipient@example.com", + format: "json", + }), + { method: "POST", body: raw } + ); + const receivedBody = await received.text(); + expect(received.status, receivedBody).toBe(200); + const captureId = await findRoutingCaptureId( + mf, + WORKER_NAME, + sourceMessageId + ); + + const response = await dispatchExplorerApi( + mf, + `/local/email/routing/resend?${new URLSearchParams({ + worker: WORKER_NAME, + capture_id: captureId, + })}`, + { method: "POST" } + ); + const responseBody = await response.text(); + expect(response.status, responseBody).toBe(200); + const result = JSON.parse(responseBody) as { + result: { messageId: string }; + }; + expect(result.result.messageId).toMatch(/^<[A-Za-z0-9]{36}@localhost>$/u); + + const detail = await dispatchExplorerApi( + mf, + `/local/email/routing?email_id=${encodeURIComponent(result.result.messageId)}` + ); + expect(await detail.json()).toMatchObject({ + result: { + from: "<>", + messageId: result.result.messageId, + }, + }); + }); + + test("keeps captured-portion state sticky across direct resend", async ({ + expect, + }) => { + const captureId = await storeReceivedEmail(mf, { + worker: WORKER_NAME, + messageId: "", + subject: "Partial source", + origin: "composer", + capturedPortion: true, + }); + const query = new URLSearchParams({ + worker: WORKER_NAME, + capture_id: captureId, + }); + const draft = await dispatchExplorerApi( + mf, + `/local/email/routing/resend/draft?${query}` + ); + expect(draft.status).toBe(400); + expect(await draft.json()).toMatchObject({ + messages: [{ code: 10604 }], + }); + + const resend = await dispatchExplorerApi( + mf, + `/local/email/routing/resend?${query}`, + { method: "POST" } + ); + const resendText = await resend.text(); + expect(resend.status, resendText).toBe(200); + const result = JSON.parse(resendText) as { + result: { messageId: string; capturedPortion: boolean }; + }; + expect(result.result.capturedPortion).toBe(true); + const descendant = await dispatchExplorerApi( + mf, + `/local/email/routing?email_id=${encodeURIComponent(result.result.messageId)}&worker=${WORKER_NAME}` + ); + expect(await descendant.json()).toMatchObject({ + result: { + capturedPortion: true, + editAndResendAvailable: false, + }, + }); + + const unknownCaptureId = await storeReceivedEmail(mf, { + worker: WORKER_NAME, + messageId: "", + subject: "Unknown source", + origin: "unknown", + }); + const unknownDraft = await dispatchExplorerApi( + mf, + `/local/email/routing/resend/draft?worker=${WORKER_NAME}&capture_id=${unknownCaptureId}` + ); + expect(unknownDraft.status).toBe(400); + expect(await unknownDraft.json()).toMatchObject({ + errors: [{ code: 10602, message: expect.stringContaining("composer") }], + messages: [], + }); + }); + + test("preserves resend outcomes and captures every attempted invocation", async ({ + expect, + }) => { + for (const mode of ["reject", "exception"] as const) { + const sendResponse = await dispatchExplorerApi( + mf, + `/local/email/routing/send?worker=${WORKER_NAME}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: ["recipient@example.com"], + subject: `Resend ${mode}`, + text: mode, + headers: { "X-Test-Mode": mode }, + }), + } + ); + const sent = (await sendResponse.json()) as { + result: { messageId: string }; + }; + const captureId = await findRoutingCaptureId( + mf, + WORKER_NAME, + sent.result.messageId + ); + const resendResponse = await dispatchExplorerApi( + mf, + `/local/email/routing/resend?worker=${WORKER_NAME}&capture_id=${captureId}`, + { method: "POST" } + ); + const resendText = await resendResponse.text(); + expect(resendResponse.status, resendText).toBe(200); + const resent = JSON.parse(resendText) as { + result: { + messageId: string; + outcome: string; + rejectReason?: string; + }; + }; + if (mode === "reject") { + expect(resent.result).toMatchObject({ + outcome: "ok", + rejectReason: "Rejected by test worker", + }); + } else { + expect(resent.result).toMatchObject({ outcome: "exception" }); + } + expect( + await findRoutingCaptureId(mf, WORKER_NAME, resent.result.messageId) + ).not.toBe(captureId); + } + + const missingHandlerCapture = await storeReceivedEmail(mf, { + worker: NO_EMAIL_HANDLER_WORKER_NAME, + messageId: "", + subject: "Missing handler resend", + }); + const missingHandler = await dispatchExplorerApi( + mf, + `/local/email/routing/resend?worker=${NO_EMAIL_HANDLER_WORKER_NAME}&capture_id=${missingHandlerCapture}`, + { method: "POST" } + ); + expect(missingHandler.status).toBe(400); + expect(await missingHandler.json()).toMatchObject({ + errors: [{ code: 10602, message: expect.stringContaining("email()") }], + }); + const missingHandlerList = await dispatchExplorerApi( + mf, + `/local/email/routing?worker=${NO_EMAIL_HANDLER_WORKER_NAME}` + ); + const missingHandlerBody = (await missingHandlerList.json()) as { + result: unknown[]; + }; + expect(missingHandlerBody.result).toHaveLength(2); + + const beforeUnavailable = await dispatchExplorerApi( + mf, + `/local/email/routing?worker=${WORKER_NAME}` + ); + const beforeUnavailableBody = (await beforeUnavailable.json()) as { + result: unknown[]; + }; + const unavailable = await dispatchExplorerApi( + mf, + `/local/email/routing/resend?worker=not-a-worker&capture_id=${crypto.randomUUID()}`, + { method: "POST" } + ); + expect(unavailable.status).toBe(400); + expect(await unavailable.json()).toMatchObject({ + errors: [ + { code: 10602, message: expect.stringContaining("not available") }, + ], + }); + const afterUnavailable = await dispatchExplorerApi( + mf, + `/local/email/routing?worker=${WORKER_NAME}` + ); + const afterUnavailableBody = (await afterUnavailable.json()) as { + result: unknown[]; + }; + expect(afterUnavailableBody.result).toHaveLength( + beforeUnavailableBody.result.length + ); + }); + + test("returns MIME validation errors for corrupt stored Base64", async ({ + expect, + }) => { + const store = (await mf._getProxyClient()).env[ + CoreBindings.SERVICE_EMAIL_STORE + ] as unknown as EmailStoreService; + const captureId = crypto.randomUUID(); + expect(await store.beginReceivedCapture(captureId)).toBe(true); + await store.storeReceivedBody(captureId, 0, "***not-base64***"); + await store.storeReceivedMetadata(captureId, 1, { + ...createStoredRoutingMetadata(), + worker: WORKER_NAME, + origin: "unknown", + capturedPortion: true, + messageId: "", + }); + const response = await dispatchExplorerApi( + mf, + `/local/email/routing/resend?worker=${WORKER_NAME}&capture_id=${captureId}`, + { method: "POST" } + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + errors: [ + { code: 10602, message: expect.stringContaining("cannot be resent") }, + ], + messages: [{ code: 10604 }], + }); + }); + + test("reports a complete replay separately from truncation of its new capture", async ({ + expect, + }) => { + const prefix = + [ + "From: sender@example.com", + "To: recipient@example.com", + "Message-ID: ", + "Subject: Capture boundary", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + ].join("\r\n") + "\r\n"; + const raw = prefix + "x".repeat(MAX_EMAIL_BODY_BYTES - prefix.length); + expect(Buffer.byteLength(raw)).toBe(MAX_EMAIL_BODY_BYTES); + const sourceCaptureId = await storeReceivedEmail(mf, { + worker: WORKER_NAME, + messageId: "", + subject: "Capture boundary", + origin: "composer", + raw, + }); + const response = await dispatchExplorerApi( + mf, + `/local/email/routing/resend?worker=${WORKER_NAME}&capture_id=${sourceCaptureId}`, + { method: "POST" } + ); + const responseText = await response.text(); + expect(response.status, responseText).toBe(200); + const resent = JSON.parse(responseText) as { + result: { messageId: string; capturedPortion: boolean }; + }; + expect(resent.result.capturedPortion).toBe(false); + const descendant = await dispatchExplorerApi( + mf, + `/local/email/routing?email_id=${encodeURIComponent(resent.result.messageId)}&worker=${WORKER_NAME}` + ); + expect(await descendant.json()).toMatchObject({ + result: { capturedPortion: true }, + messages: [{ code: 10604 }], + }); + }); + test("captures a sent EmailMessage with raw content", async ({ expect }) => { const raw = dedent` From: sender@example.com @@ -2268,6 +3200,59 @@ describe("Local Explorer email aggregation", () => { expect(unfilteredDetailResponse.status).toBe(500); }); + test("routes resend and draft to the current owner without capture fallback", async ({ + expect, + }) => { + const messageId = await sendRoutingTestEmail( + instanceB, + "email-b", + { subject: "Peer resend", text: "Peer resend body" }, + expect + ); + const captureId = await findRoutingCaptureId( + instanceA, + "email-b", + messageId + ); + const params = new URLSearchParams({ + worker: "email-b", + capture_id: captureId, + }); + const draft = await dispatchExplorerApi( + instanceA, + `/local/email/routing/resend/draft?${params}` + ); + expect(draft.status).toBe(200); + expect(await draft.json()).toMatchObject({ + result: { subject: "Peer resend", text: "Peer resend body" }, + }); + const resend = await dispatchExplorerApi( + instanceA, + `/local/email/routing/resend?${params}`, + { method: "POST" } + ); + expect(resend.status).toBe(200); + expect(await resend.json()).toMatchObject({ + result: { messageId: expect.not.stringMatching(messageId) }, + }); + + const absentParams = new URLSearchParams({ + worker: "email-b", + capture_id: crypto.randomUUID(), + }); + for (const [pathSuffix, method] of [ + ["resend/draft", "GET"], + ["resend", "POST"], + ] as const) { + const absent = await dispatchExplorerApi( + instanceA, + `/local/email/routing/${pathSuffix}?${absentParams}`, + { method } + ); + expect(absent.status, await absent.text()).toBe(404); + } + }); + test("reports unavailable peers for email lookups", async ({ expect }) => { const unavailableWorker = "email-unavailable"; const definitionPath = path.join(registryPath, unavailableWorker); @@ -2322,6 +3307,25 @@ describe("Local Explorer email aggregation", () => { message: `Worker '${unavailableWorker}' is temporarily unavailable in this dev session.`, }), ]); + + for (const [pathSuffix, method] of [ + ["resend/draft", "GET"], + ["resend", "POST"], + ] as const) { + const operation = await expectValidResponse( + await dispatchExplorerApi( + instanceA, + `/local/email/routing/${pathSuffix}?worker=${unavailableWorker}&capture_id=${crypto.randomUUID()}`, + { method } + ), + zWorkersApiResponseCommonFailure, + expect, + 502 + ); + expect(operation.errors).toEqual([ + expect.objectContaining({ code: 10603 }), + ]); + } } finally { unlinkSync(definitionPath); } diff --git a/packages/miniflare/test/plugins/local-explorer/telemetry.spec.ts b/packages/miniflare/test/plugins/local-explorer/telemetry.spec.ts index 6f2b28ad7ac..9b165e6af28 100644 --- a/packages/miniflare/test/plugins/local-explorer/telemetry.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/telemetry.spec.ts @@ -30,6 +30,14 @@ describe("getRouteName", () => { expect( getRouteName(`/cdn-cgi/local/explorer/api/storage/kv/namespaces`) ).toBe("kv.namespaces"); + expect( + getRouteName(`/cdn-cgi/local/explorer/api/local/email/routing/resend`) + ).toBe("email.routing.resend"); + expect( + getRouteName( + `/cdn-cgi/local/explorer/api/local/email/routing/resend/draft` + ) + ).toBe("email.routing.resend.draft"); }); test("returns unknown for unrecognized paths", ({ expect }) => {