diff --git a/.changeset/local-email-capture.md b/.changeset/local-email-capture.md new file mode 100644 index 00000000000..9c2e7e711f3 --- /dev/null +++ b/.changeset/local-email-capture.md @@ -0,0 +1,58 @@ +--- +"miniflare": minor +"wrangler": minor +--- + +Capture locally sent and received emails so you can inspect them during development. Emails stored in the user's project directory (or system temporary directory) are now stored using their message ID rather than a UUID. + +The email test harness result now includes a chronological list of handler events, so programmatic local email tests can assert on the order in which events occurred. + +Note that the file path logged by the `send_email` binding (the `send_email binding called with ...` log line) is now written asynchronously, so it may not exist immediately after `send()` resolves. When reading the logged file path immediately after awaiting `send()`, do not assume the file exists yet. + +When you send, reply to, or receive an email larger than 1 MiB, the email is delivered in full and a copy truncated to the first 1 MiB is captured for the Local Explorer (a warning is logged when truncation occurs). + +```ts +const result = await server.getWorker().email({ + from: "sender@example.com", + to: "inbox@example.com", + raw: [ + "From: Sender ", + "To: Inbox ", + "Message-ID: ", + "Subject: Test email", + "", + "Hello from the test harness", + ].join("\r\n"), +}); + +expect(result).toEqual({ + outcome: "ok", + forwards: [ + { + messageId: expect.any(String), + recipient: "archive@example.com", + headers: [{ name: "x-example", value: "example" }], + }, + ], + replies: [ + { + messageId: expect.any(String), + sender: "reply@example.com", + raw: expect.stringContaining("Thanks for your email"), + }, + ], + events: [ + { type: "received", timestamp: expect.any(String) }, + { + type: "forward", + timestamp: expect.any(String), + messageId: expect.any(String), + }, + { + type: "reply", + timestamp: expect.any(String), + messageId: expect.any(String), + }, + ], +}); +``` diff --git a/packages/miniflare/openapi-ts.config.ts b/packages/miniflare/openapi-ts.config.ts index a063e53af9b..f153d165838 100644 --- a/packages/miniflare/openapi-ts.config.ts +++ b/packages/miniflare/openapi-ts.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ // Keep these paths in sync with the prettier inputs in package.json (generate:types script) input: "src/workers/local-explorer/openapi.local.json", output: "src/workers/local-explorer/generated", - plugins: ["@hey-api/typescript", "zod"], + plugins: ["@hey-api/typescript", { name: "zod", compatibilityVersion: 4 }], parser: { patch: { schemas: { diff --git a/packages/miniflare/scripts/openapi-filter-config.ts b/packages/miniflare/scripts/openapi-filter-config.ts index d0968087c33..8a71cc1425e 100644 --- a/packages/miniflare/scripts/openapi-filter-config.ts +++ b/packages/miniflare/scripts/openapi-filter-config.ts @@ -628,6 +628,309 @@ const config = { }, }, + // Email endpoints (local-only, not pulling from upstream API) + "/local/email/routing": { + get: { + description: + "Lists emails received by the worker's email() handler during this dev session, or returns one email's details when `email_id` is provided.", + operationId: "email-list-routing", + parameters: [ + { + in: "query", + name: "worker", + schema: { type: "string" }, + description: + "Only return emails received by this worker's email() handler.", + }, + { + in: "query", + name: "email_id", + schema: { type: "string" }, + description: + "Return the details for this email instead of a paginated list.", + }, + { + in: "query", + name: "cursor", + schema: { type: "string" }, + description: "Opaque cursor for the next page of emails.", + }, + { + in: "query", + name: "per_page", + schema: { + type: "number", + minimum: 1, + maximum: 100, + default: 25, + }, + description: "Number of emails per page.", + }, + ], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + oneOf: [ + { + items: { + $ref: "#/components/schemas/email_routing-item", + }, + type: "array", + }, + { + $ref: "#/components/schemas/email_routing-detail", + }, + ], + }, + result_info: { + type: "object", + properties: { + count: { type: "number" }, + cursor: { type: "string" }, + per_page: { type: "number" }, + has_more: { type: "boolean" }, + }, + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "List received emails response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "List received emails failure.", + }, + }, + summary: "List Received Emails", + 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.", + operationId: "email-send-routing", + parameters: [ + { + in: "query", + name: "worker", + required: true, + schema: { type: "string" }, + description: + "Deliver the test email directly to this worker's email() handler. Required because a single dev port can serve multiple workers, so the target cannot be inferred from the recipient address.", + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/email_send-request", + }, + }, + }, + }, + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + type: "object", + properties: { + messageId: { + type: "string", + description: + "RFC Message-ID header value of the delivered test email.", + }, + 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.", + }, + }, + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "Send test email response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Send test email failure.", + }, + }, + summary: "Send Test Email", + tags: ["Email"], + }, + }, + "/local/email/clear": { + post: { + description: "Deletes all captured emails from this dev session.", + operationId: "email-clear", + responses: { + "200": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common", + }, + }, + }, + description: "Clear captured emails response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Clear captured emails failure.", + }, + }, + summary: "Clear Captured Emails", + tags: ["Email"], + }, + }, + "/local/email/sending": { + get: { + description: + "Lists emails sent through send_email bindings during this dev session, or returns one email's details when `email_id` is provided.", + operationId: "email-list-sending", + parameters: [ + { + in: "query", + name: "worker", + schema: { type: "string" }, + description: + "Only return emails sent through this worker's send_email bindings.", + }, + { + in: "query", + name: "email_id", + schema: { type: "string" }, + description: + "Return the details for this email instead of a paginated list.", + }, + { + in: "query", + name: "cursor", + schema: { type: "string" }, + description: "Opaque cursor for the next page of emails.", + }, + { + in: "query", + name: "per_page", + schema: { + type: "number", + minimum: 1, + maximum: 100, + default: 25, + }, + description: "Number of emails per page.", + }, + ], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + oneOf: [ + { + items: { + $ref: "#/components/schemas/email_sending-item", + }, + type: "array", + }, + { + $ref: "#/components/schemas/email_sending-detail", + }, + ], + }, + result_info: { + type: "object", + properties: { + count: { type: "number" }, + cursor: { type: "string" }, + per_page: { type: "number" }, + has_more: { type: "boolean" }, + }, + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "List sent emails response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "List sent emails failure.", + }, + }, + summary: "List Sent Emails", + tags: ["Email"], + }, + }, + // Workflows endpoints (local-only, not pulling from upstream API) "/workflows": { get: { @@ -1663,6 +1966,13 @@ const config = { }, description: "Workflow bindings", }, + sendEmail: { + type: "array", + items: { + $ref: "#/components/schemas/local-explorer_resource-binding", + }, + description: "Send Email bindings", + }, }, }, "local-explorer_resource-binding": { @@ -1867,6 +2177,299 @@ const config = { }, required: ["columns", "rows"], }, + + "email_handler-event": { + 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.", + oneOf: [ + { + type: "object", + properties: { + type: { + type: "string", + enum: ["received", "reject", "unhandled"], + description: "The kind of event.", + }, + timestamp: { + type: "string", + description: "ISO 8601 timestamp of when the event occurred.", + }, + }, + required: ["type", "timestamp"], + }, + { + type: "object", + properties: { + type: { + type: "string", + enum: ["forward", "reply"], + description: "The kind of event.", + }, + 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"], + }, + ], + discriminator: { propertyName: "type" }, + }, + "email_handler-forward": { + type: "object", + properties: { + messageId: { type: "string" }, + recipient: { + type: "string", + description: "Envelope recipient the message was forwarded to.", + }, + headers: { + type: "array", + description: "Headers added to the forwarded message.", + items: { + type: "object", + properties: { + name: { type: "string" }, + value: { type: "string" }, + }, + required: ["name", "value"], + }, + }, + }, + required: ["messageId", "recipient", "headers"], + }, + "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"], + }, + 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. Identifies the email in the store.", + }, + 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: ["messageId", "from", "subject", "attachments"], + }, + "email_routing-item": { + allOf: [ + { $ref: "#/components/schemas/email_base" }, + { + type: "object", + properties: { + to: { + type: "string", + description: "Envelope RCPT TO address.", + }, + 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: [ + "to", + "receivedAt", + "rawSize", + "outcome", + "forwards", + "replies", + "events", + ], + }, + ], + }, + "email_routing-detail": { + allOf: [ + { $ref: "#/components/schemas/email_routing-item" }, + { + type: "object", + properties: { + raw: { + type: "string", + description: "Raw MIME content of the received email.", + }, + rawBase64: { + type: "string", + description: + "Lossless base64 representation of the received MIME.", + }, + }, + required: ["raw"], + }, + ], + }, + "email_send-request": { + type: "object", + description: + "Fields for composing a test email, mirroring MessageBuilder.", + properties: { + from: { type: "string", description: "Sender address." }, + to: { + type: "array", + items: { type: "string" }, + minItems: 1, + 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", + 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.", + 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"], + }, + }, + }, + required: ["from", "to", "subject"], + }, + email_attachment: { + type: "object", + description: + "Metadata describing an attachment on a captured email, without its content.", + properties: { + filename: { type: "string" }, + contentType: { type: "string" }, + disposition: { + type: "string", + enum: ["inline", "attachment"], + }, + size: { type: "number" }, + }, + required: ["filename", "contentType", "disposition", "size"], + }, + "email_sending-item": { + allOf: [ + { $ref: "#/components/schemas/email_base" }, + { + type: "object", + properties: { + 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: ["to", "sentAt"], + }, + ], + }, + "email_sending-detail": { + allOf: [ + { $ref: "#/components/schemas/email_sending-item" }, + { + type: "object", + properties: { + 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.", + }, + }, + }, + ], + }, }, }, } satisfies FilterConfig; diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 7e21ba5f34a..813b92ad0b0 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1,7 +1,6 @@ import assert from "node:assert"; import crypto from "node:crypto"; import fs from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; import http from "node:http"; import net from "node:net"; import os from "node:os"; @@ -81,6 +80,7 @@ import { } from "./plugins/core"; import { InspectorProxyController } from "./plugins/core/inspector-proxy"; import { isModuleFallbackRequest } from "./plugins/core/module-fallback"; +import { writeTempFile } from "./plugins/core/temp-file"; import { HyperdriveProxyController } from "./plugins/hyperdrive/hyperdrive-proxy"; import { cfImageLocalFetcher, @@ -113,6 +113,7 @@ import { decodeErrorPayload, LogLevel, Mutex, + sanitisePath, SharedHeaders, SiteBindings, } from "./workers"; @@ -1094,6 +1095,90 @@ export class Miniflare { } } + /** + * Writes a request body to a temp file and responds with its on-disk path. + * + * By default the file is written to a single random path under this + * instance's temp directory. Callers use the reserved `email/` prefix namespace + * to select email destinations, which group files by session and mirror them + * into the project directory. + * + * @param url in format: /core/store-temp-file?prefix&extension[&id] + */ + async #handleLoopbackStoreTempFileRequest( + request: Request, + url: URL + ): Promise { + const extension = url.searchParams.get("extension") ?? "txt"; + const rawPrefix = url.searchParams.get("prefix"); + const emailPrefix = + rawPrefix !== null && rawPrefix.startsWith("email/") + ? rawPrefix.slice("email/".length) + : undefined; + const prefix = + emailPrefix !== undefined + ? `email/${emailPrefix}` + : rawPrefix + ? `files/${rawPrefix}` + : "files"; + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(extension)) { + return new Response("Invalid temporary-file extension", { status: 400 }); + } + const prefixParts = prefix.split("/"); + if ( + prefixParts.some( + (part) => + part.length === 0 || + part === "." || + part === ".." || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(part) + ) + ) { + return new Response("Invalid temporary-file prefix", { status: 400 }); + } + + const rawId = url.searchParams.get("id"); + const id = rawId === null ? crypto.randomUUID() : sanitisePath(rawId); + const fileName = `${id}.${extension}`; + const contents = new Uint8Array(await request.arrayBuffer()); + const filePath = await writeTempFile({ + tmpPath: this.#tmpPath, + prefix, + fileName, + contents, + }); + if (emailPrefix !== undefined) { + const emailPaths = getEmailPathsToClean( + this.#sharedOpts.resourceTmpPath, + this.#tmpPath + ); + if (emailPaths) { + return new Response( + await writeTempFile({ + tmpPath: emailPaths.sessionDir, + prefix: emailPrefix, + fileName, + contents, + }), + { status: 200 } + ); + } + } + return new Response(filePath, { status: 200 }); + } + + async #handleLoopbackClearEmailTempFilesRequest(): Promise { + await removeDir(path.join(this.#tmpPath, "email")); + const emailPaths = getEmailPathsToClean( + this.#sharedOpts.resourceTmpPath, + this.#tmpPath + ); + if (emailPaths) { + await removeDir(emailPaths.sessionDir); + } + return new Response(null, { status: 204 }); + } + /** * Gets DO object IDs by checking filenames in the DO persistence directory. * @@ -1448,16 +1533,12 @@ export class Miniflare { const sessionIds = this.#browserProcesses.keys(); response = Response.json(Array.from(sessionIds)); } else if (url.pathname === "/core/store-temp-file") { - const prefix = url.searchParams.get("prefix"); - const folder = prefix ? `files/${prefix}` : "files"; - await mkdir(path.join(this.#tmpPath, folder), { recursive: true }); - const filePath = path.join( - this.#tmpPath, - folder, - `${crypto.randomUUID()}.${url.searchParams.get("extension") ?? "txt"}` - ); - await writeFile(filePath, await request.text()); - response = new Response(filePath, { status: 200 }); + response = await this.#handleLoopbackStoreTempFileRequest(request, url); + } else if ( + url.pathname === "/core/clear-email-temp-files" && + request.method === "POST" + ) { + response = await this.#handleLoopbackClearEmailTempFilesRequest(); } else if (url.pathname.startsWith("/core/do-storage/")) { response = await this.#handleLoopbackDOStorageRequest(url); } else if (url.pathname.startsWith("/core/workflow-storage/")) { @@ -2083,6 +2164,7 @@ export class Miniflare { ) ? `${RPC_PROXY_SERVICE_NAME}:${this.#workerOpts[0].config.name}` : getUserServiceName(this.#workerOpts[0].config.name), + fallbackWorkerPublicName: this.#workerOpts[0].config.name, tmpPath: this.#tmpPath, log: this.#log, proxyBindings, diff --git a/packages/miniflare/src/plugins/core/constants.ts b/packages/miniflare/src/plugins/core/constants.ts index b4811ea26d9..d68cb230ec8 100644 --- a/packages/miniflare/src/plugins/core/constants.ts +++ b/packages/miniflare/src/plugins/core/constants.ts @@ -16,6 +16,12 @@ export const LOCAL_EXPLORER_DISK = `${CORE_PLUGIN_NAME}:local-explorer-disk`; // colon (it collides with the `core:user:` service namespacing). export const OBSERVABILITY_COLLECTOR_SERVICE_NAME = "miniflare-observability-collector"; +// Hosts the local email store Durable Object (see email-store.worker.ts). The +// send_email binding, the receiving `email()` path, and the local explorer all +// bind to this service to capture/read emails over workerd-internal RPC. +export const EMAIL_STORE_SERVICE_NAME = `email:store`; +// Disk service backing the EmailStore DO's SQLite storage. +export const EMAIL_STORE_DISK = `email:store-disk`; // Flags that make a user worker stream its tail (incl. user spans) to the // collector; applied to each user worker when observability is enabled export const OBSERVABILITY_COMPAT_FLAGS = [ diff --git a/packages/miniflare/src/plugins/core/explorer.ts b/packages/miniflare/src/plugins/core/explorer.ts index fbff9d5e6f2..52ed189504c 100644 --- a/packages/miniflare/src/plugins/core/explorer.ts +++ b/packages/miniflare/src/plugins/core/explorer.ts @@ -15,6 +15,7 @@ import { SERVICE_DEV_REGISTRY_PROXY, } from "../shared"; import { + EMAIL_STORE_SERVICE_NAME, getUserServiceName, LOCAL_EXPLORER_DISK, OBSERVABILITY_COLLECTOR_SERVICE_NAME, @@ -98,6 +99,18 @@ export function getExplorerServices( // workerdDebugPort bindings don't have any additional configuration workerdDebugPort: kVoid, }, + // The email store service is registered alongside the explorer (see the + // core plugin's getServices), so it's always available to read from here. + { + name: CoreBindings.SERVICE_EMAIL_STORE, + service: { name: EMAIL_STORE_SERVICE_NAME }, + }, + // Direct service bindings to each user worker in this instance. These let + // the explorer invoke a worker's handlers (e.g. `email()`. + ...workerNames.map((name) => ({ + name: `${CoreBindings.SERVICE_EXPLORER_USER_WORKER_PREFIX}${name}`, + service: { name: getUserServiceName(name) }, + })), ]; // Only bind the observability collector when observability is enabled — @@ -339,6 +352,7 @@ export function constructExplorerWorkerOpts( r2: [], do: [], workflows: [], + sendEmail: [], }; for (const [bindingName, binding] of getEnvBindingsOfType( @@ -396,6 +410,16 @@ export function constructExplorerWorkerOpts( }); } + for (const [bindingName] of getEnvBindingsOfType( + workerOpts.config, + "send-email" + ) ?? {}) { + bindings.sendEmail.push({ + id: bindingName, + bindingName, + }); + } + result[workerName] = bindings; } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index a402bf996f1..65b3cb83866 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -33,6 +33,7 @@ import { DURABLE_OBJECTS_STORAGE_SERVICE_NAME, getDurableObjectUniqueKey, } from "../do"; +import { getEmailStoreServices } from "../email/store"; import { IMAGES_PLUGIN_NAME } from "../images"; import { getR2PublicService, @@ -58,6 +59,7 @@ import { STREAM_PLUGIN_NAME } from "../stream"; import { CUSTOM_SERVICE_KNOWN_OUTBOUND, CustomServiceKind, + EMAIL_STORE_SERVICE_NAME, getBuiltinServiceName, getCustomFetchServiceName, getCustomNodeServiceName, @@ -751,6 +753,7 @@ export interface GlobalServicesOptions { sharedOptions: ParsedInstanceOptions; allWorkerRoutes: Map; fallbackWorkerName: string | undefined; + fallbackWorkerPublicName: string | undefined; tmpPath: string; log: Log; /** All user workerd-native bindings, used for Miniflare's magic proxy and the local explorer worker */ @@ -764,6 +767,7 @@ export function getGlobalServices({ sharedOptions, allWorkerRoutes, fallbackWorkerName, + fallbackWorkerPublicName, tmpPath, log, proxyBindings, @@ -792,6 +796,10 @@ export function getGlobalServices({ name: CoreBindings.SERVICE_USER_FALLBACK, service: { name: fallbackWorkerName }, }, + { + name: CoreBindings.TEXT_FALLBACK_WORKER_NAME, + json: JSON.stringify(fallbackWorkerPublicName ?? ""), + }, ...workerNames.map((name) => ({ name: CoreBindings.SERVICE_USER_ROUTE_PREFIX + name, service: { name: getUserServiceName(name) }, @@ -827,6 +835,12 @@ export function getGlobalServices({ name: SERVICE_LOCAL_EXPLORER, }, }); + // The entry worker runs the receiving `email()` path (see handleEmail), + // which captures received emails into the store over RPC. + serviceEntryBindings.push({ + name: CoreBindings.SERVICE_EMAIL_STORE, + service: { name: EMAIL_STORE_SERVICE_NAME }, + }); } const streamServiceEnabled = allWorkerOpts?.some((worker) => getEnvBindingsOfType(worker.config, "stream").some( @@ -1018,6 +1032,7 @@ export function getGlobalServices({ observabilityEnabled: sharedOptions.unsafeObservability === true, }) ); + services.push(...getEmailStoreServices(tmpPath)); } // Register the trace collector service. It's attached to each user worker's diff --git a/packages/miniflare/src/plugins/core/temp-file.ts b/packages/miniflare/src/plugins/core/temp-file.ts new file mode 100644 index 00000000000..b16d363a4e8 --- /dev/null +++ b/packages/miniflare/src/plugins/core/temp-file.ts @@ -0,0 +1,38 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** + * Writes content under a caller-provided relative prefix and returns the path. + * + * Callers own the destination layout. This keeps the helper usable for regular, + * email, and other temp-file consumers without embedding product-specific rules. + */ +export async function writeTempFile(options: { + tmpPath: string; + prefix: string; + fileName: string; + contents: string | Uint8Array; +}): Promise { + const prefixParts = options.prefix.split("/"); + if ( + prefixParts.some( + (part) => + part.length === 0 || + part === "." || + part === ".." || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(part) + ) + ) { + throw new Error("Invalid temporary-file prefix"); + } + + const directory = path.join(options.tmpPath, ...prefixParts); + await mkdir(directory, { recursive: true }); + const root = path.resolve(directory); + const filePath = path.resolve(root, options.fileName); + if (filePath === root || !filePath.startsWith(`${root}${path.sep}`)) { + throw new Error("Invalid temporary-file path"); + } + await writeFile(filePath, options.contents); + return filePath; +} diff --git a/packages/miniflare/src/plugins/core/types.ts b/packages/miniflare/src/plugins/core/types.ts index f85c0d5fdeb..e6c91c29937 100644 --- a/packages/miniflare/src/plugins/core/types.ts +++ b/packages/miniflare/src/plugins/core/types.ts @@ -51,6 +51,11 @@ export type WorkerResourceBindings = { className: string; scriptName: string; }[]; + sendEmail: { + /** id = binding name */ + id: string; + bindingName: string; + }[]; }; export type ExplorerWorkerOpts = Record; diff --git a/packages/miniflare/src/plugins/email/index.ts b/packages/miniflare/src/plugins/email/index.ts index 09a8533334d..e6b0b7f8609 100644 --- a/packages/miniflare/src/plugins/email/index.ts +++ b/packages/miniflare/src/plugins/email/index.ts @@ -1,7 +1,8 @@ -import { mkdir } from "node:fs/promises"; import path from "node:path"; import EMAIL_MESSAGE from "worker:email/email"; import SEND_EMAIL_BINDING from "worker:email/send_email"; +import { CoreBindings } from "../../workers"; +import { EMAIL_STORE_SERVICE_NAME } from "../core/constants"; import { buildRemoteProxyProps, getEnvBindingsOfType, @@ -9,6 +10,7 @@ import { getUserBindingServiceName, ProxyNodeBinding, remoteProxyClientWorker, + WORKER_BINDING_SERVICE_LOOPBACK, } from "../shared"; import type { Service, Worker_Binding } from "../../runtime"; import type { Plugin } from "../shared"; @@ -16,11 +18,21 @@ import type { Plugin } from "../shared"; export const EMAIL_PLUGIN_NAME = "email"; const SERVICE_SEND_EMAIL_WORKER_PREFIX = `SEND-EMAIL-WORKER`; const EMAIL_REMOTE_SERVICE_NAME = `${EMAIL_PLUGIN_NAME}:remote`; -// Disk service name and binding name for writing temporary files to system temp directory -const EMAIL_DISK_SERVICE_NAME = `${EMAIL_PLUGIN_NAME}:disk`; -const EMAIL_DISK_BINDING_NAME = "MINIFLARE_EMAIL_DISK"; -function buildJsonBindings(bindings: Record): Worker_Binding[] { +function getSendEmailServiceName( + workerName: string | undefined, + bindingName: string +): string { + const scope = + workerName === undefined + ? SERVICE_SEND_EMAIL_WORKER_PREFIX + : `${SERVICE_SEND_EMAIL_WORKER_PREFIX}:${workerName}`; + return getUserBindingServiceName(scope, bindingName); +} + +function buildJsonBindings( + bindings: Record +): Worker_Binding[] { return Object.entries(bindings).map(([name, value]) => ({ name, json: JSON.stringify(value), @@ -88,10 +100,7 @@ export const EMAIL_PLUGIN: Plugin = { } : { entrypoint: "SendEmailBinding", - name: getUserBindingServiceName( - SERVICE_SEND_EMAIL_WORKER_PREFIX, - name - ), + name: getSendEmailServiceName(options.config.name, name), }, }; } @@ -114,51 +123,28 @@ export const EMAIL_PLUGIN: Plugin = { return []; } - // Root directories for disk services - must exist before service creation - // Subdirectories (e.g., email-text/, email-html/) are created lazily on first write - const emailSystemDirectory = path.join(args.tmpPath, EMAIL_PLUGIN_NAME); - await mkdir(emailSystemDirectory, { recursive: true }); - - // Map binding disk services to names and paths, for concise access when storing emails as files. - // When resourceTmpPath is unset, only create system service to avoid duplicates - const diskServices: Array<{ - location: "system" | "project"; - bindingName: string; - serviceName: string; - path: string; - }> = [ - { - location: "system", - bindingName: `${EMAIL_DISK_BINDING_NAME}_SYSTEM`, - serviceName: `${EMAIL_DISK_SERVICE_NAME}:system`, - path: emailSystemDirectory, - }, - ]; - - if (args.sharedOptions.resourceTmpPath) { - const emailProjectSessionDirectory = getEmailProjectSessionDirectory( - args.sharedOptions.resourceTmpPath, - args.tmpPath - ); - if (emailProjectSessionDirectory !== undefined) { - await mkdir(emailProjectSessionDirectory, { recursive: true }); - diskServices.push({ - location: "project", - bindingName: `${EMAIL_DISK_BINDING_NAME}_PROJECT`, - serviceName: `${EMAIL_DISK_SERVICE_NAME}:project`, - path: emailProjectSessionDirectory, - }); - } - } - - const services: Service[] = diskServices.map(({ serviceName, path }) => ({ - name: serviceName, - disk: { - path, - writable: true, - }, - })); - + // The email store service only exists when the local explorer is enabled. + const emailStoreBinding: Worker_Binding[] = args.sharedOptions + .unsafeLocalExplorer + ? [ + { + name: CoreBindings.SERVICE_EMAIL_STORE, + service: { name: EMAIL_STORE_SERVICE_NAME }, + }, + ] + : []; + + // The worker that owns these send_email bindings. `getServices` is called + // once per worker, so this identifies which worker sent a message and lets + // the local explorer filter the "Sending" inbox by the selected worker. + const ownerWorkerBinding: Worker_Binding[] = args.sharedOptions + .unsafeLocalExplorer + ? buildJsonBindings({ + SEND_EMAIL_OWNER_WORKER: args.workerNames[args.workerIndex], + }) + : []; + + const services: Service[] = []; let hasRemote = false; for (const [name, binding] of sendEmailBindings) { if (getRemoteProxyConnectionString(binding, args.options.dev)) { @@ -181,7 +167,7 @@ export const EMAIL_PLUGIN: Plugin = { } services.push({ - name: getUserBindingServiceName(SERVICE_SEND_EMAIL_WORKER_PREFIX, name), + name: getSendEmailServiceName(args.workerNames[args.workerIndex], name), worker: { compatibilityDate: "2025-03-17", modules: [ @@ -192,14 +178,9 @@ export const EMAIL_PLUGIN: Plugin = { ], bindings: [ ...buildJsonBindings(config), - ...diskServices.map(({ bindingName, serviceName }) => ({ - name: bindingName, - service: { name: serviceName }, - })), - { - name: "email_disk_services", - json: JSON.stringify(diskServices), - }, + WORKER_BINDING_SERVICE_LOOPBACK, + ...emailStoreBinding, + ...ownerWorkerBinding, ], }, }); diff --git a/packages/miniflare/src/plugins/email/store.ts b/packages/miniflare/src/plugins/email/store.ts new file mode 100644 index 00000000000..0703422e0a1 --- /dev/null +++ b/packages/miniflare/src/plugins/email/store.ts @@ -0,0 +1,55 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import SCRIPT_EMAIL_STORE from "worker:email/email-store"; +import { type Service } from "../../runtime"; +import { EMAIL_STORE_DISK, EMAIL_STORE_SERVICE_NAME } from "../core/constants"; + +/** + * Builds the email store service and the disk-backed storage behind it. Allows + * the local explorer to record sent/received emails without using the miniflare + * loopback. + */ + +/** DO class name — must match the class exported by email-store.worker.ts. */ +const EMAIL_STORE_CLASS_NAME = "EmailStore"; +/** Binding name — must match the host worker's `Env.EMAIL_STORE_DO`. */ +const EMAIL_STORE_DO_BINDING = "EMAIL_STORE_DO"; + +export function getEmailStoreServices(tmpPath: string): Service[] { + const storagePath = path.join(tmpPath, "email-store"); + mkdirSync(storagePath, { recursive: true }); + + return [ + { + name: EMAIL_STORE_DISK, + disk: { path: storagePath, writable: true }, + }, + { + name: EMAIL_STORE_SERVICE_NAME, + worker: { + compatibilityDate: "2025-03-17", + modules: [ + { + name: "email-store.worker.js", + esModule: SCRIPT_EMAIL_STORE(), + }, + ], + durableObjectNamespaces: [ + { + className: EMAIL_STORE_CLASS_NAME, + uniqueKey: "miniflare-email-store", + enableSql: true, + preventEviction: true, + }, + ], + durableObjectStorage: { localDisk: EMAIL_STORE_DISK }, + bindings: [ + { + name: EMAIL_STORE_DO_BINDING, + durableObjectNamespace: { className: EMAIL_STORE_CLASS_NAME }, + }, + ], + }, + }, + ]; +} diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index b19aec075e8..cb9477121a3 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -98,6 +98,10 @@ export const CoreBindings = { SERVICE_OBSERVABILITY_COLLECTOR: "MINIFLARE_OBSERVABILITY_COLLECTOR", JSON_ACCESS_BLOB_PREFIX: "MINIFLARE_ACCESS_BLOB_", TEXT_FALLBACK_WORKER_NAME: "MINIFLARE_FALLBACK_WORKER_NAME", + SERVICE_EMAIL_STORE: "MINIFLARE_EMAIL_STORE", + // Prefix for the local explorer's direct service bindings to each user + // worker in this instance to invoke handlers (e.g email()). + SERVICE_EXPLORER_USER_WORKER_PREFIX: "MINIFLARE_EXPLORER_USER_WORKER_", } as const; export const ProxyOps = { diff --git a/packages/miniflare/src/workers/core/email.ts b/packages/miniflare/src/workers/core/email.ts index e6d7c9a31de..52b8c74385e 100644 --- a/packages/miniflare/src/workers/core/email.ts +++ b/packages/miniflare/src/workers/core/email.ts @@ -2,9 +2,24 @@ import assert from "node:assert"; import { $, blue, red, reset, yellow } from "kleur/colors"; import { LogLevel, SharedHeaders } from "miniflare:shared"; import PostalMime from "postal-mime"; +import { + MAX_LOCAL_EMAIL_BYTES, + MAX_PRODUCTION_EMAIL_BYTES, + truncateRawForCapture, +} from "../email/capture"; +import { messageIdToStorageId, synthesizeMessageId } from "../email/message-id"; import { isEmailReplyable, validateReply } from "../email/validate"; import { CoreBindings } from "./constants"; import type { MiniflareEmailMessage } from "../email/email.worker"; +import type { + EmailHandlerEvent, + EmailHandlerForward, + EmailHandlerReply, + EmailStoreService, + StoredRoutingEmail, + StoredRoutingEmailMetadata, + StoredRoutingEmailRecord, +} from "../email/storage"; import type { ForwardableEmailMessage } from "@cloudflare/workers-types/experimental"; import type { Email } from "postal-mime"; @@ -14,42 +29,40 @@ $.enabled = true; type Env = { [CoreBindings.SERVICE_LOOPBACK]: Fetcher; + [CoreBindings.SERVICE_EMAIL_STORE]?: EmailStoreService; }; function renderEmailHeaders(headers: Headers | undefined) { return headers - ? `\n headers:\n${[...headers.entries()].map(([k, v]) => ` ${k}: ${v}`).join("\n")}` + ? `\n headers:\n${[...headers.entries()].map(([k, v]) => ` ${escapeLogValue(k)}: ${escapeLogValue(v)}`).join("\n")}` : ""; } +function escapeLogValue(value: string): string { + return value.replace(/[\u0000-\u001f\u007f]/gu, (character) => { + const code = character.codePointAt(0) ?? 0; + return `\\x${code.toString(16).padStart(2, "0")}`; + }); +} + +function isMissingEmailHandlerError(e: unknown): boolean { + return ( + e instanceof Error && + e.message.includes('does not implement the method "email"') + ); +} + export async function handleEmail( params: URLSearchParams, request: Request, service: Fetcher, + workerName: string | undefined, env: Env, ctx: ExecutionContext ): Promise { - const events: Array< - | { - type: "forward" | "reply"; - timestamp: string; - messageId: string; - } - | { - type: "reject"; - timestamp: string; - } - > = []; - const forwards: Array<{ - messageId: string; - recipient: string; - headers: [string, string][]; - }> = []; - const replies: Array<{ - messageId: string; - sender: string; - raw: string; - }> = []; + const events: EmailHandlerEvent[] = []; + const forwards: EmailHandlerForward[] = []; + const replies: EmailHandlerReply[] = []; // Turn an HTTP request into an EmailMessage, using: // - `from` and `to` from the URL @@ -76,25 +89,20 @@ export async function handleEmail( const incomingEmailRaw = new Uint8Array(await request.arrayBuffer()); - // Email Routing does not support messages bigger than 25Mib: https://developers.cloudflare.com/email-routing/limits/#message-size - // In practice, local dev only supports 1MB, since it uses a JSRPC transport. - if (incomingEmailRaw.byteLength > 25 * 1024 * 1024) { - return new Response( - "Email message size is bigger than the production size limit of 25MiB. Local development has a lower limit of 1Mib.", - { - status: 400, - } - ); - } - if (incomingEmailRaw.byteLength > 1024 * 1024) { + // Reject messages larger than production limit of 25MiB + if (incomingEmailRaw.byteLength > MAX_PRODUCTION_EMAIL_BYTES) { return new Response( - "Email message size is within the production size limit of 25MiB, but exceeds the lower 1Mib limit for testing locally.", - { - status: 400, - } + "Email message size is bigger than the production size limit of 25 MiB.", + { status: 400 } ); } + // Delivery to the user Worker uses the full message regardless of size (up to + // the production limit above) — the capture feature must never change what + // `email()` receives. The captured copy is truncated to + // `MAX_LOCAL_EMAIL_BYTES` (see `storeReceivedEmail`) so the workerd-internal + // RPC to the store stays under its ~1 MiB argument cap. + let parsedIncomingEmail: Email; try { parsedIncomingEmail = await PostalMime.parse(incomingEmailRaw); @@ -121,7 +129,7 @@ export async function handleEmail( { method: "POST", headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString() }, - body: `${yellow("Provided MAIL FROM address doesn't match the email message's \"From\" header")}:\n MAIL FROM: ${from}\n "From" header: ${parsedIncomingEmail.from.address}`, + body: `${yellow("Provided MAIL FROM address doesn't match the email message's \"From\" header")}:\n MAIL FROM: ${escapeLogValue(from)}\n "From" header: ${escapeLogValue(parsedIncomingEmail.from.address ?? "")}`, } ); } @@ -132,7 +140,7 @@ export async function handleEmail( { method: "POST", headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString() }, - body: `${yellow('Provided RCPT TO address doesn\'t match any "To" header in the email message')}:\n RCPT TO: ${to}\n "To" header: ${parsedIncomingEmail.to?.map((addr) => addr.address).join(", ")}`, + body: `${yellow('Provided RCPT TO address doesn\'t match any "To" header in the email message')}:\n RCPT TO: ${escapeLogValue(to)}\n "To" header: ${escapeLogValue(parsedIncomingEmail.to?.map((addr) => addr.address).join(", ") ?? "")}`, } ); } @@ -141,177 +149,384 @@ export async function handleEmail( parsedIncomingEmail.headers.map((header) => [header.key, header.value]) ); + let outcome: "ok" | "exception" = "ok"; // Propogate `.setReject()` reasons to the caller let rejectReason: string | undefined = undefined; + events.push({ type: "received", timestamp: new Date().toISOString() }); - // @ts-expect-error .email is not in the `Fetcher` but it's a valid RPC call. - const emailEvent = service.email( - // Construct a ForwardableEmailMessage-like object. We need - // - ForwardableEmailMessage to be able to be passed across JSRPC (to support e.g. userWorker.email(ForwardableEmailMessage)) - // - ForwardableEmailMessage properties to be synchronously available (to match production). This rules out a class extending `RpcStub` - // However, unlike EmailMessage (see email.worker.ts) it doesn't need to be user-constructable, and so we can just use an object with `satisfies` - { - from, - to, - raw: clonedRequest.body, - rawSize: incomingEmailRaw.byteLength, - headers: incomingEmailHeaders, - setReject: (reason: string): void => { - ctx.waitUntil( - env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/log", - { - method: "POST", - headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.ERROR.toString() }, - body: `${red("Email handler rejected message")}${reset(` with the following reason: "${reason}"`)}`, + // Capture this email for the local explorer "Routing" interface. Only the + // first `MAX_LOCAL_EMAIL_BYTES` are captured (the full message is still + // delivered to the user Worker); larger bodies are truncated so the store + // RPC stays under its argument cap. `rawSize` keeps the original size. + const capturedRaw = truncateRawForCapture(incomingEmailRaw); + const rawBase64 = capturedRaw.rawBase64; + if (capturedRaw.truncated) { + ctx.waitUntil( + env[CoreBindings.SERVICE_LOOPBACK] + .fetch("http://localhost/core/log", { + method: "POST", + headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString() }, + body: `Received email exceeds the ${MAX_LOCAL_EMAIL_BYTES}-byte local capture limit; the email was delivered, but only the first ${MAX_LOCAL_EMAIL_BYTES} bytes are shown in the Local Explorer.`, + }) + .catch(() => undefined) + ); + } + const storedEmail: StoredRoutingEmail = { + worker: workerName, + from, + to, + subject: parsedIncomingEmail.subject ?? "(no subject)", + messageId: parsedIncomingEmail.messageId, + receivedAt: new Date().toISOString(), + rawSize: incomingEmailRaw.byteLength, + raw: capturedRaw.raw, + rawBase64, + attachments: (parsedIncomingEmail.attachments ?? []).map((attachment) => ({ + filename: attachment.filename ?? "attachment", + contentType: attachment.mimeType ?? "application/octet-stream", + disposition: + attachment.disposition === "inline" ? "inline" : "attachment", + size: + typeof attachment.content === "string" + ? new TextEncoder().encode(attachment.content).byteLength + : attachment.content.byteLength, + })), + outcome, + forwards, + replies, + events, + }; + // Store exactly once per request, no matter which exit path runs. The result + // fields are refreshed from the (possibly mutated) locals on each attempt. + let stored = false; + async function storeReceivedEmail(): Promise { + if (stored) { + return; + } + stored = true; + storedEmail.outcome = outcome; + storedEmail.rejectReason = rejectReason; + try { + const { + raw: _raw, + rawBase64: _rawBase64, + ...emailMetadata + } = storedEmail; + const store = env[CoreBindings.SERVICE_EMAIL_STORE]; + if (store !== undefined) { + const recordId = messageIdToStorageId(storedEmail.messageId); + // Stream when either the received body or any reply body would + // exceed workerd's RPC argument limit if sent in a single call. + const needsStreaming = + rawBase64.length > 64 * 1024 || + emailMetadata.replies.some( + (reply) => (reply.rawBase64?.length ?? 0) > 64 * 1024 + ); + if (needsStreaming) { + // Reply bodies are streamed separately, so drop them from the + // prelude to keep it under the RPC argument limit. + const metadata: StoredRoutingEmailMetadata = { + ...emailMetadata, + replies: emailMetadata.replies.map( + ({ raw: _replyRaw, rawBase64: _replyRawBase64, ...reply }) => + reply + ), + }; + await store.beginReceived(metadata); + try { + for ( + let offset = 0; + offset < rawBase64.length; + offset += 64 * 1024 + ) { + await store.appendReceivedRaw( + recordId, + rawBase64.slice(offset, offset + 64 * 1024) + ); } - ) - ); - - events.push({ - type: "reject", - timestamp: new Date().toISOString(), - }); - rejectReason = reason; - }, - forward: async ( - rcptTo: string, - headers?: Headers - ): Promise => { + for (const [replyIndex, reply] of emailMetadata.replies.entries()) { + const replyRawBase64 = reply.rawBase64; + if (replyRawBase64 === undefined) { + continue; + } + for ( + let offset = 0; + offset < replyRawBase64.length; + offset += 64 * 1024 + ) { + await store.appendReplyRaw( + recordId, + replyIndex, + replyRawBase64.slice(offset, offset + 64 * 1024) + ); + } + } + await store.finishReceived(recordId); + } catch (error) { + await store.discardReceived(recordId).catch(() => undefined); + throw error; + } + } else { + const record: StoredRoutingEmailRecord = { + ...emailMetadata, + rawBase64, + }; + await store.storeReceived(record); + } + } + } catch { + // Storage failures must not affect email handling, but a dropped + // capture should still be observable (unlike the silent path before). + // Mirror the sent path's WARN so a failed capture is visible in dev. + stored = false; + try { await env[CoreBindings.SERVICE_LOOPBACK].fetch( "http://localhost/core/log", { method: "POST", - headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.INFO.toString() }, - body: `${blue("Email handler forwarded message")}${reset(` with\n rcptTo: ${rcptTo}${renderEmailHeaders(headers)}`)}`, + headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString() }, + body: "Failed to capture received email for the Local Explorer; the email was still delivered.", } ); - /** - * The message ID in production is a 36 character random string that identifies the message for e.g. linking up threads. - * In production it uses the sender domain rather than example.com. Locally, we have access to none of that information - * so instead we make a dummy message ID that matches the production format (36 characters followed by a domain) - */ - const uuid = crypto.randomUUID().replaceAll("-", ""); - const result = { messageId: `${uuid}@example.com` }; - - events.push({ - type: "forward", - timestamp: new Date().toISOString(), - messageId: result.messageId, - }); - forwards.push({ - recipient: rcptTo, - headers: headers ? [...headers.entries()] : [], - messageId: result.messageId, - }); + } catch { + // Logging failures must not affect email handling. + } + } + } - return result; - }, - reply: async (replyMessage): Promise => { - assert( - "from" in replyMessage && "to" in replyMessage, - "EmailReplyMessageBuilder is not currently supported" - ); + try { + // @ts-expect-error .email is not in the `Fetcher` but it's a valid RPC call. + const emailEvent = service.email( + // Construct a ForwardableEmailMessage-like object. We need + // - ForwardableEmailMessage to be able to be passed across JSRPC (to support e.g. userWorker.email(ForwardableEmailMessage)) + // - ForwardableEmailMessage properties to be synchronously available (to match production). This rules out a class extending `RpcStub` + // However, unlike EmailMessage (see email.worker.ts) it doesn't need to be user-constructable, and so we can just use an object with `satisfies` + { + from, + to, + raw: clonedRequest.body, + rawSize: incomingEmailRaw.byteLength, + headers: incomingEmailHeaders, + setReject: (reason: string): void => { + ctx.waitUntil( + env[CoreBindings.SERVICE_LOOPBACK].fetch( + "http://localhost/core/log", + { + method: "POST", + headers: { + [SharedHeaders.LOG_LEVEL]: LogLevel.ERROR.toString(), + }, + body: `${red("Email handler rejected message")}${reset(` with the following reason: "${escapeLogValue(reason)}"`)}`, + } + ) + ); + + events.push({ + type: "reject", + timestamp: new Date().toISOString(), + }); + rejectReason = reason; + }, + forward: async ( + rcptTo: string, + headers?: Headers + ): Promise => { + await env[CoreBindings.SERVICE_LOOPBACK].fetch( + "http://localhost/core/log", + { + method: "POST", + headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.INFO.toString() }, + body: `${blue("Email handler forwarded message")}${reset(` with\n rcptTo: ${escapeLogValue(rcptTo)}${renderEmailHeaders(headers)}`)}`, + } + ); + // Production returns a message id identifying the forwarded message. + // Locally we have no such id, so synthesize one in the production + // shape, using the recipient's domain. + const result = { messageId: synthesizeMessageId(rcptTo) }; + + events.push({ + type: "forward", + timestamp: new Date().toISOString(), + messageId: result.messageId, + }); + forwards.push({ + recipient: rcptTo, + headers: headers + ? [...headers.entries()].map(([name, value]) => ({ name, value })) + : [], + messageId: result.messageId, + }); + + return result; + }, + reply: async (replyMessage): Promise => { + assert( + "from" in replyMessage && "to" in replyMessage, + "EmailReplyMessageBuilder is not currently supported" + ); - if ( - !(await isEmailReplyable( + if ( + !(await isEmailReplyable( + parsedIncomingEmail, + incomingEmailHeaders, + async (msg) => + void (await env[CoreBindings.SERVICE_LOOPBACK].fetch( + "http://localhost/core/log", + { + method: "POST", + headers: { + [SharedHeaders.LOG_LEVEL]: LogLevel.ERROR.toString(), + }, + body: msg, + } + )) + )) + ) { + throw new Error("Original email is not replyable"); + } + const validatedReply = await validateReply( parsedIncomingEmail, - incomingEmailHeaders, - async (msg) => - void (await env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/log", - { + replyMessage as MiniflareEmailMessage + ); + const finalReply = validatedReply.raw; + const replyId = messageIdToStorageId(validatedReply.messageId); + + // Store the reply under `email//reply/.eml`. + // The on-disk copy is a dev-only inspection aid, so a failure here + // must not surface as an exception in the user's `email()` handler. + // The reply itself has already succeeded; continue without a file path. + let file: string | undefined; + const resp = await env[CoreBindings.SERVICE_LOOPBACK].fetch( + `http://localhost/core/store-temp-file?extension=eml&prefix=email/reply&id=${encodeURIComponent(replyId)}`, + { + method: "POST", + body: finalReply, + } + ); + if (resp.ok) { + file = await resp.text(); + } else { + await env[CoreBindings.SERVICE_LOOPBACK].fetch( + "http://localhost/core/log", + { + method: "POST", + headers: { + [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString(), + }, + body: `${yellow("Failed to persist replied email for the Local Explorer; the reply was still sent")}${reset(`: ${escapeLogValue(await resp.text())}`)}`, + } + ); + } + + await env[CoreBindings.SERVICE_LOOPBACK].fetch( + "http://localhost/core/log", + { + method: "POST", + headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.INFO.toString() }, + body: `${blue("Email handler replied to sender")}${reset(` with the following message:\n ${escapeLogValue(file ?? "(reply not persisted)")}`)}`, + } + ); + + // The reply MIME already has a message id + const result = { messageId: validatedReply.messageId }; + events.push({ + type: "reply", + timestamp: new Date().toISOString(), + messageId: result.messageId, + }); + // The full reply is written to disk above; only capture up to the + // local limit in the store record so it stays under the RPC cap. + const capturedReply = truncateRawForCapture(finalReply); + if (capturedReply.truncated) { + ctx.waitUntil( + env[CoreBindings.SERVICE_LOOPBACK] + .fetch("http://localhost/core/log", { method: "POST", headers: { - [SharedHeaders.LOG_LEVEL]: LogLevel.ERROR.toString(), + [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString(), }, - body: msg, - } - )) - )) - ) { - throw new Error("Original email is not replyable"); - } - const finalReply = await validateReply( - parsedIncomingEmail, - replyMessage as MiniflareEmailMessage - ); - - const resp = await env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/store-temp-file?extension=eml&prefix=email", - { - method: "POST", - body: finalReply, + body: `Reply email exceeds the ${MAX_LOCAL_EMAIL_BYTES}-byte local capture limit; the reply was sent, but only the first ${MAX_LOCAL_EMAIL_BYTES} bytes are shown in the Local Explorer.`, + }) + .catch(() => undefined) + ); } - ); - const file = await resp.text(); + replies.push({ + messageId: result.messageId, + sender: replyMessage.from, + raw: capturedReply.raw, + rawBase64: capturedReply.rawBase64, + }); + return result; + }, + } satisfies ForwardableEmailMessage + ); - await env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/log", - { - method: "POST", - headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.INFO.toString() }, - body: `${blue("Email handler replied to sender")}${reset(` with the following message:\n ${file}`)}`, - } + if (params.get("format") !== "json") { + await emailEvent; + // Record the message now the handler has finished, so `events` is + // complete. Every exit from here on must store exactly once. + + // Give an un-awaited `setReject()` call time to cross JSRPC. + await scheduler.wait(0); + await storeReceivedEmail(); + + if (rejectReason !== undefined) { + return new Response( + `Worker rejected email with the following reason: ${rejectReason}`, + { status: 400 } ); + } - /** - * The message ID in production is a 36 character random string that identifies the message for e.g. linking up threads. - * In production it uses the sender domain rather than example.com. Locally, we have access to none of that information - * so instead we make a dummy message ID that matches the production format (36 characters followed by a domain) - */ - const uuid = crypto.randomUUID().replaceAll("-", ""); - const result = { messageId: `${uuid}@example.com` }; - events.push({ - type: "reply", + return new Response("Worker successfully processed email", { + status: 200, + }); + } + + try { + await emailEvent; + outcome = "ok"; + } catch (e) { + outcome = "exception"; + if (isMissingEmailHandlerError(e)) { + // The Worker has no `email()` handler, so the message could not be + // delivered. Record it as `unhandled`` + events.splice(0, events.length, { + type: "unhandled", timestamp: new Date().toISOString(), - messageId: result.messageId, - }); - replies.push({ - messageId: result.messageId, - sender: replyMessage.from, - raw: new TextDecoder().decode(finalReply), }); - return result; - }, - } satisfies ForwardableEmailMessage - ); + } + } - if (params.get("format") !== "json") { - await emailEvent; + // Give an un-awaited `setReject()` call time to cross JSRPC. + await scheduler.wait(0); + await storeReceivedEmail(); - if (rejectReason !== undefined) { + return Response.json( + { + outcome, + rejectReason, + forwards, + replies: replies.map(({ rawBase64: _rawBase64, ...reply }) => reply), + events, + }, + { status: outcome === "ok" ? 200 : 500 } + ); + } catch (e) { + outcome = "exception"; + if (isMissingEmailHandlerError(e)) { + // The Worker has no `email()` handler, so the message could not be + // delivered. Record it as `unhandled`` + events.splice(0, events.length, { + type: "unhandled", + timestamp: new Date().toISOString(), + }); + await storeReceivedEmail(); return new Response( - `Worker rejected email with the following reason: ${rejectReason}`, - { status: 400 } + "Worker does not export an email() handler; message stored without delivery.", + { status: 500 } ); } - - return new Response("Worker successfully processed email", { - status: 200, - }); + await storeReceivedEmail(); + throw e; } - - let outcome: "ok" | "exception"; - - try { - await emailEvent; - outcome = "ok"; - } catch { - outcome = "exception"; - } - - // Give an un-awaited `setReject()` call time to cross JSRPC. - await scheduler.wait(0); - - return Response.json( - { - outcome, - rejectReason, - forwards, - replies, - events, - }, - { status: outcome === "ok" ? 200 : 500 } - ); } diff --git a/packages/miniflare/src/workers/core/entry.worker.ts b/packages/miniflare/src/workers/core/entry.worker.ts index 3ae53589f9e..6a397dcd9fa 100644 --- a/packages/miniflare/src/workers/core/entry.worker.ts +++ b/packages/miniflare/src/workers/core/entry.worker.ts @@ -17,6 +17,7 @@ import type { Colorize } from "kleur/colors"; type Env = { [CoreBindings.SERVICE_LOOPBACK]: Fetcher; [CoreBindings.SERVICE_USER_FALLBACK]: Fetcher; + [CoreBindings.TEXT_FALLBACK_WORKER_NAME]: string; [CoreBindings.SERVICE_LOCAL_EXPLORER]: Fetcher; [CoreBindings.SERVICE_STREAM]?: Fetcher; [CoreBindings.SERVICE_IMAGES_DELIVERY]?: Fetcher; @@ -560,6 +561,7 @@ export default >{ url.searchParams, request, service, + routeTarget, env, ctx ); diff --git a/packages/miniflare/src/workers/email/capture.ts b/packages/miniflare/src/workers/email/capture.ts new file mode 100644 index 00000000000..38837f327f2 --- /dev/null +++ b/packages/miniflare/src/workers/email/capture.ts @@ -0,0 +1,107 @@ +// Helpers for preparing email bytes for capture into the local email store. +// +// Capture is a dev-only inspection aid for the Local Explorer. It pushes the +// raw MIME (as base64) to the EmailStore Durable Object over workerd-internal +// RPC, whose argument size is capped near 1 MiB. Rather than fail delivery for +// larger messages, oversized bodies are truncated for capture only; delivery +// always uses the full, untruncated message. + +export const RAW_EMAIL = "EmailMessage::raw"; + +/** + * Maximum raw byte size of an email body captured into the local email store. + * + * This is a capture/inspection limit, not a delivery limit: sending, replying, + * and receiving always use the full message regardless of size. Capture pushes + * the raw MIME to the EmailStore Durable Object over workerd-internal RPC, + * whose argument size is capped near 1 MiB, so larger bodies are truncated to + * this size for the Local Explorer (see `truncateRawForCapture`). + */ +export const MAX_LOCAL_EMAIL_BYTES = 1024 * 1024; + +/** + * Maximum raw byte size of an email message, matching production behaviour so + * oversized messages fail the same way locally. + */ +export const MAX_PRODUCTION_EMAIL_BYTES = 25 * 1024 * 1024; + +/** + * Combined byte budget for the inline `text` + `html` bodies of a captured + * sent email (the MessageBuilder path, which has no raw body to stream). + * + * These bodies travel inline in the single `storeSent` RPC argument alongside + * headers, subject, addresses, and attachment metadata. workerd caps that + * argument near 1 MiB, so the combined body budget is kept well below it to + * leave comfortable headroom for the rest of the record. Exceeding it would + * make `storeSent` throw and the email would silently never be captured. + */ +export const MAX_CAPTURE_BODY_BYTES = 256 * 1024; + +/** Encodes bytes without passing a large argument list to String.fromCharCode. */ +export function bytesToBase64(bytes: Uint8Array): string { + const chunkSize = 0x8000; + let binary = ""; + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + binary += String.fromCharCode( + ...bytes.subarray(offset, offset + chunkSize) + ); + } + return btoa(binary); +} + +export function base64ToBytes(encoded: string): Uint8Array { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +export interface TruncatedRaw { + /** Raw MIME content, truncated to `MAX_LOCAL_EMAIL_BYTES` when oversized. */ + raw: string; + /** Lossless base64 of the (possibly truncated) raw content. */ + rawBase64: string; + /** Whether the content was truncated for capture. */ + truncated: boolean; +} + +/** + * Prepares a raw email body for capture in the local email store. + * + * Capture pushes the raw MIME (as base64) to the EmailStore Durable Object over + * workerd-internal RPC, whose argument size is capped near 1 MiB. Rather than + * fail delivery for larger messages, we capture only the first + * `MAX_LOCAL_EMAIL_BYTES` of the raw body so the Local Explorer still shows a + * (truncated) message. Delivery itself always uses the full, untruncated body — + * this only affects what the inspector stores. + */ +export function truncateRawForCapture(raw: Uint8Array): TruncatedRaw { + const truncated = raw.byteLength > MAX_LOCAL_EMAIL_BYTES; + const captured = truncated ? raw.subarray(0, MAX_LOCAL_EMAIL_BYTES) : raw; + return { + raw: new TextDecoder().decode(captured), + rawBase64: bytesToBase64(captured), + truncated, + }; +} + +/** + * Truncates a UTF-8 string to at most `maxBytes` bytes, splitting on a byte + * boundary (any trailing partial multi-byte sequence is dropped by the decoder). + * Returns the original string when it already fits. + */ +export function truncateStringForCapture( + value: string, + maxBytes: number = MAX_LOCAL_EMAIL_BYTES +): { value: string; truncated: boolean } { + const bytes = new TextEncoder().encode(value); + if (bytes.byteLength <= maxBytes) { + return { value, truncated: false }; + } + return { + value: new TextDecoder().decode(bytes.subarray(0, Math.max(maxBytes, 0))), + truncated: true, + }; +} diff --git a/packages/miniflare/src/workers/email/constants.ts b/packages/miniflare/src/workers/email/constants.ts deleted file mode 100644 index 9f9dacfbe3b..00000000000 --- a/packages/miniflare/src/workers/email/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const RAW_EMAIL = "EmailMessage::raw"; diff --git a/packages/miniflare/src/workers/email/email-store.ts b/packages/miniflare/src/workers/email/email-store.ts new file mode 100644 index 00000000000..aafc8651142 --- /dev/null +++ b/packages/miniflare/src/workers/email/email-store.ts @@ -0,0 +1,454 @@ +/** + * The local email store: a SQLite-backed Durable Object holding the emails + * captured during a dev session. The `send_email` binding and the `email()` + * receiving path write to it, and the Local Explorer's Email API reads from it, + * all over workerd-internal RPC. Because every hop stays inside workerd, capture + * never depends on the Node host loopback server — so it works even when a + * binding method is invoked through the synchronous platform proxy + * (`getPlatformProxy()` / `getBindings()`), which blocks the Node main thread. + * + * Records are stored as full JSON blobs in one table, discriminated by kind and + * ordered by capture time. Lists derive compact summaries from bounded cursor + * pages. This data is local only: it is never exposed to the user's app or sent + * anywhere, and it does not persist across dev-server restarts (the store is + * backed by the instance temp directory). + */ +import { DurableObject } from "cloudflare:workers"; +import { z } from "zod"; +import { + zEmailBase, + zEmailHandlerForward, + zEmailHandlerReply, + zEmailSendingDetail, +} from "../local-explorer/generated/zod.gen"; +import { base64ToBytes, bytesToBase64 } from "./capture"; +import { messageIdToStorageId } from "./message-id"; +import type { + EmailListPage, + StoredRoutingEmailMetadata, + StoredRoutingEmailRecord, + StoredRoutingEmail, + StoredRoutingEmailSummary, + StoredSendingEmail, + StoredSendingEmailMetadata, + StoredSendingEmailSummary, +} from "./storage"; + +export type { StoredRoutingEmail, StoredSendingEmail }; + +function materialiseReceivedEmail( + email: StoredRoutingEmailRecord +): StoredRoutingEmail { + return { + ...email, + raw: new TextDecoder().decode(base64ToBytes(email.rawBase64)), + replies: email.replies.map((reply) => ({ + ...reply, + raw: + reply.raw ?? + (reply.rawBase64 === undefined + ? "" + : new TextDecoder().decode(base64ToBytes(reply.rawBase64))), + })), + }; +} + +/** + * Decodes a sent record's `raw` from its `rawBase64` when it was stored + * base64-only (the chunked send path stores no decoded `raw`). Records that + * already carry `raw`, or have no raw body at all, are returned unchanged. + */ +function materialiseSentEmail(email: StoredSendingEmail): StoredSendingEmail { + if (email.raw !== undefined || email.rawBase64 === undefined) { + return email; + } + return { + ...email, + raw: new TextDecoder().decode(base64ToBytes(email.rawBase64)), + }; +} + +const SCHEMA = [ + `CREATE TABLE IF NOT EXISTS emails ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL CHECK (kind IN ('received', 'sent')), + id TEXT NOT NULL, + created_at TEXT NOT NULL, + data TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS emails_by_kind_seq ON emails (kind, seq DESC)`, + `CREATE INDEX IF NOT EXISTS emails_by_kind_id ON emails (kind, id)`, +]; + +const zStoredEmailBase = zEmailBase.extend({ + raw: z.string().optional(), + rawBase64: z.string().optional(), +}); +const zStoredEmailReply = zEmailHandlerReply.extend({ raw: z.string() }); +const zStoredEmailEvent = z.discriminatedUnion("type", [ + z.object({ + type: z.enum(["forward", "reply"]), + timestamp: z.string(), + messageId: z.string(), + }), + z.object({ + type: z.enum(["received", "reject", "unhandled"]), + timestamp: z.string(), + }), +]); +export const zStoredRoutingEmail = zStoredEmailBase.extend({ + raw: z.string(), + to: z.string(), + 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(zStoredEmailEvent), +}); +const zStoredRoutingEmailRecord = zStoredRoutingEmail + .omit({ raw: true, replies: true }) + .extend({ + rawBase64: z.string(), + // Reply raw bodies are stored base64-only (streamed in chunks); the + // decoded `raw` is materialised on read. + replies: z.array(zEmailHandlerReply), + }); +export const zStoredRoutingEmailSummary = zStoredRoutingEmail + .omit({ raw: true, rawBase64: true, replies: true }) + .extend({ + replies: z.array(zStoredEmailReply.omit({ raw: true, rawBase64: true })), + }); + +type EmailTable = "received" | "sent"; +type EmailCursor = { seq: number }; + +function createStatements(kind: EmailTable) { + return { + insert: `INSERT INTO emails (kind, id, created_at, data) VALUES ('${kind}', ?, ?, ?)`, + list: `SELECT seq, created_at, data FROM emails + WHERE kind = '${kind}' ORDER BY seq DESC LIMIT ?`, + listAfter: `SELECT seq, created_at, data FROM emails + WHERE kind = '${kind}' + AND seq < ? + ORDER BY seq DESC LIMIT ?`, + find: `SELECT data FROM emails WHERE kind = '${kind}' AND id = ? + ORDER BY seq DESC LIMIT 1`, + }; +} + +const STATEMENTS = { + received: createStatements("received"), + sent: createStatements("sent"), + clear: "DELETE FROM emails", +} as const; + +const DEFAULT_LIST_LIMIT = 25; +const MAX_LIST_LIMIT = 100; + +function encodeCursor(cursor: EmailCursor): string { + return bytesToBase64(new TextEncoder().encode(JSON.stringify(cursor))); +} + +function decodeCursor(value: string): EmailCursor { + try { + const cursor = JSON.parse( + new TextDecoder().decode(base64ToBytes(value)) + ) as Partial; + if (typeof cursor.seq !== "number" || !Number.isSafeInteger(cursor.seq)) { + throw new Error("Invalid cursor"); + } + return cursor as EmailCursor; + } catch { + throw new TypeError("Invalid email pagination cursor"); + } +} + +function normaliseLimit(limit: number | undefined): number { + if (limit === undefined) { + return DEFAULT_LIST_LIMIT; + } + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) { + throw new RangeError("Invalid email pagination limit"); + } + return limit; +} + +function normaliseReceivedRecord( + email: StoredRoutingEmail | StoredRoutingEmailRecord +): StoredRoutingEmailRecord { + if ("raw" in email) { + const { raw: _raw, ...record } = email; + return { + ...record, + rawBase64: + email.rawBase64 ?? bytesToBase64(new TextEncoder().encode(email.raw)), + }; + } + return email; +} + +function parseReceivedRecord(data: unknown): StoredRoutingEmailRecord { + const record = zStoredRoutingEmailRecord.safeParse(data); + if (record.success) { + return record.data; + } + return normaliseReceivedRecord(zStoredRoutingEmail.parse(data)); +} + +function getReceivedSummary( + email: StoredRoutingEmailRecord +): StoredRoutingEmailSummary { + const { rawBase64: _rawBase64, replies, ...rest } = email; + return { + ...rest, + replies: replies.map( + ({ raw: _replyRaw, rawBase64: _replyRawBase64, ...reply }) => reply + ), + }; +} + +function getSentSummary(email: StoredSendingEmail): StoredSendingEmailSummary { + const { + text: _text, + html: _html, + raw: _raw, + rawBase64: _rawBase64, + ...summary + } = email; + return summary; +} + +export class EmailStore extends DurableObject { + private sql = this.ctx.storage.sql; + #pendingReceived = new Map< + string, + { + email: StoredRoutingEmailMetadata; + chunks: string[]; + replyChunks: Map; + } + >(); + #pendingSent = new Map< + string, + { email: StoredSendingEmailMetadata; chunks: string[] } + >(); + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env as never); + this.ctx.blockConcurrencyWhile(async () => { + for (const stmt of SCHEMA) { + this.sql.exec(stmt); + } + }); + } + + #insert( + table: EmailTable, + id: string, + createdAt: string, + data: unknown + ): void { + this.sql.exec( + STATEMENTS[table].insert, + id, + createdAt, + JSON.stringify(data) + ); + } + + /** Newest-first cursor page of records from a table. */ + #list( + table: EmailTable, + parse: (data: string) => T, + cursor: string | undefined, + limit: number | undefined + ): EmailListPage { + const pageSize = normaliseLimit(limit); + const rows = + cursor === undefined + ? this.sql + .exec<{ seq: number; created_at: string; data: string }>( + STATEMENTS[table].list, + pageSize + 1 + ) + .toArray() + : (() => { + const decoded = decodeCursor(cursor); + return this.sql + .exec<{ seq: number; created_at: string; data: string }>( + STATEMENTS[table].listAfter, + decoded.seq, + pageSize + 1 + ) + .toArray(); + })(); + const hasMore = rows.length > pageSize; + const pageRows = rows.slice(0, pageSize); + const last = pageRows.at(-1); + return { + items: pageRows.map(({ data }) => parse(data)), + hasMore, + ...(hasMore && last !== undefined + ? { + cursor: encodeCursor({ + seq: last.seq, + }), + } + : {}), + }; + } + + /** Most recently stored full record with the given message ID. */ + #find(table: EmailTable, id: string): T | undefined { + const row = this.sql + .exec<{ data: string }>(STATEMENTS[table].find, id) + .toArray()[0]; + return row === undefined ? undefined : (JSON.parse(row.data) as T); + } + + storeReceived(email: StoredRoutingEmailRecord): void { + this.#insert( + "received", + messageIdToStorageId(email.messageId), + email.receivedAt, + email + ); + } + + beginReceived(email: StoredRoutingEmailMetadata): void { + this.#pendingReceived.set(messageIdToStorageId(email.messageId), { + email, + chunks: [], + replyChunks: new Map(), + }); + } + + appendReceivedRaw(id: string, chunk: string): void { + const pending = this.#pendingReceived.get(id); + if (pending === undefined) { + throw new Error(`No pending received email for ${id}`); + } + pending.chunks.push(chunk); + } + + appendReplyRaw(id: string, replyIndex: number, chunk: string): void { + const pending = this.#pendingReceived.get(id); + if (pending === undefined) { + throw new Error(`No pending received email for ${id}`); + } + let chunks = pending.replyChunks.get(replyIndex); + if (chunks === undefined) { + chunks = []; + pending.replyChunks.set(replyIndex, chunks); + } + chunks.push(chunk); + } + + async finishReceived(id: string): Promise { + const pending = this.#pendingReceived.get(id); + if (pending === undefined) { + throw new Error(`No pending received email for ${id}`); + } + this.#pendingReceived.delete(id); + this.storeReceived({ + ...pending.email, + rawBase64: pending.chunks.join(""), + replies: pending.email.replies.map((reply, index) => { + const chunks = pending.replyChunks.get(index); + return chunks === undefined + ? reply + : { ...reply, rawBase64: chunks.join("") }; + }), + }); + } + + discardReceived(id: string): void { + this.#pendingReceived.delete(id); + } + + findReceived(id: string): StoredRoutingEmail | undefined { + const row = this.sql + .exec<{ data: string }>(STATEMENTS.received.find, id) + .toArray()[0]; + return row === undefined + ? undefined + : materialiseReceivedEmail(parseReceivedRecord(JSON.parse(row.data))); + } + + listReceived( + cursor?: string, + limit?: number + ): EmailListPage { + return this.#list( + "received", + (data) => getReceivedSummary(parseReceivedRecord(JSON.parse(data))), + cursor, + limit + ); + } + + storeSent(email: StoredSendingEmail): void { + this.#insert( + "sent", + messageIdToStorageId(email.messageId), + email.sentAt, + email + ); + } + + beginSent(email: StoredSendingEmailMetadata): void { + this.#pendingSent.set(messageIdToStorageId(email.messageId), { + email, + chunks: [], + }); + } + + appendSentRaw(id: string, chunk: string): void { + const pending = this.#pendingSent.get(id); + if (pending === undefined) { + throw new Error(`No pending sent email for ${id}`); + } + pending.chunks.push(chunk); + } + + async finishSent(id: string): Promise { + const pending = this.#pendingSent.get(id); + if (pending === undefined) { + throw new Error(`No pending sent email for ${id}`); + } + this.#pendingSent.delete(id); + this.storeSent({ + ...pending.email, + rawBase64: pending.chunks.join(""), + }); + } + + discardSent(id: string): void { + this.#pendingSent.delete(id); + } + + findSent(id: string): StoredSendingEmail | undefined { + const email = this.#find("sent", id); + return email === undefined ? undefined : materialiseSentEmail(email); + } + + listSent( + cursor?: string, + limit?: number + ): EmailListPage { + return this.#list( + "sent", + (data) => getSentSummary(zEmailSendingDetail.parse(JSON.parse(data))), + cursor, + limit + ); + } + + clear(): void { + this.#pendingReceived.clear(); + this.#pendingSent.clear(); + 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 new file mode 100644 index 00000000000..8edcc8f4340 --- /dev/null +++ b/packages/miniflare/src/workers/email/email-store.worker.ts @@ -0,0 +1,117 @@ +/** + * Hosts the `EmailStore` Durable Object and exposes it to the other email + * services over RPC. The `send_email` binding and the `email()` receiving path + * write captured emails here, and the Local Explorer reads them back — all + * through workerd-internal service-binding RPC, so nothing touches the Node host + * loopback server (see email-store.ts for why that matters). + */ +import { WorkerEntrypoint } from "cloudflare:workers"; +import { + EmailStore, + zStoredRoutingEmail, + zStoredRoutingEmailSummary, +} from "./email-store"; +import type { + EmailListPage, + StoredRoutingEmail, + StoredRoutingEmailMetadata, + StoredRoutingEmailRecord, + StoredRoutingEmailSummary, + StoredSendingEmail, + StoredSendingEmailMetadata, + StoredSendingEmailSummary, +} from "./storage"; + +// Re-export so the embedded worker registers the DO class under its namespace. +export { EmailStore }; + +interface Env { + EMAIL_STORE_DO: DurableObjectNamespace; +} + +export default class EmailStoreHost extends WorkerEntrypoint { + #store() { + return this.env.EMAIL_STORE_DO.get( + this.env.EMAIL_STORE_DO.idFromName("singleton") + ); + } + + async storeReceived(email: StoredRoutingEmailRecord): Promise { + await this.#store().storeReceived(email); + } + + async beginReceived(email: StoredRoutingEmailMetadata): Promise { + await this.#store().beginReceived(email); + } + + async appendReceivedRaw(id: string, chunk: string): Promise { + await this.#store().appendReceivedRaw(id, chunk); + } + + async appendReplyRaw( + id: string, + replyIndex: number, + chunk: string + ): Promise { + await this.#store().appendReplyRaw(id, replyIndex, chunk); + } + + async finishReceived(id: string): Promise { + await this.#store().finishReceived(id); + } + + async discardReceived(id: string): Promise { + await this.#store().discardReceived(id); + } + + async findReceived(id: string): Promise { + const email = await this.#store().findReceived(id); + return email === undefined ? undefined : zStoredRoutingEmail.parse(email); + } + + async listReceived( + cursor?: string, + limit?: number + ): Promise> { + const page = await this.#store().listReceived(cursor, limit); + return { + ...page, + items: zStoredRoutingEmailSummary.array().parse(page.items), + }; + } + + async storeSent(email: StoredSendingEmail): Promise { + await this.#store().storeSent(email); + } + + async beginSent(email: StoredSendingEmailMetadata): Promise { + await this.#store().beginSent(email); + } + + async appendSentRaw(id: string, chunk: string): Promise { + await this.#store().appendSentRaw(id, chunk); + } + + async finishSent(id: string): Promise { + await this.#store().finishSent(id); + } + + async discardSent(id: string): Promise { + await this.#store().discardSent(id); + } + + async findSent(id: string): Promise { + return await this.#store().findSent(id); + } + + async listSent( + cursor?: string, + limit?: number + ): Promise> { + return await this.#store().listSent(cursor, limit); + } + + async clear(): Promise { + await this.#store().clear(); + } +} diff --git a/packages/miniflare/src/workers/email/email.worker.ts b/packages/miniflare/src/workers/email/email.worker.ts index b8917d1f97b..5402b451b7c 100644 --- a/packages/miniflare/src/workers/email/email.worker.ts +++ b/packages/miniflare/src/workers/email/email.worker.ts @@ -1,4 +1,4 @@ -import { RAW_EMAIL } from "./constants"; +import { RAW_EMAIL } from "./capture"; import type { EmailMessage as EmailMessageType } from "@cloudflare/workers-types/experimental"; // This type is the _actual_ type of an EmailMessage when running locally, which is different to production diff --git a/packages/miniflare/src/workers/email/message-id.ts b/packages/miniflare/src/workers/email/message-id.ts new file mode 100644 index 00000000000..b1c20098a8b --- /dev/null +++ b/packages/miniflare/src/workers/email/message-id.ts @@ -0,0 +1,47 @@ +// Message-ID handling shared by the paths that capture emails: the `send_email` +// binding and the local explorer's "send test email" endpoint. Both must agree +// on the format, because the id derived from a Message-ID keys the explorer's +// record. + +/** + * Builds a Message-ID in the shape the `mimetext` library generates for emails + * created via `createMimeMessage()`: `<{base36 random}@{sender domain}>`. Used + * as a fallback when no Message-ID is otherwise available, so a synthesized id + * matches the format callers see everywhere else. + */ +export function synthesizeMessageId(senderEmail: string): string { + const id = Math.random().toString(36).slice(2); + const domain = senderEmail.slice(senderEmail.lastIndexOf("@") + 1); + return `<${id}@${domain}>`; +} + +/** + * Derives the id an email is indexed under from its Message-ID by stripping the + * enclosing angle brackets. + * + * This id keys the local explorer record, so a message listed in the explorer + * can be looked up by it. + */ +export function messageIdToStorageId(messageId: string): string { + return messageId.replace(/^<|>$/g, ""); +} + +/** + * Case-insensitive lookup of a header value in a `Record` of headers, so a + * caller-supplied Message-ID is honoured whatever casing it uses. + */ +export function getHeader( + headers: Record | undefined, + name: string +): string | undefined { + if (headers === undefined) { + return undefined; + } + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) { + return value; + } + } + return undefined; +} diff --git a/packages/miniflare/src/workers/email/send_email.worker.ts b/packages/miniflare/src/workers/email/send_email.worker.ts index 725eba84a76..688bb579a98 100644 --- a/packages/miniflare/src/workers/email/send_email.worker.ts +++ b/packages/miniflare/src/workers/email/send_email.worker.ts @@ -1,24 +1,47 @@ import { WorkerEntrypoint } from "cloudflare:workers"; -import { blue } from "kleur/colors"; +import { $, blue } from "kleur/colors"; +import { LogLevel, SharedHeaders } from "miniflare:shared"; import PostalMime from "postal-mime"; -import { RAW_EMAIL } from "./constants"; +import { CoreBindings } from "../core/constants"; +import { + MAX_CAPTURE_BODY_BYTES, + MAX_LOCAL_EMAIL_BYTES, + RAW_EMAIL, + truncateRawForCapture, + truncateStringForCapture, +} from "./capture"; import { type MiniflareEmailMessage as EmailMessage } from "./email.worker"; +import { messageIdToStorageId, synthesizeMessageId } from "./message-id"; +import type { + EmailStoreService, + StoredEmailAttachment, + StoredSendingEmail, +} from "./storage"; import type { EmailAddress, MessageBuilder } from "./types"; import type { Email } from "postal-mime"; +// Force-enable colours. +$.enabled = true; + /** - * Build a Message-ID in the shape the production `send_email` binding returns: - * `<{36 alphanumeric chars}@{sender domain}>`, brackets included. The body is - * random — production synthesizes its own id rather than echoing any header - * present in the submitted email. + * Byte length of email content, so attachment sizes are accurate for + * multi-byte payloads (string `.length` counts UTF-16 code units, not bytes). */ -function synthesizeMessageId(senderEmail: string): string { - const alphabet = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - const bytes = crypto.getRandomValues(new Uint8Array(36)); - const id = Array.from(bytes, (b) => alphabet[b % alphabet.length]).join(""); - const domain = senderEmail.slice(senderEmail.lastIndexOf("@") + 1); - return `<${id}@${domain}>`; +function contentByteLength( + content: string | ArrayBuffer | ArrayBufferView +): number { + if (typeof content === "string") { + return new TextEncoder().encode(content).byteLength; + } + return content.byteLength; +} + +function getAttachmentExtension(filename: string): string { + const extension = filename.match(/\.([^.]+)$/u)?.[1]; + return extension !== undefined && + /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(extension) + ? extension + : "bin"; } /** @@ -44,6 +67,16 @@ function formatEmailAddress(addr: string | EmailAddress): string { return `"${addr.name}" <${addr.email}>`; } +function formatParsedAddress(addr: { + address?: string; + name?: string; +}): string { + const email = addr.address ?? ""; + return addr.name === undefined || addr.name === "" + ? email + : `"${addr.name}" <${email}>`; +} + /** * Formats a MessageBuilder for logging */ @@ -70,64 +103,107 @@ function formatMessageBuilder(builder: MessageBuilder): string { return lines.join("\n"); } -/** - * Appends path segments to a base path using the separator already implied by - * the base path string. This trims trailing `/` and `\` from the base before - * joining, but does not otherwise normalize the full path. - */ -function joinPath(base: string, ...segments: string[]): string { - const separator = base.includes("\\") ? "\\" : "/"; - return [base.replace(/[\\/]+$/, ""), ...segments].join(separator); -} - -interface DiskServiceConfig { - location: "system" | "project"; - bindingName: string; - serviceName: string; - path: string; -} - interface SendEmailEnv { - email_disk_services: DiskServiceConfig[]; destinationAddress: string | undefined; allowedDestinationAddresses: string[] | undefined; allowedSenderAddresses: string[] | undefined; - MINIFLARE_EMAIL_DISK_SYSTEM: Fetcher; - MINIFLARE_EMAIL_DISK_PROJECT?: Fetcher; + MINIFLARE_LOOPBACK: Fetcher; + [CoreBindings.SERVICE_EMAIL_STORE]?: EmailStoreService; + /** Worker that owns this send_email binding, set when the local explorer is enabled. */ + SEND_EMAIL_OWNER_WORKER?: string; } export class SendEmailBinding extends WorkerEntrypoint { /** - * Gets a disk service binding by name + * Logs a message via the loopback `/core/log` endpoint. */ - private getServiceBinding(bindingName: string): Fetcher { - const binding = - this.env[ - bindingName as - | "MINIFLARE_EMAIL_DISK_SYSTEM" - | "MINIFLARE_EMAIL_DISK_PROJECT" - ]; - if (!binding) { - throw new Error(`Disk service binding not found: ${bindingName}`); + private async log( + message: string, + level: LogLevel = LogLevel.INFO + ): Promise { + await this.env.MINIFLARE_LOOPBACK.fetch("http://localhost/core/log", { + method: "POST", + headers: { [SharedHeaders.LOG_LEVEL]: level.toString() }, + body: message, + }); + } + + /** + * Warns (via the loopback log) that an oversized message was truncated for + * capture. Delivery is unaffected — only the Local Explorer copy is trimmed. + */ + private async warnTruncated(): Promise { + try { + await this.log( + `Email exceeds the ${MAX_LOCAL_EMAIL_BYTES}-byte local capture limit; the email was sent, but only the first ${MAX_LOCAL_EMAIL_BYTES} bytes are shown in the Local Explorer.`, + LogLevel.WARN + ); + } catch { + // Logging failures must not affect sending. } - return binding; } /** - * Logs a message via the runtime console. + * Captures a sent email into the local email store for the explorer. + * + * Capture is a dev-only inspection aid: any failure here (store unbound, + * RPC error) is swallowed so it never affects the result of `send()`. When + * the store is unbound (local explorer disabled) this is a no-op. Large raw + * bodies are streamed in 64 KB base64 slices to stay under workerd's RPC + * argument cap, mirroring the received path. */ - private log(message: string): void { - console.log(message); + private async reportSentEmail(email: StoredSendingEmail): Promise { + const store = this.env[CoreBindings.SERVICE_EMAIL_STORE]; + if (store === undefined) { + return; + } + try { + const rawBase64 = email.rawBase64; + if (rawBase64 !== undefined && rawBase64.length > 64 * 1024) { + const { raw: _raw, rawBase64: _rawBase64, ...metadata } = email; + const id = messageIdToStorageId(email.messageId); + await store.beginSent(metadata); + try { + for (let offset = 0; offset < rawBase64.length; offset += 64 * 1024) { + await store.appendSentRaw( + id, + rawBase64.slice(offset, offset + 64 * 1024) + ); + } + await store.finishSent(id); + return; + } catch (error) { + await store.discardSent(id).catch(() => undefined); + throw error; + } + } + await store.storeSent(email); + } catch { + try { + await this.log( + "Failed to capture sent email for the Local Explorer; the email was still sent.", + LogLevel.WARN + ); + } catch { + // Capture failures must not affect sending. + } + return; + } } /** - * Stores content to a temporary file via the disk service. + * Persists email content to a temp file via the loopback + * `/core/store-temp-file` endpoint and returns the on-disk path. + * + * Uses the email prefix so the file lands in the email directories and is + * mirrored into the project directory. + * + * `id` names the file. */ private async storeTempFile( content: string | ArrayBuffer | ArrayBufferView, extension: string, prefix: string, - location: "system" | "project" = "system", - messageUUID?: string + id: string ): Promise { let body: string | Uint8Array; if (typeof content === "string") { @@ -143,27 +219,27 @@ export class SendEmailBinding extends WorkerEntrypoint { ); } - const fileName = messageUUID - ? `${messageUUID}.${extension}` - : `${crypto.randomUUID()}.${extension}`; - const url = new URL(`${prefix}/${fileName}`, "http://placeholder/"); + const params = new URLSearchParams({ + prefix: `email/${prefix}`, + extension, + id, + }); - // Find the disk service config for the requested location. - const diskConfig = this.env.email_disk_services.find( - (config) => config.location === location + const resp = await this.env.MINIFLARE_LOOPBACK.fetch( + `http://localhost/core/store-temp-file?${params.toString()}`, + { + method: "POST", + body, + } ); - if (!diskConfig) { - throw new Error(`Disk service for ${location} not found`); + const text = await resp.text(); + if (!resp.ok) { + // A non-2xx body is an error message, not a path; surface it so the + // caller doesn't log an error string as if it were a file path. + throw new Error(`could not store email temporary file: ${text}`); } - - const service = this.getServiceBinding(diskConfig.bindingName); - await service.fetch(url, { - method: "PUT", - body, - }); - - return joinPath(diskConfig.path, prefix, fileName); + return text; } private checkDestinationAllowed(to: string) { @@ -230,7 +306,6 @@ export class SendEmailBinding extends WorkerEntrypoint { emailMessageOrBuilder: EmailMessage | MessageBuilder ): Promise { // Check if this is an EmailMessage (has RAW_EMAIL symbol) or MessageBuilder - const messageUUID: string = crypto.randomUUID(); if (this.isEmailMessage(emailMessageOrBuilder)) { // Original EmailMessage API - validate and parse MIME const emailMessage = emailMessageOrBuilder; @@ -273,30 +348,79 @@ export class SendEmailBinding extends WorkerEntrypoint { throw new Error("invalid headers set"); } - const locations = this.env.email_disk_services.map( - (service) => service.location - ); - const filePaths = await Promise.all( - locations.map((location) => - this.storeTempFile( + // Always synthesise new ID for user sent emails. + const messageId = synthesizeMessageId(emailMessage.from); + const id = messageIdToStorageId(messageId); + + // Capture only up to the local limit; delivery uses the full body. The + // captured copy (store record and on-disk .eml) is trimmed to keep the + // workerd-internal RPC argument under its ~1 MiB cap. + const capturedRaw = truncateRawForCapture(rawEmailBuffer); + if (capturedRaw.truncated) { + await this.warnTruncated(); + } + + // Complete the workerd-side capture before resolving send(). File writes + // remain deferred because they cross the Node loopback service. + await this.reportSentEmail({ + worker: this.env.SEND_EMAIL_OWNER_WORKER, + from: emailMessage.from, + to: [emailMessage.to], + cc: parsedEmail.cc?.map(formatParsedAddress), + bcc: parsedEmail.bcc?.map(formatParsedAddress), + replyTo: parsedEmail.replyTo + ? parsedEmail.replyTo.map(formatParsedAddress).join(", ") + : undefined, + subject: parsedEmail.subject ?? "(no subject)", + sentAt: new Date().toISOString(), + messageId, + headers: Object.fromEntries( + parsedEmail.headers.map(({ key, value }) => [key, value]) + ), + // `text`/`html` are derived views of the raw body (the full copy is + // preserved via `rawBase64`), and travel in the metadata prelude + // that precedes the streamed raw body. Cap them well under + // workerd's RPC argument limit so the prelude always fits. + text: + parsedEmail.text === undefined + ? undefined + : truncateStringForCapture(parsedEmail.text, 64 * 1024).value, + html: + parsedEmail.html === undefined + ? undefined + : truncateStringForCapture(parsedEmail.html, 64 * 1024).value, + attachments: (parsedEmail.attachments ?? []).map((attachment) => ({ + filename: attachment.filename ?? "attachment", + contentType: attachment.mimeType ?? "application/octet-stream", + disposition: + attachment.disposition === "inline" ? "inline" : "attachment", + size: contentByteLength(attachment.content), + })), + raw: capturedRaw.raw, + rawBase64: capturedRaw.rawBase64, + }); + + this.ctx.waitUntil( + (async () => { + const filePath = await this.storeTempFile( rawEmailBuffer, "eml", "email", - location, - messageUUID - ) - ) - ); - - // Log only project location if it exists, otherwise system location - const projectIndex = locations.indexOf("project"); - const logIndex = projectIndex !== -1 ? projectIndex : 0; - const fileInfo = `Email: ${filePaths[logIndex]}`; - this.log( - `${blue("send_email binding called with the following message:")}\n${fileInfo}` + id + ); + await this.log( + `${blue("send_email binding called with the following message:")}\nEmail: ${filePath}` + ); + })().catch(async (error: unknown) => { + try { + await this.log(`Failed to persist sent email: ${String(error)}`); + } catch { + // Logging failures must not create another unhandled rejection. + } + }) ); - return { messageId: synthesizeMessageId(emailMessage.from) }; + return { messageId }; } else { // New MessageBuilder API - just validate and log const builder = emailMessageOrBuilder; @@ -304,82 +428,125 @@ export class SendEmailBinding extends WorkerEntrypoint { // Validate the message builder this.validateMessageBuilder(builder); - // Store text, HTML content, and attachments to files for easy viewing - const locations = this.env.email_disk_services.map( - (service) => service.location - ); - const files: string[] = []; - - if (builder.text) { - const text = builder.text; - const textResults = await Promise.all( - locations.map((location) => - this.storeTempFile(text, "txt", "email-text", location, messageUUID) - ) - ); - // Log only project location if it exists, otherwise system location - const projectIndex = locations.indexOf("project"); - const logIndex = projectIndex !== -1 ? projectIndex : 0; - files.push(`Text: ${textResults[logIndex]}`); + // Always synthesise new ID for user sent emails. + const messageId = synthesizeMessageId(extractEmailAddress(builder.from)); + const id = messageIdToStorageId(messageId); + + function toDisplay( + addr: string | EmailAddress | (string | EmailAddress)[] + ): string[] { + return (Array.isArray(addr) ? addr : [addr]).map(formatEmailAddress); } - if (builder.html) { - const html = builder.html; - const htmlResults = await Promise.all( - locations.map((location) => - this.storeTempFile( - html, - "html", - "email-html", - location, - messageUUID + const sentAttachments: StoredEmailAttachment[] = ( + builder.attachments ?? [] + ).map((attachment) => ({ + filename: attachment.filename, + contentType: attachment.type, + disposition: attachment.disposition ?? "attachment", + size: contentByteLength(attachment.content), + })); + + // A MessageBuilder carries its `text`/`html` inline in the single + // `storeSent` RPC argument (there is no raw body to stream). They share + // a combined budget kept well under workerd's ~1 MiB RPC argument cap, + // so the whole record fits even with headers and attachment metadata: + // `text` is capped first, then `html` gets whatever budget remains. + // Delivery always uses the full, untruncated content. + const capturedText = + builder.text !== undefined + ? truncateStringForCapture(builder.text, MAX_CAPTURE_BODY_BYTES) + : undefined; + const capturedTextBytes = + capturedText === undefined + ? 0 + : new TextEncoder().encode(capturedText.value).byteLength; + const capturedHtml = + builder.html !== undefined + ? truncateStringForCapture( + builder.html, + MAX_CAPTURE_BODY_BYTES - capturedTextBytes ) - ) - ); - // Log only project location if it exists, otherwise system location - const projectIndex = locations.indexOf("project"); - const logIndex = projectIndex !== -1 ? projectIndex : 0; - files.push(`HTML: ${htmlResults[logIndex]}`); + : undefined; + if (capturedText?.truncated || capturedHtml?.truncated) { + await this.warnTruncated(); } - // Store attachments - if (builder.attachments) { - for (const attachment of builder.attachments) { - // Extract file extension from filename or use generic extension - const extMatch = attachment.filename.match(/\.([^.]+)$/); - const extension = extMatch ? extMatch[1] : "bin"; - const attachmentUUID = crypto.randomUUID(); - - const attachmentResults = await Promise.all( - locations.map((location) => - this.storeTempFile( + // Complete the workerd-side capture before resolving send() + await this.reportSentEmail({ + worker: this.env.SEND_EMAIL_OWNER_WORKER, + from: formatEmailAddress(builder.from), + to: toDisplay(builder.to), + cc: builder.cc ? toDisplay(builder.cc) : undefined, + bcc: builder.bcc ? toDisplay(builder.bcc) : undefined, + replyTo: builder.replyTo + ? formatEmailAddress(builder.replyTo) + : undefined, + subject: builder.subject ?? "(no subject)", + sentAt: new Date().toISOString(), + messageId, + text: capturedText?.value, + html: capturedHtml?.value, + headers: builder.headers, + attachments: sentAttachments, + }); + + // Persist file artifacts independently of the new email record. + this.ctx.waitUntil( + (async () => { + const files: string[] = []; + + if (builder.text) { + const textPath = await this.storeTempFile( + builder.text, + "txt", + "email-text", + id + ); + files.push(`Text: ${textPath}`); + } + + if (builder.html) { + const htmlPath = await this.storeTempFile( + builder.html, + "html", + "email-html", + id + ); + files.push(`HTML: ${htmlPath}`); + } + + if (builder.attachments) { + for (const [index, attachment] of builder.attachments.entries()) { + const extension = getAttachmentExtension(attachment.filename); + + const attachmentPath = await this.storeTempFile( attachment.content, extension, "email-attachment", - location, - attachmentUUID - ) - ) - ); - // Log only project location if it exists, otherwise system location - const projectIndex = locations.indexOf("project"); - const logIndex = projectIndex !== -1 ? projectIndex : 0; - files.push( - `Attachment (${attachment.disposition}): ${attachment.filename} -> ${attachmentResults[logIndex]}` + `${id}-${index + 1}` + ); + files.push( + `Attachment (${attachment.disposition}): ${attachment.filename} -> ${attachmentPath}` + ); + } + } + + const formatted = formatMessageBuilder(builder); + const fileInfo = files.length > 0 ? `\n\n${files.join("\n")}` : ""; + await this.log( + `${blue("send_email binding called with MessageBuilder:")}\n${formatted}${fileInfo}` ); - } - } - - // Format and log the message details with file paths - const formatted = formatMessageBuilder(builder); - const fileInfo = files.length > 0 ? `\n\n${files.join("\n")}` : ""; - this.log( - `${blue("send_email binding called with MessageBuilder:")}\n${formatted}${fileInfo}` + })().catch(async (error: unknown) => { + try { + await this.log(`Failed to persist sent email: ${String(error)}`); + } catch { + // Logging failures must not create another unhandled rejection. + } + }) ); - return { - messageId: synthesizeMessageId(extractEmailAddress(builder.from)), - }; + return { messageId }; } } } diff --git a/packages/miniflare/src/workers/email/storage.ts b/packages/miniflare/src/workers/email/storage.ts new file mode 100644 index 00000000000..f1e1dcb09cc --- /dev/null +++ b/packages/miniflare/src/workers/email/storage.ts @@ -0,0 +1,159 @@ +// Shared types for the local email store. +// +// Received ("routing") and sent ("sending") emails are captured at runtime and +// held in the instance-local email-store Durable Object. Workers push records +// over workerd-internal RPC, and the local explorer reads them back. Emails do +// not persist across dev-server restarts. +// +// This module also defines the shape of an `email()` handler's result (the +// `EmailHandler*` types), as returned by `/cdn-cgi/local/email?format=json` and +// captured for the local explorer's "Routing" view. A single event model +// describes everything the handler did to a message: `events` is the ordered +// lifecycle, and `forwards`/`replies` carry the full payload for each +// `forward`/`reply` event (correlated by `messageId`). This lets consumers +// render a timeline while still having the details on hand. + +import type { + EmailAttachment, + EmailHandlerForward, + EmailRoutingDetail, + EmailRoutingItem, + EmailSendingItem, + EmailSendingDetail, +} from "../local-explorer/generated"; + +export type { EmailHandlerForward }; + +export type EmailHandlerEvent = + | { + /** A message the handler forwarded or replied to. */ + type: "forward" | "reply"; + timestamp: string; + /** Correlates with the matching `forwards`/`replies` entry. */ + messageId: string; + } + | { + /** + * A lifecycle event with no associated message: `received` (first + * for any message actually delivered to an `email()` handler), + * `reject` (the handler called `setReject()`), or `unhandled` if + * the worker has no email() handler. + */ + type: "received" | "reject" | "unhandled"; + timestamp: string; + }; + +export interface EmailHandlerReply { + messageId: string; + /** Address the reply was sent from. */ + sender: string; + /** Raw MIME content of the reply. */ + raw: string; + /** Lossless base64 representation of the reply MIME. */ + rawBase64?: string; +} + +export interface EmailHandlerResult { + outcome: "ok" | "exception"; + /** Reason passed to `setReject()`, if the handler rejected the message. */ + rejectReason?: string; + forwards: EmailHandlerForward[]; + replies: EmailHandlerReply[]; + /** Ordered lifecycle of everything the handler did to the message. */ + events: EmailHandlerEvent[]; +} + +export type StoredRoutingEmail = Omit< + EmailRoutingDetail, + "forwards" | "replies" +> & + EmailHandlerResult; + +export type StoredRoutingEmailMetadata = Omit< + StoredRoutingEmail, + "raw" | "rawBase64" | "replies" +> & { + // Reply raw bodies are streamed separately (see the received chunk + // transport), so the metadata prelude carries only reply envelope fields. + replies: Array< + Omit + >; +}; + +export type StoredRoutingEmailRecord = Omit< + StoredRoutingEmail, + "raw" | "rawBase64" | "replies" +> & { + rawBase64: string; + // Reply raw bodies are stored base64-only; the decoded `raw` is + // materialised on read. + replies: Array< + Omit & { + raw?: string; + rawBase64?: string; + } + >; +}; + +export type StoredRoutingEmailSummary = Omit< + EmailRoutingItem, + "forwards" | "replies" +> & { + forwards: EmailHandlerForward[]; + replies: Array>; +}; + +export type StoredEmailAttachment = EmailAttachment; + +export type StoredSendingEmail = EmailSendingDetail; + +export type StoredSendingEmailSummary = EmailSendingItem; + +/** + * A sent email without its raw MIME body, used as the metadata prelude when + * streaming a large raw email's body to the store in chunks (mirrors + * `StoredRoutingEmailMetadata`). + */ +export type StoredSendingEmailMetadata = Omit< + StoredSendingEmail, + "raw" | "rawBase64" +>; + +export interface EmailListPage { + items: T[]; + cursor?: string; + hasMore: boolean; +} + +/** + * 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 + * capture (send_email, the receiving `email()` path) and read (local explorer) + * emails. + */ +export interface EmailStoreService { + storeReceived(email: StoredRoutingEmailRecord): Promise; + beginReceived(email: StoredRoutingEmailMetadata): Promise; + appendReceivedRaw(id: string, chunk: string): Promise; + appendReplyRaw(id: string, replyIndex: number, chunk: string): Promise; + finishReceived(id: string): Promise; + discardReceived(id: string): Promise; + /** Looks up a received email by its local storage ID. */ + findReceived(id: string): Promise; + listReceived( + cursor?: string, + limit?: number + ): Promise>; + storeSent(email: StoredSendingEmail): Promise; + beginSent(email: StoredSendingEmailMetadata): Promise; + appendSentRaw(id: string, chunk: string): Promise; + finishSent(id: string): Promise; + discardSent(id: string): Promise; + /** Looks up a sent email by its local storage ID. */ + findSent(id: string): Promise; + listSent( + cursor?: string, + limit?: number + ): Promise>; + clear(): Promise; +} diff --git a/packages/miniflare/src/workers/email/validate.ts b/packages/miniflare/src/workers/email/validate.ts index b9e0883cd82..bf87268cfa5 100644 --- a/packages/miniflare/src/workers/email/validate.ts +++ b/packages/miniflare/src/workers/email/validate.ts @@ -1,6 +1,6 @@ import { red } from "kleur/colors"; import PostalMime from "postal-mime"; -import { RAW_EMAIL } from "./constants"; +import { RAW_EMAIL } from "./capture"; import { type MiniflareEmailMessage as EmailMessage } from "./email.worker"; import type { Email } from "postal-mime"; @@ -64,7 +64,7 @@ export async function isEmailReplyable( export async function validateReply( incomingMessage: Email, replyMessage: EmailMessage -): Promise { +): Promise<{ raw: Uint8Array; messageId: string }> { const rawEmail: ReadableStream = replyMessage[RAW_EMAIL]; const rawEmailBuffer = new Uint8Array( @@ -130,8 +130,8 @@ export async function validateReply( // prepend References to be in the headers instead of the end of the body finalReplyEmail.set(encodedReferences, 0); finalReplyEmail.set(rawEmailBuffer, encodedReferences.byteLength); - return finalReplyEmail; + return { raw: finalReplyEmail, messageId: parsedReply.messageId }; } - return rawEmailBuffer; + return { raw: rawEmailBuffer, messageId: parsedReply.messageId }; } diff --git a/packages/miniflare/src/workers/index.ts b/packages/miniflare/src/workers/index.ts index 3cfe67ac4ba..95f1de03efb 100644 --- a/packages/miniflare/src/workers/index.ts +++ b/packages/miniflare/src/workers/index.ts @@ -1,5 +1,11 @@ export * from "./cache"; export * from "./core"; +export type { + EmailHandlerEvent, + EmailHandlerForward, + EmailHandlerReply, + EmailHandlerResult, +} from "./email/storage"; export * from "./kv"; export * from "./queues"; export * from "./shared"; diff --git a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts index e9b4b127867..acc02e6aa27 100644 --- a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts +++ b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts @@ -12,6 +12,9 @@ import { zD1RawDatabaseQueryData, zDurableObjectsNamespaceListObjectsData, zDurableObjectsNamespaceQuerySqliteData, + zEmailListRoutingData, + zEmailListSendingData, + zEmailSendRoutingData, zR2BucketDeleteObjectsData, zR2BucketListObjectsData, zWorkersKvNamespaceGetMultipleKeyValuePairsData, @@ -24,6 +27,14 @@ import { import openApiSpec from "./openapi.local.json"; import { listD1Databases, rawD1Database } from "./resources/d1"; import { listDONamespaces, listDOObjects, queryDOSqlite } from "./resources/do"; +import { + clearEmails, + getReceivedEmail, + getSentEmail, + listReceivedEmails, + listSentEmails, + sendTestEmail, +} from "./resources/email"; import { bulkGetKVValues, deleteKVValue, @@ -59,6 +70,7 @@ import type { import type { WorkerRegistry } from "../../shared/dev-registry-types"; import type { CoreBindings } from "../core"; import type { WorkerdDebugPortConnector } from "../core/dev-registry-proxy-shared.worker"; +import type { EmailStoreService } from "../email/storage"; import type { LocalExplorerWorker } from "./generated"; export type Env = { @@ -78,6 +90,9 @@ export type Env = { // Internal observability collector's read API — only bound when local // observability is enabled (see getExplorerServices). [CoreBindings.SERVICE_OBSERVABILITY_COLLECTOR]?: Fetcher; + // Email store RPC. Bound whenever the local explorer is enabled (see + // getExplorerServices). Backs the Email tab's routing/sending views. + [CoreBindings.SERVICE_EMAIL_STORE]?: EmailStoreService; }; export type AppBindings = { Bindings: Env }; @@ -366,6 +381,41 @@ app.post( app.post("/api/local/observability/clear", (c) => clearTraces(c)); +// ============================================================================ +// Email Endpoints +// ============================================================================ + +app.post("/api/local/email/clear", (c) => clearEmails(c)); + +app.get( + "/api/local/email/routing", + validateQuery(zEmailListRoutingData.shape.query.unwrap()), + (c) => { + const query = c.req.valid("query"); + return query.email_id === undefined + ? listReceivedEmails(c, query) + : getReceivedEmail(c, query.email_id, query.worker); + } +); + +app.post( + "/api/local/email/routing/send", + validateQuery(zEmailSendRoutingData.shape.query), + validateRequestBody(zEmailSendRoutingData.shape.body), + (c) => sendTestEmail(c, c.req.valid("json"), c.req.valid("query").worker) +); + +app.get( + "/api/local/email/sending", + validateQuery(zEmailListSendingData.shape.query.unwrap()), + (c) => { + const query = c.req.valid("query"); + return query.email_id === undefined + ? listSentEmails(c, query) + : getSentEmail(c, query.email_id, query.worker); + } +); + // ============================================================================ // Local Workers / Dev Registry Endpoint // ============================================================================ diff --git a/packages/miniflare/src/workers/local-explorer/generated/index.ts b/packages/miniflare/src/workers/local-explorer/generated/index.ts index 4533d808860..5700ebd6715 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/index.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/index.ts @@ -46,6 +46,36 @@ export type { DurableObjectsNamespaceQuerySqliteErrors, DurableObjectsNamespaceQuerySqliteResponse, DurableObjectsNamespaceQuerySqliteResponses, + EmailAttachment, + EmailBase, + EmailClearData, + EmailClearError, + EmailClearErrors, + EmailClearResponse, + EmailClearResponses, + EmailHandlerEvent, + EmailHandlerForward, + EmailHandlerReply, + EmailListRoutingData, + EmailListRoutingError, + EmailListRoutingErrors, + EmailListRoutingResponse, + EmailListRoutingResponses, + EmailListSendingData, + EmailListSendingError, + EmailListSendingErrors, + EmailListSendingResponse, + EmailListSendingResponses, + EmailRoutingDetail, + EmailRoutingItem, + EmailSendingDetail, + EmailSendingItem, + EmailSendRequest, + EmailSendRoutingData, + EmailSendRoutingError, + EmailSendRoutingErrors, + EmailSendRoutingResponse, + EmailSendRoutingResponses, LocalExplorerDoBinding, LocalExplorerListWorkersData, LocalExplorerListWorkersError, 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 3bf15682d9f..96a1f9ec6ca 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts @@ -607,6 +607,10 @@ export type LocalExplorerWorkerBindings = { * Workflow bindings */ workflows?: Array; + /** + * Send Email bindings + */ + sendEmail?: Array; }; export type LocalExplorerResourceBinding = { @@ -778,6 +782,208 @@ export type ObservabilityQueryResult = { rows: Array>; }; +/** + * 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. + */ +export type EmailHandlerEvent = + | { + /** + * The kind of event. + */ + type: "received" | "reject" | "unhandled"; + /** + * ISO 8601 timestamp of when the event occurred. + */ + timestamp: string; + } + | { + /** + * The kind of event. + */ + type: "forward" | "reply"; + /** + * ISO 8601 timestamp of when the event occurred. + */ + timestamp: string; + /** + * Correlates with the matching `forwards`/`replies` entry. + */ + messageId: string; + }; + +export type EmailHandlerForward = { + messageId: string; + /** + * Envelope recipient the message was forwarded to. + */ + recipient: string; + /** + * Headers added to the forwarded message. + */ + headers: Array<{ + name: string; + value: string; + }>; +}; + +export type EmailHandlerReply = { + messageId: string; + /** + * Address the reply was sent from. + */ + sender: string; + /** + * Raw MIME content of the reply. Omitted from the routing list; present on the detail response. + */ + raw?: string; + /** + * Lossless base64 representation of the reply MIME. + */ + rawBase64?: string; +}; + +export type EmailBase = { + /** + * Worker associated with the email, if known. + */ + worker?: string; + /** + * Envelope MAIL FROM address. + */ + from: string; + subject: string; + /** + * RFC Message-ID header value. Identifies the email in the store. + */ + messageId: string; + /** + * Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME. + */ + attachments: Array; +}; + +export type EmailRoutingItem = EmailBase & { + /** + * Envelope RCPT TO address. + */ + to: string; + receivedAt: string; + rawSize: number; + /** + * Whether the handler ran to completion or threw. + */ + outcome: "ok" | "exception"; + /** + * Reason passed to setReject(), if the handler rejected the message. + */ + rejectReason?: string; + forwards: Array; + replies: Array; + events: Array; +}; + +export type EmailRoutingDetail = EmailRoutingItem & { + /** + * Raw MIME content of the received email. + */ + raw: string; + /** + * Lossless base64 representation of the received MIME. + */ + rawBase64?: string; +}; + +/** + * Fields for composing a test email, mirroring MessageBuilder. + */ +export type EmailSendRequest = { + /** + * Sender address. + */ + from: string; + /** + * Recipient addresses. + */ + to: Array; + cc?: Array; + bcc?: Array; + replyTo?: string; + subject: string; + /** + * Plain text body. + */ + text?: string; + /** + * HTML body. + */ + html?: string; + /** + * Custom headers to include on the message. + */ + headers?: { + [key: string]: string; + }; + /** + * 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. + */ + attachments?: Array<{ + /** + * Name the attachment is presented under. + */ + filename: string; + /** + * MIME type of the attachment, e.g. 'application/pdf'. + */ + type: string; + /** + * Attachment content, base64-encoded. MessageBuilder takes raw bytes here, but this endpoint accepts JSON so the bytes must be base64-encoded. + */ + content: string; + /** + * Content-ID for an inline attachment. + */ + contentId?: string; + /** + * How the attachment is presented. Defaults to 'attachment'. + */ + disposition?: "inline" | "attachment"; + }>; +}; + +/** + * Metadata describing an attachment on a captured email, without its content. + */ +export type EmailAttachment = { + filename: string; + contentType: string; + disposition: "inline" | "attachment"; + size: number; +}; + +export type EmailSendingItem = EmailBase & { + to: Array; + cc?: Array; + bcc?: Array; + replyTo?: string; + sentAt: string; + headers?: { + [key: string]: string; + }; +}; + +export type EmailSendingDetail = EmailSendingItem & { + text?: string; + html?: string; + /** + * Raw MIME content, present when sent via the EmailMessage API. + */ + raw?: string; + /** + * Lossless base64 representation of sent MIME. + */ + rawBase64?: string; +}; + export type R2ResultInfoWritable = { [key: string]: unknown; }; @@ -1466,6 +1672,182 @@ export type LocalExplorerListWorkersResponses = { export type LocalExplorerListWorkersResponse = LocalExplorerListWorkersResponses[keyof LocalExplorerListWorkersResponses]; +export type EmailListRoutingData = { + body?: never; + path?: never; + query?: { + /** + * Only return emails received by this worker's email() handler. + */ + worker?: string; + /** + * Return the details for this email instead of a paginated list. + */ + email_id?: string; + /** + * Opaque cursor for the next page of emails. + */ + cursor?: string; + /** + * Number of emails per page. + */ + per_page?: number; + }; + url: "/local/email/routing"; +}; + +export type EmailListRoutingErrors = { + /** + * List received emails failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type EmailListRoutingError = + EmailListRoutingErrors[keyof EmailListRoutingErrors]; + +export type EmailListRoutingResponses = { + /** + * List received emails response. + */ + 200: WorkersApiResponseCommon & { + result?: Array | EmailRoutingDetail; + result_info?: { + count?: number; + cursor?: string; + per_page?: number; + has_more?: boolean; + }; + }; +}; + +export type EmailListRoutingResponse = + EmailListRoutingResponses[keyof EmailListRoutingResponses]; + +export type EmailSendRoutingData = { + body: EmailSendRequest; + path?: never; + query: { + /** + * Deliver the test email directly to this worker's email() handler. Required because a single dev port can serve multiple workers, so the target cannot be inferred from the recipient address. + */ + worker: string; + }; + url: "/local/email/routing/send"; +}; + +export type EmailSendRoutingErrors = { + /** + * Send test email failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type EmailSendRoutingError = + EmailSendRoutingErrors[keyof EmailSendRoutingErrors]; + +export type EmailSendRoutingResponses = { + /** + * Send test email response. + */ + 200: WorkersApiResponseCommon & { + result?: { + /** + * RFC Message-ID header value of the delivered test email. + */ + messageId?: string; + /** + * Whether the handler ran to completion or threw. + */ + outcome?: "ok" | "exception"; + /** + * Reason passed to setReject(), if the handler rejected the message. + */ + rejectReason?: string; + }; + }; +}; + +export type EmailSendRoutingResponse = + EmailSendRoutingResponses[keyof EmailSendRoutingResponses]; + +export type EmailClearData = { + body?: never; + path?: never; + query?: never; + url: "/local/email/clear"; +}; + +export type EmailClearErrors = { + /** + * Clear captured emails failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type EmailClearError = EmailClearErrors[keyof EmailClearErrors]; + +export type EmailClearResponses = { + /** + * Clear captured emails response. + */ + 200: WorkersApiResponseCommon; +}; + +export type EmailClearResponse = EmailClearResponses[keyof EmailClearResponses]; + +export type EmailListSendingData = { + body?: never; + path?: never; + query?: { + /** + * Only return emails sent through this worker's send_email bindings. + */ + worker?: string; + /** + * Return the details for this email instead of a paginated list. + */ + email_id?: string; + /** + * Opaque cursor for the next page of emails. + */ + cursor?: string; + /** + * Number of emails per page. + */ + per_page?: number; + }; + url: "/local/email/sending"; +}; + +export type EmailListSendingErrors = { + /** + * List sent emails failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type EmailListSendingError = + EmailListSendingErrors[keyof EmailListSendingErrors]; + +export type EmailListSendingResponses = { + /** + * List sent emails response. + */ + 200: WorkersApiResponseCommon & { + result?: Array | EmailSendingDetail; + result_info?: { + count?: number; + cursor?: string; + per_page?: number; + has_more?: boolean; + }; + }; +}; + +export type EmailListSendingResponse = + EmailListSendingResponses[keyof EmailListSendingResponses]; + export type WorkflowsListWorkflowsData = { body?: never; 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 998064d0c3d..6f12b7c3764 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts @@ -440,6 +440,7 @@ export const zLocalExplorerWorkerBindings = z.object({ r2: z.array(zLocalExplorerResourceBinding).optional(), do: z.array(zLocalExplorerDoBinding).optional(), workflows: z.array(zLocalExplorerWorkflowBinding).optional(), + sendEmail: z.array(zLocalExplorerResourceBinding).optional(), }); export const zLocalExplorerWorker = z.object({ @@ -528,6 +529,123 @@ export const zObservabilityQueryResult = z.object({ rows: z.array(z.array(z.unknown())), }); +/** + * 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. + */ +export const zEmailHandlerEvent = z.union([ + z.object({ + type: z.enum(["received", "reject", "unhandled"]), + timestamp: z.string(), + }), + z.object({ + type: z.enum(["forward", "reply"]), + timestamp: z.string(), + messageId: z.string(), + }), +]); + +export const zEmailHandlerForward = z.object({ + messageId: z.string(), + recipient: z.string(), + headers: z.array( + z.object({ + name: z.string(), + value: z.string(), + }) + ), +}); + +export const zEmailHandlerReply = z.object({ + messageId: z.string(), + sender: z.string(), + raw: z.string().optional(), + rawBase64: z.string().optional(), +}); + +/** + * Fields for composing a test email, mirroring MessageBuilder. + */ +export const zEmailSendRequest = z.object({ + from: z.string(), + to: z.array(z.string()).min(1), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + replyTo: z.string().optional(), + subject: z.string(), + text: z.string().optional(), + html: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + attachments: z + .array( + z.object({ + filename: z.string(), + type: z.string(), + content: z.string(), + contentId: z.string().optional(), + disposition: z.enum(["inline", "attachment"]).optional(), + }) + ) + .optional(), +}); + +/** + * Metadata describing an attachment on a captured email, without its content. + */ +export const zEmailAttachment = z.object({ + filename: z.string(), + contentType: z.string(), + disposition: z.enum(["inline", "attachment"]), + size: z.number(), +}); + +export const zEmailBase = z.object({ + worker: z.string().optional(), + from: z.string(), + subject: z.string(), + messageId: z.string(), + attachments: z.array(zEmailAttachment), +}); + +export const zEmailRoutingItem = zEmailBase.and( + z.object({ + to: z.string(), + receivedAt: z.string(), + rawSize: z.number(), + outcome: z.enum(["ok", "exception"]), + rejectReason: z.string().optional(), + forwards: z.array(zEmailHandlerForward), + replies: z.array(zEmailHandlerReply), + events: z.array(zEmailHandlerEvent), + }) +); + +export const zEmailRoutingDetail = zEmailRoutingItem.and( + z.object({ + raw: z.string(), + rawBase64: z.string().optional(), + }) +); + +export const zEmailSendingItem = zEmailBase.and( + z.object({ + 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 zEmailSendingDetail = zEmailSendingItem.and( + z.object({ + text: z.string().optional(), + html: z.string().optional(), + raw: z.string().optional(), + rawBase64: z.string().optional(), + }) +); + export const zR2ResultInfoWritable = z.record(z.string(), z.unknown()); export const zWorkersNamespaceWritable = z.object({ @@ -928,6 +1046,104 @@ export const zLocalExplorerListWorkersResponse = zWorkersApiResponseCommon.and( }) ); +export const zEmailListRoutingData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z + .object({ + worker: z.string().optional(), + email_id: z.string().optional(), + cursor: z.string().optional(), + per_page: z.number().gte(1).lte(100).optional().default(25), + }) + .optional(), +}); + +/** + * List received emails response. + */ +export const zEmailListRoutingResponse = zWorkersApiResponseCommon.and( + z.object({ + result: z + .union([z.array(zEmailRoutingItem), zEmailRoutingDetail]) + .optional(), + result_info: z + .object({ + count: z.number().optional(), + cursor: z.string().optional(), + per_page: z.number().optional(), + has_more: z.boolean().optional(), + }) + .optional(), + }) +); + +export const zEmailSendRoutingData = z.object({ + body: zEmailSendRequest, + path: z.never().optional(), + query: z.object({ + worker: z.string(), + }), +}); + +/** + * Send test email response. + */ +export const zEmailSendRoutingResponse = zWorkersApiResponseCommon.and( + z.object({ + result: z + .object({ + messageId: z.string().optional(), + outcome: z.enum(["ok", "exception"]).optional(), + rejectReason: z.string().optional(), + }) + .optional(), + }) +); + +export const zEmailClearData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional(), +}); + +/** + * Clear captured emails response. + */ +export const zEmailClearResponse = zWorkersApiResponseCommon; + +export const zEmailListSendingData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z + .object({ + worker: z.string().optional(), + email_id: z.string().optional(), + cursor: z.string().optional(), + per_page: z.number().gte(1).lte(100).optional().default(25), + }) + .optional(), +}); + +/** + * List sent emails response. + */ +export const zEmailListSendingResponse = zWorkersApiResponseCommon.and( + z.object({ + result: z + .union([z.array(zEmailSendingItem), zEmailSendingDetail]) + .optional(), + result_info: z + .object({ + count: z.number().optional(), + cursor: z.string().optional(), + per_page: z.number().optional(), + has_more: z.boolean().optional(), + }) + .optional(), + }) +); + export const zWorkflowsListWorkflowsData = z.object({ body: z.never().optional(), 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 bdff2fa1e49..61508c35f7d 100644 --- a/packages/miniflare/src/workers/local-explorer/openapi.local.json +++ b/packages/miniflare/src/workers/local-explorer/openapi.local.json @@ -1282,6 +1282,326 @@ "tags": ["Local Explorer"] } }, + "/local/email/routing": { + "get": { + "description": "Lists emails received by the worker's email() handler during this dev session, or returns one email's details when `email_id` is provided.", + "operationId": "email-list-routing", + "parameters": [ + { + "in": "query", + "name": "worker", + "schema": { + "type": "string" + }, + "description": "Only return emails received by this worker's email() handler." + }, + { + "in": "query", + "name": "email_id", + "schema": { + "type": "string" + }, + "description": "Return the details for this email instead of a paginated list." + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + }, + "description": "Opaque cursor for the next page of emails." + }, + { + "in": "query", + "name": "per_page", + "schema": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 25 + }, + "description": "Number of emails per page." + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "oneOf": [ + { + "items": { + "$ref": "#/components/schemas/email_routing-item" + }, + "type": "array" + }, + { + "$ref": "#/components/schemas/email_routing-detail" + } + ] + }, + "result_info": { + "type": "object", + "properties": { + "count": { + "type": "number" + }, + "cursor": { + "type": "string" + }, + "per_page": { + "type": "number" + }, + "has_more": { + "type": "boolean" + } + } + } + }, + "type": "object" + } + ] + } + } + }, + "description": "List received emails response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "List received emails failure." + } + }, + "summary": "List Received Emails", + "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.", + "operationId": "email-send-routing", + "parameters": [ + { + "in": "query", + "name": "worker", + "required": true, + "schema": { + "type": "string" + }, + "description": "Deliver the test email directly to this worker's email() handler. Required because a single dev port can serve multiple workers, so the target cannot be inferred from the recipient address." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/email_send-request" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "type": "object", + "properties": { + "messageId": { + "type": "string", + "description": "RFC Message-ID header value of the delivered test email." + }, + "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." + } + } + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Send test email response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Send test email failure." + } + }, + "summary": "Send Test Email", + "tags": ["Email"] + } + }, + "/local/email/clear": { + "post": { + "description": "Deletes all captured emails from this dev session.", + "operationId": "email-clear", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common" + } + } + }, + "description": "Clear captured emails response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Clear captured emails failure." + } + }, + "summary": "Clear Captured Emails", + "tags": ["Email"] + } + }, + "/local/email/sending": { + "get": { + "description": "Lists emails sent through send_email bindings during this dev session, or returns one email's details when `email_id` is provided.", + "operationId": "email-list-sending", + "parameters": [ + { + "in": "query", + "name": "worker", + "schema": { + "type": "string" + }, + "description": "Only return emails sent through this worker's send_email bindings." + }, + { + "in": "query", + "name": "email_id", + "schema": { + "type": "string" + }, + "description": "Return the details for this email instead of a paginated list." + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + }, + "description": "Opaque cursor for the next page of emails." + }, + { + "in": "query", + "name": "per_page", + "schema": { + "type": "number", + "minimum": 1, + "maximum": 100, + "default": 25 + }, + "description": "Number of emails per page." + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "oneOf": [ + { + "items": { + "$ref": "#/components/schemas/email_sending-item" + }, + "type": "array" + }, + { + "$ref": "#/components/schemas/email_sending-detail" + } + ] + }, + "result_info": { + "type": "object", + "properties": { + "count": { + "type": "number" + }, + "cursor": { + "type": "string" + }, + "per_page": { + "type": "number" + }, + "has_more": { + "type": "boolean" + } + } + } + }, + "type": "object" + } + ] + } + } + }, + "description": "List sent emails response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "List sent emails failure." + } + }, + "summary": "List Sent Emails", + "tags": ["Email"] + } + }, "/workflows": { "get": { "description": "Returns the workflows configured for local development.", @@ -3165,6 +3485,13 @@ "$ref": "#/components/schemas/local-explorer_workflow-binding" }, "description": "Workflow bindings" + }, + "sendEmail": { + "type": "array", + "items": { + "$ref": "#/components/schemas/local-explorer_resource-binding" + }, + "description": "Send Email bindings" } } }, @@ -3400,6 +3727,377 @@ } }, "required": ["columns", "rows"] + }, + "email_handler-event": { + "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.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["received", "reject", "unhandled"], + "description": "The kind of event." + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp of when the event occurred." + } + }, + "required": ["type", "timestamp"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["forward", "reply"], + "description": "The kind of event." + }, + "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"] + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "email_handler-forward": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "recipient": { + "type": "string", + "description": "Envelope recipient the message was forwarded to." + }, + "headers": { + "type": "array", + "description": "Headers added to the forwarded message.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": ["name", "value"] + } + } + }, + "required": ["messageId", "recipient", "headers"] + }, + "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"] + }, + "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. Identifies the email in the store." + }, + "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": ["messageId", "from", "subject", "attachments"] + }, + "email_routing-item": { + "allOf": [ + { + "$ref": "#/components/schemas/email_base" + }, + { + "type": "object", + "properties": { + "to": { + "type": "string", + "description": "Envelope RCPT TO address." + }, + "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": [ + "to", + "receivedAt", + "rawSize", + "outcome", + "forwards", + "replies", + "events" + ] + } + ] + }, + "email_routing-detail": { + "allOf": [ + { + "$ref": "#/components/schemas/email_routing-item" + }, + { + "type": "object", + "properties": { + "raw": { + "type": "string", + "description": "Raw MIME content of the received email." + }, + "rawBase64": { + "type": "string", + "description": "Lossless base64 representation of the received MIME." + } + }, + "required": ["raw"] + } + ] + }, + "email_send-request": { + "type": "object", + "description": "Fields for composing a test email, mirroring MessageBuilder.", + "properties": { + "from": { + "type": "string", + "description": "Sender address." + }, + "to": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "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", + "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.", + "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"] + } + } + }, + "required": ["from", "to", "subject"] + }, + "email_attachment": { + "type": "object", + "description": "Metadata describing an attachment on a captured email, without its content.", + "properties": { + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "disposition": { + "type": "string", + "enum": ["inline", "attachment"] + }, + "size": { + "type": "number" + } + }, + "required": ["filename", "contentType", "disposition", "size"] + }, + "email_sending-item": { + "allOf": [ + { + "$ref": "#/components/schemas/email_base" + }, + { + "type": "object", + "properties": { + "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": ["to", "sentAt"] + } + ] + }, + "email_sending-detail": { + "allOf": [ + { + "$ref": "#/components/schemas/email_sending-item" + }, + { + "type": "object", + "properties": { + "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." + } + } + } + ] } } } diff --git a/packages/miniflare/src/workers/local-explorer/resources/email.ts b/packages/miniflare/src/workers/local-explorer/resources/email.ts new file mode 100644 index 00000000000..3060fa40c88 --- /dev/null +++ b/packages/miniflare/src/workers/local-explorer/resources/email.ts @@ -0,0 +1,963 @@ +import { decodeWords } from "postal-mime"; +import { z } from "zod"; +import { CoreBindings, CorePaths } from "../../core"; +import { handleEmail } from "../../core/email"; +import { MAX_LOCAL_EMAIL_BYTES } from "../../email/capture"; +import { + getHeader, + messageIdToStorageId, + synthesizeMessageId, +} from "../../email/message-id"; +import { fetchFromPeer, getPeerUrlsIfAggregating } from "../aggregation"; +import { errorResponse, wrapResponse } from "../common"; +import { + zEmailHandlerEvent, + zEmailHandlerForward, + zEmailHandlerReply, + zEmailRoutingDetail, + zEmailRoutingItem, + zEmailSendingDetail, + zEmailSendingItem, + zLocalExplorerListWorkersResponse, +} from "../generated/zod.gen"; +import type { EmailListPage, EmailStoreService } from "../../email/storage"; +import type { AppContext } from "../common"; +import type { + EmailRoutingItem, + EmailSendRequest, + EmailSendingItem, +} from "../generated"; +import type { zEmailListRoutingData } from "../generated/zod.gen"; + +const EMAIL_ERROR_NOT_FOUND = 10601; +const EMAIL_ERROR_SEND_FAILED = 10602; +/** Occurs when the email store binding is missing (should not happen when the explorer is + * enabled, since the store is registered alongside it). */ +const EMAIL_ERROR_STORE_UNAVAILABLE = 10603; + +const zEmailHandlerResult = z.object({ + outcome: z.enum(["ok", "exception"]), + rejectReason: z.string().optional(), + forwards: z.array(zEmailHandlerForward), + replies: z.array(zEmailHandlerReply.extend({ raw: z.string() })), + events: z.array(zEmailHandlerEvent), +}); + +function getEmailStore(c: AppContext): EmailStoreService | undefined { + return c.env[CoreBindings.SERVICE_EMAIL_STORE]; +} + +function isFetcher(value: unknown): value is Fetcher { + return ( + typeof value === "object" && + value !== null && + "fetch" in value && + typeof value.fetch === "function" + ); +} + +/** Whether the given worker is served by this Miniflare instance. */ +function isLocalWorker(c: AppContext, worker: string): boolean { + return c.env[CoreBindings.JSON_LOCAL_EXPLORER_WORKER_NAMES].includes(worker); +} + +/** + * Resolves a direct service binding to a user worker in this instance, used to + * invoke that worker's `email()` handler for "Send Test Email". These bindings + * are registered per worker by `getExplorerServices` (see the + * `SERVICE_EXPLORER_USER_WORKER_PREFIX` bindings). + */ +function getUserWorkerService( + c: AppContext, + worker: string +): Fetcher | undefined { + const service = + c.env[`${CoreBindings.SERVICE_EXPLORER_USER_WORKER_PREFIX}${worker}`]; + return isFetcher(service) ? service : undefined; +} + +/** + * Keeps only the emails belonging to `worker`. Returns all with no 'worker' + */ +type EmailListQuery = z.output< + ReturnType +>; + +type EmailListItem = EmailRoutingItem | EmailSendingItem; +type EmailCursorState = Record; +type EmailCandidate = { + item: T; + nextCursor?: string; + hasMore: boolean; +}; +type PeerEmailPage = { + result?: T[]; + result_info?: { cursor?: string; has_more?: boolean }; +}; + +function parsePeerEmailList( + value: unknown, + itemSchema: z.ZodType +): PeerEmailPage { + return z + .object({ + result: z.array(itemSchema).optional(), + result_info: z + .object({ + cursor: z.string().optional(), + has_more: z.boolean().optional(), + }) + .optional(), + }) + .parse(value); +} + +function encodeAggregateCursor(state: EmailCursorState): string { + return `a.${btoa(JSON.stringify(state))}`; +} + +function isEmailCursorState(value: unknown): value is EmailCursorState { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every( + (cursor) => cursor === null || typeof cursor === "string" + ) + ); +} + +function isInvalidEmailCursor(error: unknown): boolean { + return ( + error instanceof TypeError && + error.message === "Invalid email pagination cursor" + ); +} + +function decodeAggregateCursor( + cursor: string | undefined, + sources: string[] +): EmailCursorState { + if (cursor === undefined) { + return {}; + } + try { + if (!cursor.startsWith("a.")) { + throw new Error("Invalid cursor"); + } + const state = JSON.parse(atob(cursor.slice(2))) as unknown; + if ( + !isEmailCursorState(state) || + Object.keys(state).some((source) => !sources.includes(source)) + ) { + throw new Error("Invalid cursor"); + } + return state; + } catch { + throw new TypeError("Invalid email pagination cursor"); + } +} + +function getEmailTimestamp(email: EmailListItem): string { + return "receivedAt" in email ? email.receivedAt : email.sentAt; +} + +function compareEmailItems(a: EmailListItem, b: EmailListItem): number { + const timestampOrder = getEmailTimestamp(b).localeCompare( + getEmailTimestamp(a) + ); + return timestampOrder || b.messageId.localeCompare(a.messageId); +} + +async function getNextLocalEmail( + list: (cursor?: string) => Promise>, + cursor: string | undefined, + worker: string | undefined +): Promise | undefined> { + let currentCursor = cursor; + for (;;) { + const page = await list(currentCursor); + const item = page.items[0]; + if (item === undefined) { + return undefined; + } + if (worker === undefined || item.worker === worker) { + return { + item, + nextCursor: page.cursor, + hasMore: page.hasMore, + }; + } + if (!page.hasMore || page.cursor === undefined) { + return undefined; + } + currentCursor = page.cursor; + } +} + +async function getNextPeerEmail( + peerUrl: string, + basePath: string, + cursor: string | undefined, + worker: string | undefined, + itemSchema: z.ZodType +): Promise | undefined> { + const params = new URLSearchParams({ per_page: "1" }); + if (cursor !== undefined) { + params.set("cursor", cursor); + } + if (worker !== undefined) { + params.set("worker", worker); + } + const response = await fetchFromPeer(peerUrl, `${basePath}?${params}`); + if (!response?.ok) { + return undefined; + } + try { + const data = parsePeerEmailList(await response.json(), itemSchema); + const item = data.result?.[0]; + return item === undefined + ? undefined + : { + item, + nextCursor: data.result_info?.cursor, + hasMore: data.result_info?.has_more ?? false, + }; + } catch { + return undefined; + } +} + +async function listAggregatedEmails(options: { + c: AppContext; + query: EmailListQuery; + basePath: string; + peerUrls: string[]; + localList: (cursor?: string) => Promise>; + itemSchema: z.ZodType; +}): Promise<{ + items: T[]; + cursor?: string; + hasMore: boolean; +}> { + const sourceUrls = ["local", ...options.peerUrls]; + const state = decodeAggregateCursor(options.query.cursor, sourceUrls); + const candidates = new Map>(); + + async function getCandidate(source: string) { + if (state[source] === null) { + return; + } + const candidate = + source === "local" + ? await getNextLocalEmail( + options.localList, + state[source], + options.query.worker + ) + : await getNextPeerEmail( + source, + options.basePath, + state[source], + options.query.worker, + options.itemSchema + ); + if (candidate === undefined) { + state[source] = null; + return; + } + candidates.set(source, candidate); + } + + await Promise.all(sourceUrls.map((source) => getCandidate(source))); + const items: T[] = []; + while (items.length < options.query.per_page && candidates.size > 0) { + const source = [...candidates.entries()].sort(([, a], [, b]) => + compareEmailItems(a.item, b.item) + )[0]?.[0]; + if (source === undefined) { + break; + } + const candidate = candidates.get(source); + if (candidate === undefined) { + break; + } + candidates.delete(source); + items.push(candidate.item); + const canContinue = candidate.hasMore && candidate.nextCursor !== undefined; + state[source] = canContinue ? candidate.nextCursor : null; + if (items.length < options.query.per_page && canContinue) { + await getCandidate(source); + } + } + + const hasMore = + candidates.size > 0 || + Object.values(state).some( + (cursor) => cursor !== null && cursor !== undefined + ); + return { + items, + hasMore, + ...(hasMore ? { cursor: encodeAggregateCursor(state) } : {}), + }; +} + +/** + * Finds the peer instance that serves `worker` by asking each peer which workers + * it hosts. Returns the peer's debug port address, or null when no peer owns it. + */ +async function findWorkerOwner( + c: AppContext, + peerUrls: string[], + worker: string +): Promise { + const responses = await Promise.all( + peerUrls.map(async (url) => { + const response = await fetchFromPeer(url, "/local/workers"); + if (!response?.ok) { + return null; + } + try { + const data = zLocalExplorerListWorkersResponse.parse( + await response.json() + ); + const owns = data.result?.some((w) => w.name === worker) ?? false; + return owns ? url : null; + } catch { + return null; + } + }) + ); + return responses.find((url) => url !== null) ?? null; +} + +function extractAddress(value: string): string { + const match = value.match(/<([^>]+)>/); + return (match ? match[1] : value).trim(); +} + +function hasUnsafeHeaderCharacters(value: string): boolean { + return /[\u0000-\u001f\u007f]/u.test(value); +} + +function decodeEmailHeaders(raw: string): string { + const separator = /\r?\n\r?\n/u.exec(raw); + if (separator?.index === undefined) { + return decodeWords(raw); + } + return `${decodeWords(raw.slice(0, separator.index))}${raw.slice(separator.index)}`; +} + +function isHeaderName(value: string): boolean { + return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(value); +} + +function isMimeType(value: string): boolean { + return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test( + value + ); +} + +function isBase64(value: string): boolean { + const normalized = value.replace(/\s/gu, ""); + if ( + normalized.length === 0 || + normalized.length % 4 !== 0 || + !/^[A-Za-z0-9+/]*={0,2}$/u.test(normalized) + ) { + return false; + } + try { + atob(normalized); + return true; + } catch { + return false; + } +} + +function validateEmailRequest(body: EmailSendRequest): string | undefined { + const headerValues = [ + body.from, + ...body.to, + ...(body.cc ?? []), + ...(body.bcc ?? []), + body.replyTo, + body.subject, + ].filter((value): value is string => value !== undefined); + if (headerValues.some(hasUnsafeHeaderCharacters)) { + return "Email fields must not contain control characters."; + } + + for (const [name, value] of Object.entries(body.headers ?? {})) { + if (!isHeaderName(name) || hasUnsafeHeaderCharacters(value)) { + return "Custom headers must use valid names and values."; + } + } + + for (const attachment of body.attachments ?? []) { + if ( + hasUnsafeHeaderCharacters(attachment.filename) || + (attachment.contentId !== undefined && + hasUnsafeHeaderCharacters(attachment.contentId)) || + !isMimeType(attachment.type) || + !isBase64(attachment.content) + ) { + return "Attachments must have valid filenames, MIME types, and base64 content."; + } + } + + return undefined; +} + +function buildMimeMessage(body: EmailSendRequest, messageId: string): string { + const headers: string[] = [`From: ${body.from}`, `To: ${body.to.join(", ")}`]; + if (body.cc?.length) { + headers.push(`Cc: ${body.cc.join(", ")}`); + } + if (body.replyTo) { + headers.push(`Reply-To: ${body.replyTo}`); + } + headers.push(`Subject: ${body.subject}`); + headers.push(`Message-ID: ${messageId}`); + headers.push(`Date: ${new Date().toUTCString()}`); + headers.push("MIME-Version: 1.0"); + + // Custom headers last so they can override defaults if intentionally set. + // Some headers are skipped because they are generated below to describe the + // actual body: a caller-supplied Message-ID is already emitted above (as + // `messageId`), and `Content-Type`/`Content-Transfer-Encoding` are set by the + // content/attachment assembly that follows — honouring a caller's value would + // emit a second, conflicting header ahead of the one that matches the body. + const managedHeaders = new Set([ + "message-id", + "content-type", + "content-transfer-encoding", + ]); + for (const [key, value] of Object.entries(body.headers ?? {})) { + if (managedHeaders.has(key.toLowerCase())) { + continue; + } + headers.push(`${key}: ${value}`); + } + + const text = body.text ?? ""; + const html = body.html; + + let contentHeaders: string[]; + let content: string; + + if (html && body.text) { + const boundary = `----=_Part_${crypto.randomUUID()}`; + contentHeaders = [ + `Content-Type: multipart/alternative; boundary="${boundary}"`, + ]; + content = [ + `--${boundary}`, + "Content-Type: text/plain; charset=utf-8", + "", + text, + `--${boundary}`, + "Content-Type: text/html; charset=utf-8", + "", + html, + `--${boundary}--`, + "", + ].join("\r\n"); + } else if (html) { + contentHeaders = ["Content-Type: text/html; charset=utf-8"]; + content = html; + } else { + contentHeaders = ["Content-Type: text/plain; charset=utf-8"]; + content = text; + } + + const attachments = body.attachments ?? []; + if (attachments.length === 0) { + headers.push(...contentHeaders); + return `${headers.join("\r\n")}\r\n\r\n${content}`; + } + + const boundary = `----=_Mixed_${crypto.randomUUID()}`; + headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); + + const parts: string[] = [`--${boundary}`, ...contentHeaders, "", content]; + for (const attachment of attachments) { + const filename = attachment.filename + .replace(/[\r\n]/g, " ") + .replace(/(["\\])/g, "\\$1"); + parts.push( + `--${boundary}`, + `Content-Type: ${attachment.type}; name="${filename}"`, + `Content-Disposition: ${attachment.disposition ?? "attachment"}; filename="${filename}"`, + "Content-Transfer-Encoding: base64", + ...(attachment.disposition === "inline" && attachment.contentId + ? [ + `Content-ID: ${attachment.contentId.startsWith("<") ? attachment.contentId : `<${attachment.contentId}>`}`, + ] + : []), + "", + // RFC 2045 caps base64 body lines at 76 characters. + attachment.content + .replace(/\s/g, "") + .replace(/(.{76})/g, "$1\r\n") + .trimEnd() + ); + } + parts.push(`--${boundary}--`, ""); + + return `${headers.join("\r\n")}\r\n\r\n${parts.join("\r\n")}`; +} + +export async function listReceivedEmails( + c: AppContext, + query: EmailListQuery +): Promise { + const store = getEmailStore(c); + if (!store) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Email store is not available for this dev session." + ); + } + try { + const peerUrls = await getPeerUrlsIfAggregating(c); + if (query.worker === undefined && peerUrls.length === 0) { + const localCursor = query.cursor?.startsWith("a.") + ? (decodeAggregateCursor(query.cursor, ["local"]).local ?? undefined) + : query.cursor; + const page = await store.listReceived(localCursor, query.per_page); + return c.json({ + ...wrapResponse(zEmailRoutingItem.array().parse(page.items)), + result_info: { + count: page.items.length, + per_page: query.per_page, + has_more: page.hasMore, + ...(page.cursor === undefined + ? {} + : { cursor: encodeAggregateCursor({ local: page.cursor }) }), + }, + }); + } + const page = await listAggregatedEmails({ + c, + query, + basePath: "/local/email/routing", + peerUrls, + itemSchema: zEmailRoutingItem, + localList: async (cursor) => { + const result = await store.listReceived(cursor, 1); + return { + ...result, + items: zEmailRoutingItem.array().parse(result.items), + }; + }, + }); + return c.json({ + ...wrapResponse(page.items), + result_info: { + count: page.items.length, + per_page: query.per_page, + has_more: page.hasMore, + ...(page.cursor === undefined ? {} : { cursor: page.cursor }), + }, + }); + } catch (error) { + if (!isInvalidEmailCursor(error)) { + throw error; + } + return errorResponse(400, 10000, "Invalid email pagination cursor"); + } +} + +export async function clearEmails(c: AppContext): Promise { + const store = getEmailStore(c); + if (!store) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Email store is not available for this dev session." + ); + } + await store.clear(); + const peerUrls = await getPeerUrlsIfAggregating(c); + const peerResponses = await Promise.all( + peerUrls.map((url) => + fetchFromPeer(url, "/local/email/clear", { method: "POST" }) + ) + ); + if (peerResponses.some((response) => response === null || !response.ok)) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Failed to clear email data on all dev session instances." + ); + } + const response = await c.env.MINIFLARE_LOOPBACK.fetch( + "http://localhost/core/clear-email-temp-files", + { method: "POST" } + ); + if (!response.ok) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Failed to clear email temporary files." + ); + } + return c.json(wrapResponse({})); +} + +export async function getReceivedEmail( + c: AppContext, + emailId: string, + worker?: string +): Promise { + const store = getEmailStore(c); + if (!store) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Email store is not available for this dev session." + ); + } + const email = await store.findReceived(messageIdToStorageId(emailId)); + 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); + } + // Decode MIME "encoded-word" headers (e.g. `=?utf-8?B?...?=`) in each reply's + // display text so the explorer shows readable subjects. + const decoded = { + ...email, + replies: email.replies.map((reply) => ({ + ...reply, + raw: decodeEmailHeaders(reply.raw), + })), + }; + return c.json(wrapResponse(zEmailRoutingDetail.parse(decoded))); +} + +/** + * Looks up an email by id on peer instances. When a `worker` is selected we ask + * the peer that owns it; otherwise (the unfiltered view) we broadcast the lookup + * to every peer and return the first hit, so a peer-owned email can still be + * opened when no worker is selected. + * + * @param basePath - The peer API path for the email endpoint, e.g. + * `/local/email/routing` or `/local/email/sending`. + */ +async function findEmailOnPeers( + c: AppContext, + basePath: string, + emailId: string, + worker: string | undefined +): Promise { + const params = new URLSearchParams({ email_id: emailId }); + if (worker !== undefined) { + params.set("worker", worker); + } + const query = `?${params}`; + + if (worker !== undefined) { + // A specific worker is selected: only the owning peer can hold it. + if (!isLocalWorker(c, worker)) { + const owner = await findWorkerOwner( + c, + await getPeerUrlsIfAggregating(c), + worker + ); + if (owner) { + const response = await fetchFromPeer(owner, `${basePath}${query}`); + if (response?.ok) { + return response; + } + } + } + } else { + // Unfiltered view: the email could live on any peer, so ask them all and + // return the first that has it. + const peerUrls = await getPeerUrlsIfAggregating(c); + const responses = await Promise.all( + peerUrls.map((url) => fetchFromPeer(url, `${basePath}${query}`)) + ); + const found = responses.find((response) => response?.ok); + if (found) { + return found; + } + } + + return errorResponse( + 404, + EMAIL_ERROR_NOT_FOUND, + `Email '${emailId}' not found.` + ); +} + +/** + * Proxies a received-email lookup to a peer. Used when the email is not held by + * this instance's store. + */ +async function getReceivedEmailFromPeers( + c: AppContext, + emailId: string, + worker: string | undefined +): Promise { + return findEmailOnPeers(c, "/local/email/routing", emailId, worker); +} + +/** + * Delivers a built test email to the selected worker's `email()` handler. + * + * Resolves a direct service binding to the target worker and invokes + * `handleEmail`, which avoids routing the delivery back through the entry + * worker. A worker is always required: a single dev port can serve multiple + * workers, so the target cannot be inferred from the recipient address. + * + * Returns the delivery `Response`, or `undefined` when the selected worker has + * no direct binding on this instance. + */ +async function deliverTestEmail( + c: AppContext, + email: { + from: string; + to: string; + id: string; + mime: string; + worker: string; + } +): Promise { + const { from, to, id, mime, worker } = email; + + const deliverUrl = new URL(CorePaths.EMAIL, "http://localhost"); + deliverUrl.searchParams.set("from", from); + deliverUrl.searchParams.set("to", to); + deliverUrl.searchParams.set("id", id); + // Request the JSON result so we can surface the handler outcome (including a + // `setReject()` reason) instead of just a text status. + deliverUrl.searchParams.set("format", "json"); + + const targetService = getUserWorkerService(c, worker); + if (targetService === undefined) { + return undefined; + } + const deliverRequest = new Request(deliverUrl, { + method: "POST", + body: mime, + }); + return handleEmail( + deliverUrl.searchParams, + deliverRequest, + targetService, + worker, + c.env, + // 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 + ); +} + +/** + * Sends a test email to trigger the worker's email() handler. + */ +export async function sendTestEmail( + c: AppContext, + body: EmailSendRequest, + worker?: string +): Promise { + const invalidRequest = validateEmailRequest(body); + if (invalidRequest !== undefined) { + return errorResponse(400, 10000, invalidRequest); + } + + // A target worker is required: a single dev port can serve multiple workers, + // so the recipient address alone cannot identify which email() handler to + // invoke. + if (worker === undefined) { + return errorResponse(400, 10000, "A target worker is required."); + } + + // When the selected worker lives in another Miniflare instance, forward the + // whole send to the instance that owns it. + if (!isLocalWorker(c, worker)) { + const owner = await findWorkerOwner( + c, + await getPeerUrlsIfAggregating(c), + worker + ); + if (owner) { + const response = await fetchFromPeer( + owner, + `/local/email/routing/send?worker=${encodeURIComponent(worker)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); + if (response) { + return response; + } + } + return errorResponse( + 400, + EMAIL_ERROR_SEND_FAILED, + `Worker '${worker}' is not available in this dev session.` + ); + } + + const from = extractAddress(body.from); + const to = extractAddress(body.to[0] ?? ""); + + if (!to) { + return errorResponse(400, 10000, "At least one recipient is required."); + } + + // Derive the Message-ID exactly as the `send_email` binding does. + const messageId = + getHeader(body.headers, "Message-ID") ?? synthesizeMessageId(from); + const id = messageIdToStorageId(messageId); + const mime = buildMimeMessage(body, messageId); + if (new TextEncoder().encode(mime).byteLength > MAX_LOCAL_EMAIL_BYTES) { + return errorResponse( + 400, + EMAIL_ERROR_SEND_FAILED, + "Email message exceeds the 1 MiB local development limit." + ); + } + + const response = await deliverTestEmail(c, { from, to, id, mime, worker }); + if (response === undefined) { + return errorResponse( + 400, + EMAIL_ERROR_SEND_FAILED, + `Worker '${worker}' is not available in this dev session.` + ); + } + + // A 4xx means the message itself was invalid (bad envelope, unparseable, or + // too large) and never reached the handler — that's a send failure. Anything + // else (including a handler that rejected or threw) counts as delivered. + if (response.status >= 400 && response.status < 500) { + const message = await response.text(); + return errorResponse( + 400, + EMAIL_ERROR_SEND_FAILED, + message || "Failed to deliver test email." + ); + } + + const result = zEmailHandlerResult.parse(await response.json()); + return c.json( + wrapResponse({ + messageId, + outcome: result.outcome, + ...(result.rejectReason !== undefined + ? { rejectReason: result.rejectReason } + : {}), + }) + ); +} + +export async function listSentEmails( + c: AppContext, + query: EmailListQuery +): Promise { + const store = getEmailStore(c); + if (!store) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Email store is not available for this dev session." + ); + } + try { + const peerUrls = await getPeerUrlsIfAggregating(c); + if (query.worker === undefined && peerUrls.length === 0) { + const localCursor = query.cursor?.startsWith("a.") + ? (decodeAggregateCursor(query.cursor, ["local"]).local ?? undefined) + : query.cursor; + const page = await store.listSent(localCursor, query.per_page); + return c.json({ + ...wrapResponse(zEmailSendingItem.array().parse(page.items)), + result_info: { + count: page.items.length, + per_page: query.per_page, + has_more: page.hasMore, + ...(page.cursor === undefined + ? {} + : { cursor: encodeAggregateCursor({ local: page.cursor }) }), + }, + }); + } + const page = await listAggregatedEmails({ + c, + query, + basePath: "/local/email/sending", + peerUrls, + itemSchema: zEmailSendingItem, + localList: async (cursor) => { + const result = await store.listSent(cursor, 1); + return { + ...result, + items: zEmailSendingItem.array().parse(result.items), + }; + }, + }); + return c.json({ + ...wrapResponse(page.items), + result_info: { + count: page.items.length, + per_page: query.per_page, + has_more: page.hasMore, + ...(page.cursor === undefined ? {} : { cursor: page.cursor }), + }, + }); + } catch (error) { + if (!isInvalidEmailCursor(error)) { + throw error; + } + return errorResponse(400, 10000, "Invalid email pagination cursor"); + } +} + +export async function getSentEmail( + c: AppContext, + emailId: string, + worker?: string +): Promise { + const store = getEmailStore(c); + if (!store) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Email store is not available for this dev session." + ); + } + const email = await store.findSent(messageIdToStorageId(emailId)); + if (!email || (worker !== undefined && email.worker !== worker)) { + // The email may have been sent by a worker in another Miniflare instance; + // look it up there before giving up. + return getSentEmailFromPeers(c, emailId, worker); + } + return c.json(wrapResponse(zEmailSendingDetail.parse(email))); +} + +/** + * Proxies a sent-email lookup to a peer. Used when the email is not held by this + * instance's store. + */ +async function getSentEmailFromPeers( + c: AppContext, + emailId: string, + worker: string | undefined +): Promise { + return findEmailOnPeers(c, "/local/email/sending", emailId, worker); +} diff --git a/packages/miniflare/src/workers/local-explorer/route-names.ts b/packages/miniflare/src/workers/local-explorer/route-names.ts index 1aa3c0464ff..9897f7389c5 100644 --- a/packages/miniflare/src/workers/local-explorer/route-names.ts +++ b/packages/miniflare/src/workers/local-explorer/route-names.ts @@ -30,6 +30,12 @@ const ROUTE_PATTERNS: [RegExp, string][] = [ [/^\/workflows$/, "workflows.list"], [/^\/local\/observability\/query$/, "observability.query"], [/^\/local\/observability\/clear$/, "observability.clear"], + [/^\/local\/email\/clear$/, "email.clear"], + [/^\/local\/email\/routing\/send$/, "email.routing.send"], + [/^\/local\/email\/routing\/[^/]+$/, "email.routing.details"], + [/^\/local\/email\/routing$/, "email.routing.list"], + [/^\/local\/email\/sending\/[^/]+$/, "email.sending.details"], + [/^\/local\/email\/sending$/, "email.sending.list"], [/^\/local\/workers$/, "local.workers"], ]; diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index 1a0b9f213fe..57dbc54878c 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -2475,6 +2475,52 @@ This is a random email body. expect(await res.text()).toBe("false"); }); +test("Miniflare: manually triggered email handler - missing email() handler", async ({ + expect, +}) => { + const log = new TestLog(); + + const mf = new Miniflare({ + log, + unsafeTriggerHandlers: true, + workers: [ + { + config: { + type: "worker", + name: "", + compatibilityDate: "2025-05-01", + manifest: singleModuleManifest(` + export default { + fetch() { + return new Response("ok"); + } + }`), + }, + }, + ], + }); + useDispose(mf); + + const res = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?from=someone@example.com&to=someone-else@example.com", + { + body: `From: someone +To: someone else +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This is a random email body. +`, + method: "POST", + } + ); + expect(await res.text()).toBe( + "Worker does not export an email() handler; message stored without delivery." + ); + expect(res.status).toBe(500); +}); + test("Miniflare: manually triggered email handler - reply handler works", async ({ expect, }) => { @@ -2606,7 +2652,7 @@ test("Miniflare: manually triggered email handler - structured result", async ({ rejectReason?: string; forwards: { recipient: string; - headers: [string, string][]; + headers: { name: string; value: string }[]; messageId: string; }[]; replies: { messageId: string; sender: string; raw: string }[]; @@ -2616,7 +2662,10 @@ test("Miniflare: manually triggered email handler - structured result", async ({ timestamp: string; messageId: string; } - | { type: "reject"; timestamp: string } + | { + type: "received" | "reject" | "unhandled"; + timestamp: string; + } )[]; }; } @@ -2627,7 +2676,7 @@ test("Miniflare: manually triggered email handler - structured result", async ({ forwards: [ { recipient: "archive@example.com", - headers: [["x-test", "ok"]], + headers: [{ name: "x-test", value: "ok" }], messageId: expect.any(String), }, ], @@ -2640,6 +2689,10 @@ test("Miniflare: manually triggered email handler - structured result", async ({ ], }); expect(okResult.events).toEqual([ + { + type: "received", + timestamp: expect.any(String), + }, { type: "forward", timestamp: expect.any(String), @@ -2660,6 +2713,7 @@ test("Miniflare: manually triggered email handler - structured result", async ({ replies: [], }); expect(rejectedResult.events).toEqual([ + { type: "received", timestamp: expect.any(String) }, { type: "reject", timestamp: expect.any(String) }, ]); @@ -2670,7 +2724,7 @@ test("Miniflare: manually triggered email handler - structured result", async ({ forwards: [ { recipient: "archive@example.com", - headers: [["x-test", "exception"]], + headers: [{ name: "x-test", value: "exception" }], }, ], replies: [ @@ -2682,6 +2736,7 @@ test("Miniflare: manually triggered email handler - structured result", async ({ ], }); expect(exceptionResult.events).toEqual([ + { type: "received", timestamp: expect.any(String) }, { type: "forward", timestamp: expect.any(String), diff --git a/packages/miniflare/test/plugins/email/index.spec.ts b/packages/miniflare/test/plugins/email/index.spec.ts index 890cb0af66d..b4063fa34d1 100644 --- a/packages/miniflare/test/plugins/email/index.spec.ts +++ b/packages/miniflare/test/plugins/email/index.spec.ts @@ -1,4 +1,4 @@ -import fs, { existsSync } from "node:fs"; +import { existsSync } from "node:fs"; import { mkdir, readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { @@ -1480,7 +1480,7 @@ test("MessageBuilder log output format snapshot", async ({ expect }) => { .replace(/\x1b\[[0-9;]*m/g, "") // Replace dynamic file paths with placeholders (Unix and Windows) .replace( - /(?:[A-Z]:\\|\/)[^\s]*[/\\](email-text|email-html|email-attachment)[/\\][a-f0-9-]+\.(txt|html|png|pdf)/g, + /(?:[A-Z]:\\|\/)[^\s]*[/\\](email-text|email-html|email-attachment)[/\\][^/\\\s]+\.(txt|html|png|pdf)/g, "/$1/[FILE].$2" ); @@ -2187,10 +2187,10 @@ const SEND_EMAIL_RETURNS_RESULT_WORKER = dedent /* javascript */ ` `; // Both branches return an id in the shape production returns: -// `<{36 alphanumeric chars}@{sender domain}>`, angle brackets included. +// `<{base36 random}@{sender domain}>`, angle brackets included. function synthesizedMessageId(expect: ExpectStatic, domain: string) { return expect.stringMatching( - new RegExp(`^<[A-Za-z0-9]{36}@${domain.replace(/\./g, "\\.")}>$`) + new RegExp(`^<[A-Za-z0-9]+@${domain.replace(/\./g, "\\.")}>$`) ); } @@ -2237,6 +2237,51 @@ test("send() on an EmailMessage returns a synthesized messageId", async ({ }); }); +test("send() on an EmailMessage larger than 1 MiB still succeeds", async ({ + expect, +}) => { + const mf = new Miniflare({ + workers: [ + { + config: { + type: "worker", + name: "", + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest(SEND_EMAIL_RETURNS_RESULT_WORKER), + env: { SEND_EMAIL: { type: "send-email" } }, + }, + }, + ], + }); + + useDispose(mf); + + const email = + [ + "From: someone ", + "To: someone else ", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "x".repeat(2 * 1024 * 1024), + ].join("\r\n") + "\r\n"; + + const res = await mf.dispatchFetch( + "http://localhost/?" + + new URLSearchParams({ + from: "someone@sender.domain", + to: "someone-else@example.com", + }).toString(), + { body: email, method: "POST" } + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + messageId: synthesizedMessageId(expect, "sender.domain"), + }); +}); + test("send() on a MessageBuilder returns a synthesized messageId", async ({ expect, }) => { @@ -2280,6 +2325,49 @@ test("send() on a MessageBuilder returns a synthesized messageId", async ({ }); }); +test("send() on a MessageBuilder larger than 1 MiB still succeeds", async ({ + expect, +}) => { + const mf = new Miniflare({ + workers: [ + { + config: { + type: "worker", + name: "", + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest(dedent /* javascript */ ` + export default { + async fetch(request, env) { + const builder = await request.json(); + const result = await env.SEND_EMAIL.send(builder); + return Response.json(result); + }, + }; + `), + env: { SEND_EMAIL: { type: "send-email" } }, + }, + }, + ], + }); + + useDispose(mf); + + const res = await mf.dispatchFetch("http://localhost", { + method: "POST", + body: JSON.stringify({ + from: "sender@sender.domain", + to: "recipient@example.com", + subject: "Large builder", + text: "y".repeat(2 * 1024 * 1024), + }), + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + messageId: synthesizedMessageId(expect, "sender.domain"), + }); +}); + test("send_email binding is available from getBindings", async ({ expect }) => { const mf = new Miniflare({ workers: [ @@ -2331,49 +2419,72 @@ test("disposing does not remove a concurrent email session", async ({ type: "worker", name: "", compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(""), + manifest: singleModuleManifest(SEND_EMAIL_WORKER), env: { SEND_EMAIL: { type: "send-email" } }, }, }, ], }); + let disposed = false; + + try { + // Sending an email creates this instance's project email session + // directory under `/email/`. + const email = dedent` + From: someone + To: someone else + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain + + Creates a project email session`; + const response = await mf.dispatchFetch( + "http://localhost/?" + + new URLSearchParams({ + from: "someone@example.com", + to: "someone-else@example.com", + }).toString(), + { method: "POST", body: email } + ); + expect(await response.text()).toBe("ok"); - await mf.getBindings(); - - const emailParentPath = path.join(projectTmpPath, "email"); - const [sessionName] = await readdir(emailParentPath); - if (sessionName === undefined) { - throw new Error("Expected an email session directory"); - } - const concurrentSessionPath = path.join( - emailParentPath, - "concurrent-session" - ); - await mkdir(concurrentSessionPath); - - // A separate emptiness check reintroduces the race. Return a stale result so - // regressing to read-then-remove would delete the concurrent session. - const readdirSpy = vi.spyOn(fs.promises, "readdir").mockResolvedValueOnce([]); + const emailParentPath = path.join(projectTmpPath, "email"); + const sessionName = await vi.waitFor(async () => { + const sessions = await readdir(emailParentPath); + if (sessions[0] === undefined) { + throw new Error("Expected an email session directory"); + } + return sessions[0]; + }); + const concurrentSessionPath = path.join( + emailParentPath, + "concurrent-session" + ); + await mkdir(concurrentSessionPath); - await mf.dispose(); + await mf.dispose(); + disposed = true; - expect(readdirSpy).not.toHaveBeenCalled(); - expect(existsSync(concurrentSessionPath)).toBe(true); + expect(existsSync(path.join(emailParentPath, sessionName))).toBe(false); + expect(existsSync(concurrentSessionPath)).toBe(true); + } finally { + if (!disposed) { + await mf.dispose(); + } + } }); describe("EMAIL_PLUGIN.getServices", () => { - test("creates disk services for system temp and project directories", async ({ - expect, - }) => { + test("creates a worker-scoped send_email service", async ({ expect }) => { const tmp = await useTmp(); - const projectTmpPath = path.join(tmp, ".wrangler", "tmp"); const result = await EMAIL_PLUGIN.getServices({ options: { config: { env: { SEND_EMAIL: { type: "send-email" } } }, }, - sharedOptions: { resourceTmpPath: projectTmpPath }, + sharedOptions: {}, tmpPath: tmp, + resourceTmpPath: undefined, workerNames: ["default"], workerIndex: 0, } as unknown as Parameters[0]); @@ -2383,80 +2494,23 @@ describe("EMAIL_PLUGIN.getServices", () => { } const services = result; - expect(services).toHaveLength(3); - - const diskServices = services.filter((s) => "disk" in s) as Array<{ - name: string; - disk: { path: string; writable?: boolean }; - }>; - expect(diskServices).toHaveLength(2); - - const systemTempDisk = diskServices.find( - (s) => s.name === "email:disk:system" - ); - const projectDisk = diskServices.find( - (s) => s.name === "email:disk:project" - ); - if (!systemTempDisk || !projectDisk) { - throw new Error("Expected both disk services to be present"); - } - - // System temp directory - expect(systemTempDisk.disk.path).toBe(path.join(tmp, "email")); - expect(existsSync(systemTempDisk.disk.path)).toBe(true); - - // Project temp directory - expect(projectDisk.disk.path).toBe( - path.join(projectTmpPath, "email", path.basename(tmp)) - ); - expect(existsSync(projectDisk.disk.path)).toBe(true); - - const workerService = services.find( - (s) => s.name === "SEND-EMAIL-WORKER:SEND_EMAIL" - ) as - | { - name: string; - worker: { bindings: { name: string; json?: string }[] }; - } - | undefined; - if (!workerService) { + expect(services).toHaveLength(1); + expect(services[0]?.name).toBe("SEND-EMAIL-WORKER:default:SEND_EMAIL"); + if (services[0] === undefined || !("worker" in services[0])) { throw new Error("Expected send_email worker service to be present"); } - - const bindings = workerService.worker.bindings; - - // Each disk service is bound so the worker can write to it via fetch. - const systemServiceBinding = bindings.find( - (b) => b.name === "MINIFLARE_EMAIL_DISK_SYSTEM" - ) as { name: string; service?: { name: string } } | undefined; - const projectServiceBinding = bindings.find( - (b) => b.name === "MINIFLARE_EMAIL_DISK_PROJECT" - ) as { name: string; service?: { name: string } } | undefined; - expect(systemServiceBinding?.service?.name).toBe("email:disk:system"); - expect(projectServiceBinding?.service?.name).toBe("email:disk:project"); - - const emailDiskServicesBinding = bindings.find( - (b) => b.name === "email_disk_services" - ); - if (!emailDiskServicesBinding?.json) { - throw new Error("Expected email_disk_services binding with JSON value"); + const worker = services[0].worker; + if (worker === undefined) { + throw new Error("Expected send_email worker service configuration"); } - - const emailDiskServices = JSON.parse(emailDiskServicesBinding.json); - expect(emailDiskServices).toHaveLength(2); - expect(emailDiskServices[0].bindingName).toBe( - "MINIFLARE_EMAIL_DISK_SYSTEM" - ); - expect(emailDiskServices[0].location).toBe("system"); - expect(emailDiskServices[0].path).toBe(path.join(tmp, "email")); - expect(emailDiskServices[1].bindingName).toBe( - "MINIFLARE_EMAIL_DISK_PROJECT" - ); - expect(emailDiskServices[1].location).toBe("project"); - expect(emailDiskServices[1].path).toBe(projectDisk.disk.path); + expect( + (worker.bindings ?? []).some( + (binding) => binding.name === "MINIFLARE_EMAIL_STORE" + ) + ).toBe(false); }); - test("creates only system disk service when resourceTmpPath is undefined", async ({ + test("binds the email store and owning worker for local explorer", async ({ expect, }) => { const tmp = await useTmp(); @@ -2465,9 +2519,9 @@ describe("EMAIL_PLUGIN.getServices", () => { options: { config: { env: { SEND_EMAIL: { type: "send-email" } } }, }, - sharedOptions: {}, + sharedOptions: { unsafeLocalExplorer: true }, tmpPath: tmp, - resourceTmpPath: undefined, + resourceTmpPath: path.join(tmp, ".wrangler", "tmp"), workerNames: ["default"], workerIndex: 0, } as unknown as Parameters[0]); @@ -2477,62 +2531,21 @@ describe("EMAIL_PLUGIN.getServices", () => { } const services = result; - expect(services).toHaveLength(2); - - const diskServices = services.filter((s) => "disk" in s) as Array<{ - name: string; - disk: { path: string; writable?: boolean }; - }>; - expect(diskServices).toHaveLength(1); - - const systemTempDisk = diskServices.find( - (s) => s.name === "email:disk:system" - ); - if (!systemTempDisk) { - throw new Error("Expected system disk service to be present"); - } - - expect(systemTempDisk.disk.path).toBe(path.join(tmp, "email")); - expect(existsSync(systemTempDisk.disk.path)).toBe(true); - - const workerService = services.find( - (s) => s.name === "SEND-EMAIL-WORKER:SEND_EMAIL" - ) as - | { - name: string; - worker: { bindings: { name: string; json?: string }[] }; - } - | undefined; - if (!workerService) { + expect(services).toHaveLength(1); + if (services[0] === undefined || !("worker" in services[0])) { throw new Error("Expected send_email worker service to be present"); } - - const bindings = workerService.worker.bindings; - - const systemServiceBinding = bindings.find( - (b) => b.name === "MINIFLARE_EMAIL_DISK_SYSTEM" - ) as { name: string; service?: { name: string } } | undefined; - expect(systemServiceBinding?.service?.name).toBe("email:disk:system"); - - const projectServiceBinding = bindings.find( - (b) => b.name === "MINIFLARE_EMAIL_DISK_PROJECT" - ); - expect(projectServiceBinding).toBeUndefined(); - - const emailDiskServicesBinding = bindings.find( - (b) => b.name === "email_disk_services" - ); - if (!emailDiskServicesBinding?.json) { - throw new Error("Expected email_disk_services binding with JSON value"); + const worker = services[0].worker; + if (worker === undefined) { + throw new Error("Expected send_email worker service configuration"); } - - const emailDiskServices = JSON.parse(emailDiskServicesBinding.json); - expect(emailDiskServices).toHaveLength(1); - expect(emailDiskServices[0].bindingName).toBe( - "MINIFLARE_EMAIL_DISK_SYSTEM" - ); - expect(emailDiskServices[0].location).toBe("system"); - expect(emailDiskServices[0].path).toBe(path.join(tmp, "email")); + const bindings = worker.bindings ?? []; + expect( + bindings.find((binding) => binding.name === "MINIFLARE_EMAIL_STORE") + ).toMatchObject({ service: { name: "email:store" } }); + expect( + bindings.find((binding) => binding.name === "SEND_EMAIL_OWNER_WORKER") + ).toMatchObject({ json: JSON.stringify("default") }); }); }); diff --git a/packages/miniflare/test/plugins/local-explorer/email.spec.ts b/packages/miniflare/test/plugins/local-explorer/email.spec.ts new file mode 100644 index 00000000000..344def93658 --- /dev/null +++ b/packages/miniflare/test/plugins/local-explorer/email.spec.ts @@ -0,0 +1,1186 @@ +import { Buffer } from "node:buffer"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { removeDirSync } from "@cloudflare/workers-utils"; +import { Miniflare } from "miniflare"; +import dedent from "ts-dedent"; +import { afterAll, beforeAll, describe, test } from "vitest"; +import { z } from "zod"; +import { CorePaths } from "../../../src/workers/core/constants"; +import { + MAX_CAPTURE_BODY_BYTES, + MAX_LOCAL_EMAIL_BYTES, + MAX_PRODUCTION_EMAIL_BYTES, +} from "../../../src/workers/email/capture"; +import { + zEmailRoutingDetail, + zEmailSendingDetail, + zEmailListRoutingResponse, + zEmailListSendingResponse, + zWorkersApiResponseCommon, + zWorkersApiResponseCommonFailure, +} from "../../../src/workers/local-explorer/generated/zod.gen"; +import { + disposeWithRetry, + singleModuleManifest, + waitForWorkersInRegistry, +} from "../../test-shared"; +import { expectValidResponse } from "./helpers"; + +const BASE_URL = `http://localhost${CorePaths.EXPLORER}/api`; +const WORKER_NAME = "email-worker"; +const zEmailRoutingDetailResponse = zWorkersApiResponseCommon.and( + z.object({ result: zEmailRoutingDetail }) +); +const zEmailSendingDetailResponse = zWorkersApiResponseCommon.and( + z.object({ result: zEmailSendingDetail }) +); + +function getListResult(result: T[] | T | undefined): T[] { + if (!Array.isArray(result)) { + throw new Error("Expected a list response"); + } + return result; +} + +const EMAIL_WORKER = dedent /* javascript */ ` + import { EmailMessage } from "cloudflare:email"; + + export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/send-raw") { + const message = await env.SEND_EMAIL.send(new EmailMessage( + url.searchParams.get("from"), + url.searchParams.get("to"), + request.body + )); + return Response.json(message); + } + + if (url.pathname === "/send-builder") { + return Response.json(await env.SEND_EMAIL.send(await request.json())); + } + + return new Response("ok"); + }, + + async email(message) { + const mode = message.headers.get("x-test-mode"); + if (mode === "forward") { + await message.forward("forwarded@example.com"); + } else if (mode === "reply") { + await message.reply( + new EmailMessage( + "reply@example.com", + message.from, + "From: reply@example.com\\n" + + "To: sender@example.com\\n" + + "Subject: =?UTF-8?B?UmVwbHkgc3ViamVjdA==?=\\n" + + "In-Reply-To: \\n" + + "Message-ID: \\n" + + "MIME-Version: 1.0\\n" + + "Content-Type: text/plain\\n\\n" + + "Body literal =?UTF-8?B?U2hvdWxkIHN0YXkgcmF3?=" + ) + ); + } else if (mode === "reply-large") { + const filler = "z".repeat(2 * 1024 * 1024); + await message.reply( + new EmailMessage( + "reply@example.com", + message.from, + "From: reply@example.com\\n" + + "To: sender@example.com\\n" + + "Subject: Large reply\\n" + + "In-Reply-To: \\n" + + "Message-ID: \\n" + + "MIME-Version: 1.0\\n" + + "Content-Type: text/plain\\n\\n" + + filler + ) + ); + } else if (mode === "reject") { + message.setReject("Rejected by test worker"); + } + }, + }; +`; + +describe("Local Explorer email API", () => { + let mf: Miniflare; + + beforeAll(async () => { + mf = new Miniflare({ + inspectorPort: 0, + unsafeLocalExplorer: true, + unsafeTriggerHandlers: true, + workers: [ + { + config: { + type: "worker", + name: WORKER_NAME, + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest(EMAIL_WORKER), + env: { + SEND_EMAIL: { type: "send-email" }, + }, + }, + }, + ], + }); + await mf.ready; + }); + + afterAll(async () => { + await disposeWithRetry(mf); + }); + + test("captures a sent EmailMessage with raw content", async ({ expect }) => { + const raw = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + Subject: Raw message + MIME-Version: 1.0 + Content-Type: text/plain + + Raw message body. + `; + + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-raw?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + }).toString(), + { + method: "POST", + body: raw, + } + ); + + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + expect(sentResult).toEqual({ + messageId: expect.stringMatching(/^<[A-Za-z0-9]+@example\.com>$/), + }); + const sentMessageId = sentResult.messageId; + + const listResponse = await mf.dispatchFetch( + `${BASE_URL}/local/email/sending` + ); + const list = await expectValidResponse( + listResponse, + zEmailListSendingResponse, + expect + ); + const item = getListResult(list.result).find( + (email) => email.messageId === sentMessageId + ); + expect(item).toMatchObject({ + worker: WORKER_NAME, + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Raw message", + }); + expect(item).not.toHaveProperty("raw"); + + const detailResponse = await mf.dispatchFetch( + `${BASE_URL}/local/email/sending?email_id=${encodeURIComponent(sentMessageId)}` + ); + const detail = await expectValidResponse( + detailResponse, + zEmailSendingDetailResponse, + expect + ); + expect(detail.result).toMatchObject({ + worker: WORKER_NAME, + messageId: sentMessageId, + raw, + rawBase64: Buffer.from(raw).toString("base64"), + }); + }); + + test("captures a MessageBuilder and omits large fields from list results", async ({ + expect, + }) => { + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-builder", + { + method: "POST", + body: JSON.stringify({ + from: { name: "Sender", email: "sender@example.com" }, + to: "recipient@example.com", + subject: "Builder message", + text: "Plain text", + html: "

HTML

", + headers: { "Message-ID": "" }, + attachments: [ + { + filename: "hello.txt", + type: "text/plain", + disposition: "attachment", + content: "SGVsbG8=", + }, + ], + }), + } + ); + + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + expect(sentResult).toEqual({ + messageId: expect.stringMatching(/^<[A-Za-z0-9]+@example\.com>$/), + }); + const sentMessageId = sentResult.messageId; + + const list = await expectValidResponse( + await mf.dispatchFetch(`${BASE_URL}/local/email/sending`), + zEmailListSendingResponse, + expect + ); + const item = getListResult(list.result).find( + (email) => email.messageId === sentMessageId + ); + expect(item).toMatchObject({ + worker: WORKER_NAME, + from: '"Sender" ', + to: ["recipient@example.com"], + subject: "Builder message", + attachments: [ + { + filename: "hello.txt", + contentType: "text/plain", + disposition: "attachment", + size: 8, + }, + ], + }); + expect(item).not.toHaveProperty("text"); + expect(item).not.toHaveProperty("html"); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/sending?email_id=${encodeURIComponent(sentMessageId)}` + ), + zEmailSendingDetailResponse, + expect + ); + expect(detail.result).toMatchObject({ + text: "Plain text", + html: "

HTML

", + }); + }); + + test("captures a MessageBuilder attachment that omits its disposition", async ({ + expect, + }) => { + // An attachment may leave `disposition` unset. Without a default the + // captured record stores `undefined`, which fails schema validation and + // breaks the entire sent list (and stops further capture once eviction + // begins). The default must match `buildMimeMessage` ("attachment"). + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-builder", + { + method: "POST", + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Attachment without disposition", + text: "Body", + headers: { "Message-ID": "" }, + attachments: [ + { + filename: "hello.txt", + type: "text/plain", + content: "SGVsbG8=", + }, + ], + }), + } + ); + + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + const sentMessageId = sentResult.messageId; + + // The sent list must still load and validate against the schema. + const list = await expectValidResponse( + await mf.dispatchFetch(`${BASE_URL}/local/email/sending`), + zEmailListSendingResponse, + expect + ); + const item = getListResult(list.result).find( + (email) => email.messageId === sentMessageId + ); + expect(item).toMatchObject({ + subject: "Attachment without disposition", + attachments: [ + { + filename: "hello.txt", + contentType: "text/plain", + disposition: "attachment", + size: 8, + }, + ], + }); + }); + + test("sends a >1 MiB EmailMessage and captures a truncated copy", async ({ + expect, + }) => { + const filler = "x".repeat(2 * 1024 * 1024); + const raw = + [ + "From: sender@example.com", + "To: recipient@example.com", + "Message-ID: ", + "Subject: Large raw message", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + filler, + ].join("\r\n") + "\r\n"; + expect(new TextEncoder().encode(raw).byteLength).toBeGreaterThan( + MAX_LOCAL_EMAIL_BYTES + ); + + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-raw?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + }).toString(), + { method: "POST", body: raw } + ); + + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + expect(sentResult.messageId).toMatch(/^<[A-Za-z0-9]+@example\.com>$/); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/sending?email_id=${encodeURIComponent(sentResult.messageId)}` + ), + zEmailSendingDetailResponse, + expect + ); + // Delivery used the full body, but the captured copy is truncated to the + // local limit. + expect(detail.result?.subject).toBe("Large raw message"); + const capturedBytes = Buffer.from( + String(detail.result?.rawBase64), + "base64" + ).byteLength; + expect(capturedBytes).toBe(MAX_LOCAL_EMAIL_BYTES); + }); + + test("sends a >1 MiB MessageBuilder and captures a truncated copy", async ({ + expect, + }) => { + const text = "y".repeat(2 * 1024 * 1024); + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-builder", + { + method: "POST", + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Large builder message", + text, + headers: { "Message-ID": "" }, + }), + } + ); + + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + expect(sentResult.messageId).toMatch(/^<[A-Za-z0-9]+@example\.com>$/); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/sending?email_id=${encodeURIComponent(sentResult.messageId)}` + ), + zEmailSendingDetailResponse, + expect + ); + expect(detail.result?.subject).toBe("Large builder message"); + expect(new TextEncoder().encode(detail.result?.text ?? "").byteLength).toBe( + MAX_CAPTURE_BODY_BYTES + ); + }); + + test("captures a MessageBuilder with large text, html, and headers", async ({ + expect, + }) => { + // text + html + a large custom header together would exceed workerd's + // ~1 MiB RPC argument cap if the bodies were each capped at 1 MiB, + // making `storeSent` throw and the email silently vanish from the + // explorer. The bodies share a small combined budget that leaves + // headroom for headers/metadata, so the whole record still fits. + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-builder", + { + method: "POST", + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Large text and html", + text: "t".repeat(2 * 1024 * 1024), + html: "h".repeat(2 * 1024 * 1024), + headers: { + "Message-ID": "", + "X-Large-Header": "z".repeat(768 * 1024), + }, + }), + } + ); + + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/sending?email_id=${encodeURIComponent(sentResult.messageId)}` + ), + zEmailSendingDetailResponse, + expect + ); + // The email was captured (not silently dropped by an oversized RPC). + expect(detail.result?.subject).toBe("Large text and html"); + const textBytes = new TextEncoder().encode( + detail.result?.text ?? "" + ).byteLength; + const htmlBytes = new TextEncoder().encode( + detail.result?.html ?? "" + ).byteLength; + // `text` is capped first, `html` takes the remaining budget; combined + // they stay within the shared limit. + expect(textBytes).toBe(MAX_CAPTURE_BODY_BYTES); + expect(textBytes + htmlBytes).toBeLessThanOrEqual(MAX_CAPTURE_BODY_BYTES); + }); + + test("keeps a sent email with a negative remaining body budget", async ({ + expect, + }) => { + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-builder", + { + method: "POST", + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Multibyte body budget", + text: "€".repeat(Math.ceil(MAX_CAPTURE_BODY_BYTES / 3) + 1), + html: "h".repeat(MAX_CAPTURE_BODY_BYTES), + }), + } + ); + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/sending?email_id=${encodeURIComponent(sentResult.messageId)}` + ), + zEmailSendingDetailResponse, + expect + ); + expect(detail.result).toMatchObject({ + subject: "Multibyte body budget", + text: expect.any(String), + html: "", + }); + }); + + test("captures a >1 MiB reply as a truncated copy", async ({ expect }) => { + // The incoming email stays small; the worker self-generates a >1 MiB + // reply body so we exercise reply capture without tripping the + // test-send guard on the incoming message. + const response = await mf.dispatchFetch( + `${BASE_URL}/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: "Large reply target", + text: "Large reply target", + headers: { + "Message-ID": "", + "X-Test-Mode": "reply-large", + }, + }), + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: { + messageId: "", + outcome: "ok", + }, + }); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent("")}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result?.events.map(({ type }) => type)).toEqual([ + "received", + "reply", + ]); + const reply = detail.result?.replies[0]; + expect(reply?.messageId).toBe(""); + // The reply was delivered and captured, trimmed to the local limit. + expect(new TextEncoder().encode(reply?.raw ?? "").byteLength).toBe( + MAX_LOCAL_EMAIL_BYTES + ); + }); + + test("delivers and captures a truncated copy of a >1 MiB received email", async ({ + expect, + }) => { + const headers = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain + + `; + const headerBytes = new TextEncoder().encode(headers).byteLength; + const raw = headers + "x".repeat(2 * 1024 * 1024 - headerBytes); + + const response = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + format: "json", + }).toString(), + { method: "POST", body: raw } + ); + + // Delivery succeeds regardless of size. + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ outcome: "ok" }); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent("")}` + ), + zEmailRoutingDetailResponse, + expect + ); + // Full original size is recorded, but the captured raw is truncated. + expect(detail.result?.rawSize).toBe(2 * 1024 * 1024); + expect( + Buffer.from(String(detail.result?.rawBase64), "base64").byteLength + ).toBe(MAX_LOCAL_EMAIL_BYTES); + }); + + test("rejects a received email larger than the production limit", async ({ + expect, + }) => { + const headers = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain + + `; + const headerBytes = new TextEncoder().encode(headers).byteLength; + // One byte over the production Email Routing limit. + const raw = + headers + "x".repeat(MAX_PRODUCTION_EMAIL_BYTES + 1 - headerBytes); + + const response = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + format: "json", + }).toString(), + { method: "POST", body: raw } + ); + + // Matches production: oversized messages are rejected, not delivered. + expect(response.status).toBe(400); + expect(await response.text()).toContain("production size limit of 25 MiB"); + }); + + test("stores received handler events and details", async ({ expect }) => { + const raw = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + X-Test-Mode: forward + MIME-Version: 1.0 + Content-Type: text/plain + + Received message. + `; + const response = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + format: "json", + }).toString(), + { + method: "POST", + body: raw, + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + outcome: "ok", + forwards: [ + { + recipient: "forwarded@example.com", + }, + ], + }); + + const list = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?worker=${WORKER_NAME}` + ), + zEmailListRoutingResponse, + expect + ); + const item = getListResult(list.result).find( + (email) => email.messageId === "" + ); + expect(item).toMatchObject({ + worker: WORKER_NAME, + from: "sender@example.com", + to: "recipient@example.com", + outcome: "ok", + forwards: [ + { + recipient: "forwarded@example.com", + }, + ], + }); + expect(item?.events.map(({ type }) => type)).toEqual([ + "received", + "forward", + ]); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent("")}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result).toMatchObject({ + raw, + rawBase64: Buffer.from(raw).toString("base64"), + }); + }); + + test("captures a received email at the local size limit", async ({ + expect, + }) => { + const headers = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain + + `; + const headerBytes = new TextEncoder().encode(headers).byteLength; + const raw = headers + "x".repeat(MAX_LOCAL_EMAIL_BYTES - headerBytes); + + const response = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + format: "json", + }).toString(), + { + method: "POST", + body: raw, + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ outcome: "ok" }); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent("")}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result).toMatchObject({ + messageId: "", + rawSize: MAX_LOCAL_EMAIL_BYTES, + raw, + rawBase64: Buffer.from(raw).toString("base64"), + }); + }); + + test("filters received emails by worker and records rejection", async ({ + expect, + }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/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: "Rejected message", + text: "Rejected", + headers: { + "Message-ID": "", + "X-Test-Mode": "reject", + }, + }), + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: { + messageId: "", + outcome: "ok", + rejectReason: "Rejected by test worker", + }, + }); + + const filtered = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?worker=other-worker` + ), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(filtered.result)).toEqual([]); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent("")}&worker=other-worker` + ), + zWorkersApiResponseCommonFailure, + expect, + 404 + ); + expect(detail.result).toBeNull(); + }); + + test("does not duplicate Content-Type when a test email supplies one", async ({ + expect, + }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/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: "Custom content type", + text: "Body text", + headers: { + "Message-ID": "", + // A caller-supplied content type must not be emitted: the + // generated one describes the actual body. + "Content-Type": "application/json", + }, + }), + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: { + messageId: "", + outcome: "ok", + }, + }); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent("")}` + ), + zEmailRoutingDetailResponse, + expect + ); + const raw = detail.result?.raw ?? ""; + const contentTypeLines = raw + .split(/\r?\n/) + .filter((line) => /^content-type:/i.test(line)); + // Exactly one Content-Type, and it's the generated one describing the body. + expect(contentTypeLines).toEqual([ + "Content-Type: text/plain; charset=utf-8", + ]); + }); + + test("stores reply events and reply content", async ({ expect }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/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: "Reply target", + text: "Reply target", + headers: { + "Message-ID": "", + "X-Test-Mode": "reply", + }, + }), + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: { + messageId: "", + outcome: "ok", + }, + }); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent("")}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result?.events.map(({ type }) => type)).toEqual([ + "received", + "reply", + ]); + const reply = detail.result?.replies[0]; + expect(reply).toMatchObject({ + messageId: "", + sender: "reply@example.com", + raw: expect.stringContaining("References: "), + rawBase64: expect.any(String), + }); + expect(reply?.raw).toContain("Subject: Reply subject"); + expect(reply?.raw).toContain( + "Body literal =?UTF-8?B?U2hvdWxkIHN0YXkgcmF3?=" + ); + expect( + Buffer.from(String(reply?.rawBase64), "base64").toString() + ).toContain("Subject: =?UTF-8?B?UmVwbHkgc3ViamVjdA==?="); + }); + + test( + "retains all received emails and paginates results", + { retry: 0 }, + async ({ expect }) => { + for (let index = 0; index <= 200; index++) { + const messageId = ``; + const response = await mf.dispatchFetch( + `${BASE_URL}/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: "Retention test", + text: `Message ${index}`, + headers: { "Message-ID": messageId }, + }), + } + ); + expect(response.status).toBe(200); + await response.json(); + } + + const firstPage = await expectValidResponse( + await mf.dispatchFetch(`${BASE_URL}/local/email/routing?per_page=100`), + zEmailListRoutingResponse, + expect + ); + const cursor = firstPage.result_info?.cursor; + expect(cursor).toEqual(expect.any(String)); + const secondPage = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?per_page=100&cursor=${encodeURIComponent(String(cursor))}` + ), + zEmailListRoutingResponse, + expect + ); + const thirdPage = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?per_page=100&cursor=${encodeURIComponent(String(secondPage.result_info?.cursor))}` + ), + zEmailListRoutingResponse, + expect + ); + const retained = [ + ...getListResult(firstPage.result), + ...getListResult(secondPage.result), + ...getListResult(thirdPage.result), + ].filter((email) => email.messageId.startsWith(" + email.messageId.startsWith("" + ); + expect(retained?.at(-1)?.messageId).toBe(""); + } + ); + + test("rejects malformed aggregate cursors", async ({ expect }) => { + const cursor = `a.${Buffer.from(JSON.stringify({ local: 123 })).toString( + "base64" + )}`; + const response = await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?cursor=${encodeURIComponent(cursor)}` + ); + await response.text(); + expect(response.status).toBe(400); + }); + + test("clears captured emails", async ({ expect }) => { + const clearResponse = await mf.dispatchFetch( + `${BASE_URL}/local/email/clear`, + { method: "POST" } + ); + expect(clearResponse.status).toBe(200); + await clearResponse.json(); + + const list = await expectValidResponse( + await mf.dispatchFetch(`${BASE_URL}/local/email/routing`), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(list.result)).toEqual([]); + expect(list.result_info).toMatchObject({ + count: 0, + has_more: false, + }); + }); +}); + +describe("Local Explorer email aggregation", () => { + let registryPath: string; + let instanceA: Miniflare; + let instanceB: Miniflare; + + beforeAll(async () => { + registryPath = mkdtempSync(path.join(tmpdir(), "mf-email-registry-")); + instanceA = new Miniflare({ + inspectorPort: 0, + unsafeLocalExplorer: true, + unsafeTriggerHandlers: true, + unsafeDevRegistryPath: registryPath, + workers: [ + { + dev: { unsafeRegisterWorker: true }, + config: { + type: "worker", + name: "email-a", + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest(EMAIL_WORKER), + env: { + SEND_EMAIL: { type: "send-email" }, + }, + }, + }, + { + dev: { unsafeRegisterWorker: true }, + config: { + type: "worker", + name: "email-c", + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest(EMAIL_WORKER), + env: { + SEND_EMAIL: { type: "send-email" }, + }, + }, + }, + ], + }); + instanceB = new Miniflare({ + inspectorPort: 0, + unsafeLocalExplorer: true, + unsafeTriggerHandlers: true, + unsafeDevRegistryPath: registryPath, + workers: [ + { + dev: { unsafeRegisterWorker: true }, + config: { + type: "worker", + name: "email-b", + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest(EMAIL_WORKER), + env: { + SEND_EMAIL: { type: "send-email" }, + }, + }, + }, + ], + }); + await Promise.all([instanceA.ready, instanceB.ready]); + await waitForWorkersInRegistry(registryPath, [ + "email-a", + "email-b", + "email-c", + ]); + }); + + afterAll(async () => { + await Promise.all([ + disposeWithRetry(instanceA), + disposeWithRetry(instanceB), + ]); + removeDirSync(registryPath); + }); + + test("aggregates peer records and proxies peer details", async ({ + expect, + }) => { + const messageId = ""; + const response = await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing/send?worker=email-b`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Peer email", + text: "Stored by the peer instance", + headers: { "Message-ID": messageId }, + }), + } + ); + expect(response.status).toBe(200); + await response.json(); + + const list = await expectValidResponse( + await instanceA.dispatchFetch(`${BASE_URL}/local/email/routing`), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(list.result)).toEqual([ + expect.objectContaining({ worker: "email-b", messageId }), + ]); + expect(list.result_info).toMatchObject({ + count: 1, + has_more: false, + }); + + const filteredList = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing?worker=email-b` + ), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(filteredList.result)).toEqual([ + expect.objectContaining({ worker: "email-b", messageId }), + ]); + expect(filteredList.result_info).toMatchObject({ + count: 1, + has_more: false, + }); + + const detail = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent(messageId)}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result).toMatchObject({ + worker: "email-b", + messageId, + }); + + const wrongWorker = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent(messageId)}&worker=email-a` + ), + zWorkersApiResponseCommonFailure, + expect, + 404 + ); + expect(wrongWorker.result).toBeNull(); + }); + + test("does not advertise an empty page after filtered source exhaustion", async ({ + expect, + }) => { + for (const [worker, messageId] of [ + ["email-c", ""], + ["email-a", ""], + ]) { + const response = await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing/send?worker=${worker}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Filtered pagination test", + text: "Filtered pagination test", + headers: { "Message-ID": messageId }, + }), + } + ); + expect(response.status).toBe(200); + await response.json(); + } + + const page = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing?worker=email-a` + ), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(page.result)).toEqual([ + expect.objectContaining({ + worker: "email-a", + messageId: "", + }), + ]); + expect(page.result_info).toMatchObject({ count: 1, has_more: false }); + }); +}); diff --git a/packages/miniflare/test/plugins/local-explorer/index.spec.ts b/packages/miniflare/test/plugins/local-explorer/index.spec.ts index 4395cbeeba6..ecba1c91bcd 100644 --- a/packages/miniflare/test/plugins/local-explorer/index.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/index.spec.ts @@ -768,6 +768,7 @@ describe("Local Explorer /api/local/workers endpoint", () => { "id": "r2-bucket-name", }, ], + "sendEmail": [], "workflows": [], }, "isSelf": true, @@ -784,6 +785,7 @@ describe("Local Explorer /api/local/workers endpoint", () => { }, ], "r2": [], + "sendEmail": [], "workflows": [], }, "isSelf": true, @@ -800,6 +802,7 @@ describe("Local Explorer /api/local/workers endpoint", () => { "do": [], "kv": [], "r2": [], + "sendEmail": [], "workflows": [], }, "isSelf": false, diff --git a/packages/wrangler/e2e/createTestHarness.test.ts b/packages/wrangler/e2e/createTestHarness.test.ts index a15f7c4a524..312d7206d86 100644 --- a/packages/wrangler/e2e/createTestHarness.test.ts +++ b/packages/wrangler/e2e/createTestHarness.test.ts @@ -2052,7 +2052,7 @@ describe("createTestHarness", () => { forwards: [ { recipient: "archive@example.com", - headers: [["x-test", "ok"]], + headers: [{ name: "x-test", value: "ok" }], messageId: expect.any(String), }, ], @@ -2064,6 +2064,10 @@ describe("createTestHarness", () => { }, ], events: [ + { + type: "received", + timestamp: expect.any(String), + }, { type: "forward", timestamp: expect.any(String), @@ -2088,7 +2092,10 @@ describe("createTestHarness", () => { rejectReason: "blocked sender", forwards: [], replies: [], - events: [{ type: "reject", timestamp: expect.any(String) }], + events: [ + { type: "received", timestamp: expect.any(String) }, + { type: "reject", timestamp: expect.any(String) }, + ], }); await expect( @@ -2103,7 +2110,7 @@ describe("createTestHarness", () => { forwards: [ { recipient: "archive@example.com", - headers: [["x-test", "exception"]], + headers: [{ name: "x-test", value: "exception" }], messageId: expect.any(String), }, ], @@ -2115,6 +2122,10 @@ describe("createTestHarness", () => { }, ], events: [ + { + type: "received", + timestamp: expect.any(String), + }, { type: "forward", timestamp: expect.any(String), diff --git a/packages/wrangler/e2e/dev.test.ts b/packages/wrangler/e2e/dev.test.ts index 865bbafb148..6640bd10361 100644 --- a/packages/wrangler/e2e/dev.test.ts +++ b/packages/wrangler/e2e/dev.test.ts @@ -26,6 +26,7 @@ import { E2E_ACCOUNT_WORKERS_DEV_DOMAIN, } from "./helpers/account-id"; import { WranglerE2ETestHelper } from "./helpers/e2e-wrangler-test"; +import { fetchJson } from "./helpers/fetch-json"; import { fetchText } from "./helpers/fetch-text"; import { fetchWithETag } from "./helpers/fetch-with-etag"; import { generateResourceName } from "./helpers/generate-resource-name"; @@ -2631,6 +2632,181 @@ This is a random email body. " `); }); + + it("should expose captured emails through the local explorer API", async ({ + expect, + }) => { + const helper = new WranglerE2ETestHelper(); + await helper.seed({ + "wrangler.toml": dedent` + name = "${workerName}" + main = "src/index.ts" + compatibility_date = "2025-03-17" + send_email = [{ name = "SEND_EMAIL" }] + `, + "src/index.ts": dedent` + export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/send") { + return Response.json( + await env.SEND_EMAIL.send(await request.json()) + ); + } + return new Response("ok"); + }, + async email(message) { + if (message.headers.get("x-test-mode") === "forward") { + await message.forward( + "forwarded@example.com", + new Headers({ "X-Forwarded-Test": "ok" }) + ); + } else { + message.setReject("Rejected by E2E worker"); + } + }, + }; + `, + }); + + const worker = helper.runLongLived("wrangler dev"); + const { url } = await worker.waitForReady(); + const apiUrl = `${url}/cdn-cgi/local/explorer/api`; + + const sentResponse = await fetch(`${url}/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Explorer sent email", + text: "Sent through Wrangler dev", + headers: { "Message-ID": "" }, + }), + }); + expect(sentResponse.status).toBe(200); + const sentResult = (await sentResponse.json()) as { messageId: string }; + expect(sentResult).toEqual({ + messageId: expect.stringMatching(/^<[A-Za-z0-9]+@example\.com>$/), + }); + const sentMessageId = sentResult.messageId; + + const sentList = await fetchJson<{ + result: Array<{ + worker?: string; + messageId: string; + subject: string; + text?: string; + }>; + result_info?: { count?: number; has_more?: boolean }; + }>(`${apiUrl}/local/email/sending?worker=${workerName}`); + expect(sentList.result).toEqual([ + expect.objectContaining({ + worker: workerName, + subject: "Explorer sent email", + }), + ]); + expect(sentList.result_info).toMatchObject({ count: 1, has_more: false }); + const sentItem = sentList.result.find( + (email) => email.messageId === sentMessageId + ); + expect(sentItem).toMatchObject({ + worker: workerName, + subject: "Explorer sent email", + }); + expect(sentItem).not.toHaveProperty("text"); + + const sentDetail = await fetchJson<{ + result: { text?: string; messageId: string }; + }>( + `${apiUrl}/local/email/sending?email_id=${encodeURIComponent(sentMessageId)}` + ); + expect(sentDetail.result).toMatchObject({ + messageId: sentMessageId, + text: "Sent through Wrangler dev", + }); + + const receivedRaw = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + X-Test-Mode: forward + MIME-Version: 1.0 + Content-Type: text/plain + + Received through Wrangler dev. + `; + const receivedResponse = await fetch( + `${url}/cdn-cgi/local/email?` + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + format: "json", + }).toString(), + { + method: "POST", + body: receivedRaw, + } + ); + expect(receivedResponse.status).toBe(200); + expect(await receivedResponse.json()).toMatchObject({ + outcome: "ok", + forwards: [ + { + recipient: "forwarded@example.com", + headers: [{ name: "x-forwarded-test", value: "ok" }], + }, + ], + }); + + const receivedList = await fetchJson<{ + result: Array<{ + worker?: string; + messageId: string; + outcome: string; + forwards: Array<{ + recipient: string; + headers: Array<{ name: string; value: string }>; + }>; + }>; + result_info?: { count?: number; has_more?: boolean }; + }>(`${apiUrl}/local/email/routing?worker=${workerName}`); + expect(receivedList.result).toEqual([ + expect.objectContaining({ + worker: workerName, + messageId: "", + outcome: "ok", + forwards: expect.arrayContaining([ + expect.objectContaining({ + recipient: "forwarded@example.com", + headers: [ + expect.objectContaining({ + name: "x-forwarded-test", + value: "ok", + }), + ], + }), + ]), + }), + ]); + expect(receivedList.result_info).toMatchObject({ + count: 1, + has_more: false, + }); + + const receivedDetail = await fetchJson<{ + result: { + raw: string; + events: Array<{ type: string }>; + }; + }>( + `${apiUrl}/local/email/routing?email_id=${encodeURIComponent("")}` + ); + expect(receivedDetail.result).toMatchObject({ + raw: receivedRaw, + events: [{ type: "received" }, { type: "forward" }], + }); + }); }); describe("r2 local S3-compatible API", () => { diff --git a/packages/wrangler/e2e/get-platform-proxy.test.ts b/packages/wrangler/e2e/get-platform-proxy.test.ts index 72bce990bb0..b16ebbd93f9 100644 --- a/packages/wrangler/e2e/get-platform-proxy.test.ts +++ b/packages/wrangler/e2e/get-platform-proxy.test.ts @@ -694,7 +694,7 @@ describe("getPlatformProxy()", () => { encoding: "utf-8", }); - expect(stdout).toMatch(/^<[A-Za-z0-9]{36}@sender\.domain>/); + expect(stdout).toMatch(/^<[A-Za-z0-9]+@sender\.domain>/); }); }); }); diff --git a/packages/wrangler/e2e/multiworker-dev.test.ts b/packages/wrangler/e2e/multiworker-dev.test.ts index 67da1ac58e1..48c411babe6 100644 --- a/packages/wrangler/e2e/multiworker-dev.test.ts +++ b/packages/wrangler/e2e/multiworker-dev.test.ts @@ -670,3 +670,76 @@ describe("multiworker", () => { }); }); }); + +describe("multiworker email local dev", () => { + it("filters captured emails by the selected worker", async ({ expect }) => { + const helper = new WranglerE2ETestHelper(); + const workerAName = generateResourceName("worker"); + const workerBName = generateResourceName("worker"); + const script = dedent /* javascript */ ` + export default { + async email(message) { + await message.forward("forwarded@example.com"); + }, + }; + `; + const rootA = await makeRoot(); + await baseSeed(rootA, { + "wrangler.toml": dedent` + name = "${workerAName}" + main = "src/index.ts" + compatibility_date = "2025-03-17" + `, + "src/index.ts": script, + }); + + const rootB = await makeRoot(); + await baseSeed(rootB, { + "wrangler.toml": dedent` + name = "${workerBName}" + main = "src/index.ts" + compatibility_date = "2025-03-17" + `, + "src/index.ts": script, + }); + + const worker = helper.runLongLived( + `wrangler dev -c wrangler.toml -c ${rootB}/wrangler.toml`, + { cwd: rootA } + ); + const { url } = await worker.waitForReady(30_000); + const messageId = ""; + const response = await fetch( + `${url}/cdn-cgi/local/explorer/api/local/email/routing/send?worker=${workerBName}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Multi-worker email", + text: "Captured by worker B", + headers: { "Message-ID": messageId }, + }), + } + ); + expect(response.status).toBe(200); + + const apiUrl = `${url}/cdn-cgi/local/explorer/api`; + const workerAEmails = await fetchJson<{ + result: Array<{ messageId: string }>; + }>(`${apiUrl}/local/email/routing?worker=${workerAName}`); + const workerBEmails = await fetchJson<{ + result: Array<{ messageId: string; worker?: string }>; + }>(`${apiUrl}/local/email/routing?worker=${workerBName}`); + + expect( + workerAEmails.result.some((email) => email.messageId === messageId) + ).toBe(false); + expect(workerBEmails.result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ messageId, worker: workerBName }), + ]) + ); + }); +}); diff --git a/packages/wrangler/src/api/test-harness.ts b/packages/wrangler/src/api/test-harness.ts index b26b1926ff8..e57bff168cf 100644 --- a/packages/wrangler/src/api/test-harness.ts +++ b/packages/wrangler/src/api/test-harness.ts @@ -56,6 +56,7 @@ import type { DurableObjectStorageHandle, DurableObjectStorageOptions, DispatchFetch, + EmailHandlerResult, Json, Miniflare, RequestInfo, @@ -106,31 +107,7 @@ export type FetcherEmailOptions = { raw: string | ReadableStream; }; -export type FetcherEmailResult = { - outcome: "ok" | "exception"; - rejectReason?: string; - forwards: Array<{ - messageId: string; - recipient: string; - headers: [string, string][]; - }>; - replies: Array<{ - messageId: string; - sender: string; - raw: string; - }>; - events: Array< - | { - type: "forward" | "reply"; - timestamp: string; - messageId: string; - } - | { - type: "reject"; - timestamp: string; - } - >; -}; +export type FetcherEmailResult = EmailHandlerResult; export type WorkerDefaultExport = // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Match workers-types Service constructor constraint.