diff --git a/.changeset/email-artifact-message-ids.md b/.changeset/email-artifact-message-ids.md new file mode 100644 index 00000000000..75f469b037d --- /dev/null +++ b/.changeset/email-artifact-message-ids.md @@ -0,0 +1,11 @@ +--- +"miniflare": patch +--- + +Generate and use production-style Message-IDs for local email artifacts + +Locally sent emails and replies now use generated Message-IDs - which are 36 alphanumeric characters - consistently in returned results, raw MIME headers, Local Explorer records, and stored artifact filenames. User-provided `Message-ID` headers are replaced by the generated ID. + +For example, sending an email from `sender@example.com` may return ``. The raw email uses that same value for its `Message-ID` header, the Local Explorer exposes the same ID, and the stored artifact is named `AbCdEfGhIjKlMnOpQrStUvWxYz0123456789@example.com.eml`. + +Similarly, a reply containing `Message-ID: ` is stored and returned with a newly generated ID instead. This mirrors production behavior and prevents the supplied ID from becoming the local artifact key. diff --git a/.changeset/email-reply-builder.md b/.changeset/email-reply-builder.md new file mode 100644 index 00000000000..57ec77b1baf --- /dev/null +++ b/.changeset/email-reply-builder.md @@ -0,0 +1,7 @@ +--- +"miniflare": minor +--- + +Support `EmailReplyMessageBuilder` when replying from local email handlers + +Builder replies now generate the recipient, threading headers, and a production-style Message-ID automatically. Raw `EmailMessage` replies also use a generated production-style Message-ID; user-provided Message-ID headers are rejected in favor of the generated ID. diff --git a/.changeset/email-test-harness-events.md b/.changeset/email-test-harness-events.md new file mode 100644 index 00000000000..c78a9dcd3cd --- /dev/null +++ b/.changeset/email-test-harness-events.md @@ -0,0 +1,35 @@ +--- +"miniflare": minor +"wrangler": minor +--- + +Include a chronological list of handler events in email test harness results, so programmatic local email tests can assert the order in which messages are received, forwarded, replied to, or rejected. + +```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.events).toEqual([ + { 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/.changeset/local-explorer-email-api.md b/.changeset/local-explorer-email-api.md new file mode 100644 index 00000000000..631e393dd84 --- /dev/null +++ b/.changeset/local-explorer-email-api.md @@ -0,0 +1,45 @@ +--- +"miniflare": minor +--- + +Capture locally sent and received emails, along with forwarding and reply activity and metadata, for inspection through the Local Explorer email API. + +Miniflare now captures locally sent and received emails, including forwarding, reply, rejection, and exception activity. The following endpoints are available below `/cdn-cgi/local/explorer/api` while `wrangler dev` is running: + +- `POST /local/email/routing/send?worker=` sends a test email to a Worker's `email()` handler. +- `GET /local/email/routing?worker=` lists emails received by a Worker. +- `GET /local/email/routing?email_id=&worker=` returns a received email and its handler activity. +- `GET /local/email/sending?worker=` lists emails sent through a Worker's `send_email` bindings. +- `GET /local/email/sending?email_id=&worker=` returns a sent email. + +For example, send and then inspect a test email against a Worker named `my-worker`: + +```sh +curl -X POST \ + "http://localhost:8787/cdn-cgi/local/explorer/api/local/email/routing/send?worker=my-worker" \ + -H "Content-Type: application/json" \ + --data '{ + "from": "sender@example.com", + "to": ["inbox@example.com"], + "subject": "Local test", + "text": "Hello from Local Explorer" + }' + +curl \ + "http://localhost:8787/cdn-cgi/local/explorer/api/local/email/routing?worker=my-worker" +``` + +List endpoints support `per_page` and opaque `cursor` query parameters. File paths logged by the `send_email` binding are asynchronous debugging artifacts and should not be used to synchronize after `send()` resolves. Email handler exceptions are logged when structured local delivery reports an exception outcome. + +When email content exceeds the local storage row budget of approximately 2 MB, the email is delivered in full but the Local Explorer capture is truncated to fit. Detail responses identify each truncated sent email, received email, or reply in the top-level `messages` array with warning code `10604`; for example: + +```json +{ + "messages": [ + { + "code": 10604, + "message": "Displayed received email content was truncated during local capture. The complete message was still delivered to the Worker." + } + ] +} +``` 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/email-openapi.ts b/packages/miniflare/scripts/email-openapi.ts new file mode 100644 index 00000000000..fd5e464da42 --- /dev/null +++ b/packages/miniflare/scripts/email-openapi.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import { + zEmailAttachment, + zEmailBase, + zEmailHandlerEvent, + zEmailHandlerForward, + zEmailHandlerReplyApi, + zEmailRoutingDetail, + zEmailRoutingItem, + zEmailSendingDetail, + zEmailSendingItem, + zEmailSendRequest, +} from "../src/workers/email/contracts"; + +function toOpenApiSchema(schema: z.ZodType): Record { + const { $schema: _$schema, ...openApiSchema } = z.toJSONSchema(schema, { + target: "openapi-3.0", + unrepresentable: "any", + }); + return openApiSchema; +} + +export const EMAIL_OPENAPI_SCHEMAS = { + "email_handler-event": toOpenApiSchema(zEmailHandlerEvent), + "email_handler-forward": toOpenApiSchema(zEmailHandlerForward), + "email_handler-reply": toOpenApiSchema(zEmailHandlerReplyApi), + email_base: toOpenApiSchema(zEmailBase), + "email_routing-item": toOpenApiSchema(zEmailRoutingItem), + "email_routing-detail": toOpenApiSchema(zEmailRoutingDetail), + "email_send-request": toOpenApiSchema(zEmailSendRequest), + email_attachment: toOpenApiSchema(zEmailAttachment), + "email_sending-item": toOpenApiSchema(zEmailSendingItem), + "email_sending-detail": toOpenApiSchema(zEmailSendingDetail), +}; diff --git a/packages/miniflare/scripts/openapi-filter-config.ts b/packages/miniflare/scripts/openapi-filter-config.ts index cde10c6e5d5..615ee19d5e3 100644 --- a/packages/miniflare/scripts/openapi-filter-config.ts +++ b/packages/miniflare/scripts/openapi-filter-config.ts @@ -1,3 +1,4 @@ +import { EMAIL_OPENAPI_SCHEMAS } from "./email-openapi"; import type { FilterConfig } from "./filter-openapi"; /** @@ -628,6 +629,279 @@ const config = { }, }, + // Email endpoints (local-only, not pulling from upstream API) + "/local/email/routing": { + get: { + description: + "Lists emails received by any email() handler during this dev session. Use the optional `worker` query parameter to filter by worker, or `email_id` to return one email's details.", + 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: "integer", + 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: "integer" }, + 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/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: "integer", + 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: "integer" }, + 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: { @@ -1764,6 +2038,23 @@ const config = { }, description: "Workflow bindings", }, + sendEmail: { + type: "array", + items: { + $ref: "#/components/schemas/local-explorer_named-binding", + }, + description: "Send Email bindings", + }, + }, + }, + "local-explorer_named-binding": { + type: "object", + required: ["bindingName"], + properties: { + bindingName: { + type: "string", + description: "Name of the binding in the worker's env", + }, }, }, "local-explorer_resource-binding": { @@ -1968,6 +2259,7 @@ const config = { }, required: ["columns", "rows"], }, + ...EMAIL_OPENAPI_SCHEMAS, }, }, } satisfies FilterConfig; diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index f82a69eb41c..1a1cc95b252 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"; @@ -86,6 +85,7 @@ import { import { ContainerPrivilegesCache } from "./plugins/core/container"; 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, @@ -126,6 +126,7 @@ import { decodeErrorPayload, LogLevel, Mutex, + sanitisePath, SharedHeaders, SiteBindings, } from "./workers"; @@ -1148,6 +1149,78 @@ 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 }); + } + /** * Gets DO object IDs by checking filenames in the DO persistence directory. * @@ -1643,16 +1716,7 @@ 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.startsWith("/core/do-storage/")) { response = await this.#handleLoopbackDOStorageRequest(url); } else if (url.pathname.startsWith("/core/workflow-storage/")) { 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 a9aa59c5896..c1614589eba 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 — @@ -347,6 +360,7 @@ export function constructExplorerWorkerOpts( r2: [], do: [], workflows: [], + sendEmail: [], }; for (const [bindingName, binding] of getEnvBindingsOfType( @@ -404,6 +418,13 @@ export function constructExplorerWorkerOpts( }); } + for (const [bindingName] of getEnvBindingsOfType( + workerOpts.config, + "send-email" + ) ?? {}) { + bindings.sendEmail.push({ bindingName }); + } + result[workerName] = bindings; } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index b1a3665cb23..a0f8ac6f85b 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -31,6 +31,7 @@ import { CoreBindings, CoreHeaders, viewToBuffer } from "../../workers"; import { getCacheServiceName } from "../cache"; import { DURABLE_OBJECTS_STORAGE_SERVICE_NAME } from "../do"; import { getDurableObjectNamespaces } from "../do/namespaces"; +import { getEmailStoreServices } from "../email/store"; import { IMAGES_PLUGIN_NAME } from "../images"; import { getR2PublicService, @@ -56,6 +57,7 @@ import { STREAM_PLUGIN_NAME } from "../stream"; import { CUSTOM_SERVICE_KNOWN_OUTBOUND, CustomServiceKind, + EMAIL_STORE_SERVICE_NAME, getBuiltinServiceName, getCustomFetchServiceName, getCustomNodeServiceName, @@ -796,6 +798,10 @@ export function getGlobalServices({ name: CoreBindings.SERVICE_DEV_CONTROL, service: { name: CoreBindings.SERVICE_DEV_CONTROL }, }, + { + name: CoreBindings.SERVICE_EMAIL_STORE, + service: { name: EMAIL_STORE_SERVICE_NAME }, + }, ]; if (sharedOptions.unsafeLocalExplorer) { serviceEntryBindings.push({ @@ -960,6 +966,8 @@ export function getGlobalServices({ services.push(r2S3Service); } + services.push(...getEmailStoreServices(tmpPath)); + if (sharedOptions.unsafeLocalExplorer) { const localExplorerUiPath = resolveLocalExplorerUi(tmpPath); const workflowOptions = new Map(); 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..5133ecf3f0c 100644 --- a/packages/miniflare/src/plugins/core/types.ts +++ b/packages/miniflare/src/plugins/core/types.ts @@ -51,6 +51,9 @@ export type WorkerResourceBindings = { className: string; scriptName: string; }[]; + sendEmail: { + 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..87aab0ced21 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,20 @@ 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 emailStoreBinding: Worker_Binding = { + name: CoreBindings.SERVICE_EMAIL_STORE, + service: { name: EMAIL_STORE_SERVICE_NAME }, + }; - const services: Service[] = diskServices.map(({ serviceName, path }) => ({ - name: serviceName, - disk: { - path, - writable: true, - }, - })); + // 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 = { + name: "SEND_EMAIL_OWNER_WORKER", + json: JSON.stringify(args.workerNames[args.workerIndex]), + }; + const services: Service[] = []; let hasRemote = false; for (const [name, binding] of sendEmailBindings) { if (getRemoteProxyConnectionString(binding, args.options.dev)) { @@ -181,7 +159,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 +170,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 5772e35bbf4..82d303addd5 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -99,6 +99,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..d0684f95547 100644 --- a/packages/miniflare/src/workers/core/email.ts +++ b/packages/miniflare/src/workers/core/email.ts @@ -1,10 +1,28 @@ import assert from "node:assert"; import { $, blue, red, reset, yellow } from "kleur/colors"; -import { LogLevel, SharedHeaders } from "miniflare:shared"; +import { LogLevel } from "miniflare:shared"; import PostalMime from "postal-mime"; +import { + captureRawForBodyRow, + MAX_PRODUCTION_EMAIL_BYTES, + RAW_EMAIL, + stripEmailHeader, +} from "../email/capture"; +import { getParsedEmailCaptureFields } from "../email/capture-metadata"; +import { logEmailToLoopback, storeEmailTempFile } from "../email/loopback"; +import { messageIdToStorageId, synthesizeMessageId } from "../email/message-id"; +import { buildReplyFromMessageBuilder } from "../email/mime"; import { isEmailReplyable, validateReply } from "../email/validate"; import { CoreBindings } from "./constants"; import type { MiniflareEmailMessage } from "../email/email.worker"; +import type { + EmailHandlerEvent, + EmailHandlerForward, + EmailHandlerReply, + EmailStoreService, + StoredRoutingEmailMetadata, +} from "../email/storage"; +import type { EmailReplyMessageBuilder } from "../email/types"; import type { ForwardableEmailMessage } from "@cloudflare/workers-types/experimental"; import type { Email } from "postal-mime"; @@ -14,42 +32,42 @@ $.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, 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[] = []; + const capturedReplyRawBase64: string[] = []; + const capturedReplyTruncated: boolean[] = []; // Turn an HTTP request into an EmailMessage, using: // - `from` and `to` from the URL @@ -68,36 +86,24 @@ export async function handleEmail( } ); } - // We need to parse the email body in this handler in order to validate it, but we also want to pass through - // the raw email to the user Worker. As such, clone the request for use in this handler. - const clonedRequest = request.clone(); - - assert(clonedRequest.body !== null, "Cloned request body is null"); - 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 } ); } + // SMTP removes Bcc before a recipient receives a message. Local injection can + // include it in the raw request, so derive the recipient-visible copy once and + // use it consistently for parsing, delivery, and capture. + const deliveredEmailRaw = stripEmailHeader(incomingEmailRaw, "bcc"); + let parsedIncomingEmail: Email; try { - parsedIncomingEmail = await PostalMime.parse(incomingEmailRaw); + parsedIncomingEmail = await PostalMime.parse(deliveredEmailRaw); } catch (e) { const error = e as Error; return new Response( @@ -116,202 +122,324 @@ export async function handleEmail( // Emails can contain both an "envelope" from/to and a "header" from/to. Warn if these are different. // Refer to https://datatracker.ietf.org/doc/html/rfc5321#section-3, https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.2, and https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 for more details if (from !== parsedIncomingEmail.from.address) { - await env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/log", - { - 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}`, - } + await logEmailToLoopback( + env[CoreBindings.SERVICE_LOOPBACK], + `${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 ?? "")}`, + LogLevel.WARN ); } if (!parsedIncomingEmail.to?.map((addr) => addr.address).includes(to)) { - await env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/log", - { - 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(", ")}`, - } + await logEmailToLoopback( + env[CoreBindings.SERVICE_LOOPBACK], + `${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(", ") ?? "")}`, + LogLevel.WARN ); } const incomingEmailHeaders = new Headers( - parsedIncomingEmail.headers.map((header) => [header.key, header.value]) + parsedIncomingEmail.headers + .filter(({ key }) => key.toLowerCase() !== "bcc") + .map((header) => [header.key, header.value]) ); - // Propogate `.setReject()` reasons to the caller + let outcome: "ok" | "exception" = "ok"; + // Propagate `.setReject()` reasons to the caller let rejectReason: string | undefined = undefined; - - // @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}"`)}`, - } - ) - ); - - events.push({ - type: "reject", - timestamp: new Date().toISOString(), - }); - rejectReason = reason; + function structuredResultResponse(): Response { + return Response.json( + { + outcome, + rejectReason, + forwards, + replies: replies.map(({ rawBase64: _rawBase64, ...reply }) => reply), + events, }, - 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: ${rcptTo}${renderEmailHeaders(headers)}`)}`, + { status: outcome === "ok" ? 200 : 500 } + ); + } + events.push({ type: "received", timestamp: new Date().toISOString() }); + + const store = env[CoreBindings.SERVICE_EMAIL_STORE]; + const storedFrom = from; + const storedTo = to; + const receivedAt = new Date().toISOString(); + // 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; + try { + const capturedRaw = captureRawForBodyRow(deliveredEmailRaw); + const rawBase64 = capturedRaw.rawBase64; + const parsedFields = getParsedEmailCaptureFields(parsedIncomingEmail, [ + "bcc", + ]); + const metadata: StoredRoutingEmailMetadata = { + worker: workerName, + from: storedFrom, + to: storedTo, + cc: parsedFields.cc, + subject: parsedFields.subject, + messageId: parsedIncomingEmail.messageId, + headers: parsedFields.headers, + receivedAt, + rawSize: deliveredEmailRaw.byteLength, + attachments: parsedFields.attachments, + outcome, + rejectReason, + forwards, + replies: replies.map( + ({ raw: _raw, rawBase64: _rawBase64, ...reply }, index) => ({ + ...reply, + ...(capturedReplyTruncated[index] + ? { captureTruncated: true } + : {}), + }) + ), + events, + ...(capturedRaw.truncated ? { captureTruncated: true } : {}), + }; + const captureId = crypto.randomUUID(); + try { + await store.storeReceivedBody(captureId, 0, rawBase64); + for (const [index] of replies.entries()) { + const replyRawBase64 = capturedReplyRawBase64[index]; + if (replyRawBase64 === undefined) { + throw new Error( + `Received email ${metadata.messageId} has no captured reply body at index ${index}` + ); } + await store.storeReceivedBody(captureId, index + 1, replyRawBase64); + } + await store.storeReceivedMetadata( + captureId, + replies.length + 1, + metadata ); - /** - * 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` }; + } catch (error) { + await store.discardReceived(captureId).catch(() => undefined); + throw error; + } + } catch (error) { + stored = false; + try { + await logEmailToLoopback( + env[CoreBindings.SERVICE_LOOPBACK], + `Failed to capture received email for the Local Explorer; the email was still delivered. Cause: ${escapeLogValue(error instanceof Error ? error.message : String(error))}`, + LogLevel.WARN + ); + } catch { + // Logging failures must not affect email handling. + } + } + } - events.push({ - type: "forward", - timestamp: new Date().toISOString(), - messageId: result.messageId, - }); - forwards.push({ - recipient: rcptTo, - headers: headers ? [...headers.entries()] : [], - messageId: result.messageId, - }); + try { + const deliveredEmailBody = new Response(deliveredEmailRaw).body; + assert(deliveredEmailBody !== null, "Delivered email body is null"); + // @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: deliveredEmailBody, + rawSize: deliveredEmailRaw.byteLength, + headers: incomingEmailHeaders, + setReject: (reason: string): void => { + ctx.waitUntil( + logEmailToLoopback( + env[CoreBindings.SERVICE_LOOPBACK], + `${red("Email handler rejected message")}${reset(` with the following reason: "${escapeLogValue(reason)}"`)}`, + LogLevel.ERROR + ) + ); - return result; - }, - reply: async (replyMessage): Promise => { - assert( - "from" in replyMessage && "to" in replyMessage, - "EmailReplyMessageBuilder is not currently supported" - ); + events.push({ + type: "reject", + timestamp: new Date().toISOString(), + }); + rejectReason = reason; + }, + forward: async ( + rcptTo: string, + headers?: Headers + ): Promise => { + await logEmailToLoopback( + env[CoreBindings.SERVICE_LOOPBACK], + `${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) }; - 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 finalReply = await validateReply( - parsedIncomingEmail, - replyMessage as MiniflareEmailMessage - ); + events.push({ + type: "forward", + timestamp: new Date().toISOString(), + messageId: result.messageId, + }); + forwards.push({ + recipient: rcptTo, + headers: headers ? [...headers.entries()] : [], + messageId: result.messageId, + }); - const resp = await env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/store-temp-file?extension=eml&prefix=email", - { - method: "POST", - body: finalReply, + return result; + }, + reply: async (replyMessage): Promise => { + if ( + !(await isEmailReplyable( + parsedIncomingEmail, + incomingEmailHeaders, + async (msg) => + void (await logEmailToLoopback( + env[CoreBindings.SERVICE_LOOPBACK], + msg, + LogLevel.ERROR + )) + )) + ) { + throw new Error("Original email is not replyable"); } - ); - const file = await resp.text(); + let validatedReply: { raw: Uint8Array; messageId: string }; + let replySender: string; + if (RAW_EMAIL in replyMessage) { + const rawReply = replyMessage as MiniflareEmailMessage; + validatedReply = await validateReply(parsedIncomingEmail, rawReply); + replySender = rawReply.from; + } else { + const builtReply = buildReplyFromMessageBuilder( + replyMessage as EmailReplyMessageBuilder, + parsedIncomingEmail, + from + ); + validatedReply = builtReply; + replySender = builtReply.sender; + } + const finalReply = validatedReply.raw; + const replyId = messageIdToStorageId(validatedReply.messageId); - 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}`)}`, + // 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 storeEmailTempFile( + env[CoreBindings.SERVICE_LOOPBACK], + finalReply, + { + extension: "eml", + prefix: "email/reply", + id: replyId, + } + ); + if (resp.ok) { + file = await resp.text(); + } else { + await logEmailToLoopback( + env[CoreBindings.SERVICE_LOOPBACK], + `${yellow("Failed to persist replied email for the Local Explorer; the reply was still sent")}${reset(`: ${escapeLogValue(await resp.text())}`)}`, + LogLevel.WARN + ); } + + await logEmailToLoopback( + env[CoreBindings.SERVICE_LOOPBACK], + `${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, + }); + const capturedReply = captureRawForBodyRow(finalReply); + capturedReplyRawBase64.push(capturedReply.rawBase64); + capturedReplyTruncated.push(capturedReply.truncated); + replies.push({ + messageId: result.messageId, + sender: replySender, + raw: new TextDecoder().decode(finalReply), + }); + return result; + }, + } satisfies ForwardableEmailMessage + ); + + 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 } ); + } + + return new Response("Worker successfully processed email", { + status: 200, + }); + } - /** - * 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", + try { + await emailEvent; + outcome = "ok"; + } catch (e) { + outcome = "exception"; + if (isMissingEmailHandlerError(e)) { + 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), + } else { + await logEmailToLoopback( + env[CoreBindings.SERVICE_LOOPBACK], + red(e instanceof Error ? (e.stack ?? String(e)) : String(e)), + LogLevel.ERROR + ).catch(() => { + // Logging failures must not affect delivery reporting. }); - 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 structuredResultResponse(); + } catch (e) { + outcome = "exception"; + if (isMissingEmailHandlerError(e)) { + events.splice(0, events.length, { + type: "unhandled", + timestamp: new Date().toISOString(), + }); + await storeReceivedEmail(); + if (params.get("format") === "json") { + return structuredResultResponse(); + } 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..07ff97c445f 100644 --- a/packages/miniflare/src/workers/core/entry.worker.ts +++ b/packages/miniflare/src/workers/core/entry.worker.ts @@ -11,12 +11,15 @@ import { handleEmail } from "./email"; import { STATUS_CODES } from "./http"; import { matchRoutes } from "./routing"; import { handleScheduled } from "./scheduled"; +import type { EmailStoreService } from "../email/storage"; import type { WorkerRoute } from "./routing"; import type { Colorize } from "kleur/colors"; type Env = { [CoreBindings.SERVICE_LOOPBACK]: Fetcher; + [CoreBindings.SERVICE_EMAIL_STORE]: EmailStoreService; [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 +563,7 @@ export default >{ url.searchParams, request, service, + routeTarget, env, ctx ); diff --git a/packages/miniflare/src/workers/email/address.ts b/packages/miniflare/src/workers/email/address.ts new file mode 100644 index 00000000000..4e829e59f9c --- /dev/null +++ b/packages/miniflare/src/workers/email/address.ts @@ -0,0 +1,28 @@ +import { extractAddressFromString } from "./message-id"; +import type { EmailAddress } from "./types"; + +function quoteDisplayName(name: string): string { + return `"${name.replace(/["\\]/gu, (character) => `\\${character}`)}"`; +} + +export function formatParsedAddress(address: { + address?: string; + name?: string; +}): string { + const email = address.address ?? ""; + return address.name === undefined || address.name === "" + ? email + : `${quoteDisplayName(address.name)} <${email}>`; +} + +export function extractEmailAddress(address: string | EmailAddress): string { + return typeof address === "string" + ? extractAddressFromString(address) + : address.email; +} + +export function formatEmailAddress(address: string | EmailAddress): string { + return typeof address === "string" + ? address + : `${quoteDisplayName(address.name)} <${address.email}>`; +} diff --git a/packages/miniflare/src/workers/email/capture-metadata.ts b/packages/miniflare/src/workers/email/capture-metadata.ts new file mode 100644 index 00000000000..f0cdec92b33 --- /dev/null +++ b/packages/miniflare/src/workers/email/capture-metadata.ts @@ -0,0 +1,50 @@ +import { formatParsedAddress } from "./address"; +import type { StoredEmailAttachment } from "./storage"; +import type { Email } from "postal-mime"; + +export interface ParsedEmailCaptureFields { + cc?: string[]; + bcc?: string[]; + replyTo?: string; + subject: string; + headers: Record; + attachments: StoredEmailAttachment[]; +} + +export function contentByteLength( + content: string | ArrayBuffer | ArrayBufferView +): number { + if (typeof content === "string") { + return new TextEncoder().encode(content).byteLength; + } + return content.byteLength; +} + +export function getParsedEmailCaptureFields( + email: Email, + excludedHeaderNames: readonly string[] = [] +): ParsedEmailCaptureFields { + const excludedHeaders = new Set( + excludedHeaderNames.map((name) => name.toLowerCase()) + ); + return { + cc: email.cc?.map(formatParsedAddress), + bcc: email.bcc?.map(formatParsedAddress), + replyTo: email.replyTo + ? email.replyTo.map(formatParsedAddress).join(", ") + : undefined, + subject: email.subject ?? "(no subject)", + headers: Object.fromEntries( + email.headers + .filter(({ key }) => !excludedHeaders.has(key.toLowerCase())) + .map(({ key, value }) => [key, value]) + ), + attachments: (email.attachments ?? []).map((attachment) => ({ + filename: attachment.filename ?? "attachment", + contentType: attachment.mimeType ?? "application/octet-stream", + disposition: + attachment.disposition === "inline" ? "inline" : "attachment", + size: contentByteLength(attachment.content), + })), + }; +} diff --git a/packages/miniflare/src/workers/email/capture.ts b/packages/miniflare/src/workers/email/capture.ts new file mode 100644 index 00000000000..6573ec9c391 --- /dev/null +++ b/packages/miniflare/src/workers/email/capture.ts @@ -0,0 +1,353 @@ +// Helpers for fitting captured email records into the local email store. +// +// Capture is a dev-only inspection aid for the Local Explorer. SQLite-backed +// Durable Objects limit each row and string value to 2 MB, so metadata is +// preserved first and body content is truncated to fit the remaining space. +// Delivery always uses the full, untruncated message. + +export const RAW_EMAIL = "EmailMessage::raw"; + +/** + * Maximum size of an email-store SQLite row or string value. + */ +export const MAX_EMAIL_ROW_BYTES = 2_000_000; + +/** + * 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; + +/** + * Reserve a small amount for SQLite's record header and the non-JSON columns + * (`kind`, `id`, and `created_at`). The remaining bytes are available to the + * serialized JSON value stored in `emails.data`, or to a body-table value. + */ +export const MAX_EMAIL_ROW_VALUE_BYTES = MAX_EMAIL_ROW_BYTES - 1024; +export const MAX_EMAIL_BODY_BYTES = + Math.floor(MAX_EMAIL_ROW_VALUE_BYTES / 4) * 3; + +const encoder = new TextEncoder(); + +/** 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 function jsonByteLength(value: unknown): number { + return encoder.encode(JSON.stringify(value)).byteLength; +} + +/** + * Removes top-level MIME headers from a captured copy without decoding or + * re-encoding the body. Delivery can continue using the original bytes. + */ +export function stripEmailHeader( + raw: Uint8Array, + headerName: string +): Uint8Array { + const removals: Array<{ start: number; end: number }> = []; + let offset = 0; + let headerStart = 0; + let removeHeader = false; + + while (offset < raw.byteLength) { + const line = findHeaderLine(raw, offset); + if (line.contentEnd === offset) { + if (removeHeader) { + removals.push({ start: headerStart, end: offset }); + removeHeader = false; + } + break; + } + + const continuation = raw[offset] === 0x20 || raw[offset] === 0x09; + if (!continuation) { + if (removeHeader) { + removals.push({ start: headerStart, end: offset }); + } + headerStart = offset; + removeHeader = headerNameMatches( + raw, + offset, + line.contentEnd, + headerName + ); + } + offset = line.end; + } + if (removeHeader) { + removals.push({ start: headerStart, end: offset }); + } + + if (removals.length === 0) { + return raw; + } + + const removedBytes = removals.reduce( + (total, removal) => total + removal.end - removal.start, + 0 + ); + const stripped = new Uint8Array(raw.byteLength - removedBytes); + let sourceOffset = 0; + let targetOffset = 0; + for (const removal of removals) { + const retained = raw.subarray(sourceOffset, removal.start); + stripped.set(retained, targetOffset); + targetOffset += retained.byteLength; + sourceOffset = removal.end; + } + stripped.set(raw.subarray(sourceOffset), targetOffset); + return stripped; +} + +export interface CapturedRaw { + /** Lossless base64 of the captured raw MIME prefix. */ + rawBase64: string; + /** Whether the content was truncated for capture. */ + truncated: boolean; +} + +/** + * Captures the largest raw MIME prefix whose Base64 representation fits in a + * body-table value. + */ +export function captureRawForBodyRow(raw: Uint8Array): CapturedRaw { + return captureRawForBase64Budget(raw, MAX_EMAIL_ROW_VALUE_BYTES); +} + +/** + * Captures the largest raw MIME prefix that fits beside the supplied metadata + * in a JSON email row. + */ +export function captureRawForJsonRow( + metadata: T, + raw: Uint8Array, + includeTruncationMarker = false +): { + email: T & { rawBase64?: string; captureTruncated?: boolean }; + truncated: boolean; +} { + assertMetadataFits(metadata); + const initial = captureRawForJsonRowMetadata(metadata, raw); + if (!initial.truncated || !includeTruncationMarker) { + return initial; + } + return captureRawForJsonRowMetadata( + { ...metadata, captureTruncated: true }, + raw + ); +} + +function captureRawForJsonRowMetadata( + metadata: T, + raw: Uint8Array +): { email: T & { rawBase64?: string }; truncated: boolean } { + assertMetadataFits(metadata); + const emptyRecord = { ...metadata, rawBase64: "" }; + const emptyRecordBytes = jsonByteLength(emptyRecord); + if (emptyRecordBytes > MAX_EMAIL_ROW_VALUE_BYTES) { + return { + email: { ...metadata }, + truncated: raw.byteLength > 0, + }; + } + const availableBase64Bytes = MAX_EMAIL_ROW_VALUE_BYTES - emptyRecordBytes; + const captured = captureRawForBase64Budget(raw, availableBase64Bytes); + return { + email: { ...metadata, rawBase64: captured.rawBase64 }, + truncated: captured.truncated, + }; +} + +/** + * Fits MessageBuilder body fields into a JSON email row. Each field is added + * only if its empty representation fits, so `text` receives priority and + * `html` consumes only the remaining bytes. + */ +export function captureTextAndHtmlForJsonRow( + metadata: T, + text: string | undefined, + html: string | undefined, + includeTruncationMarker = false +): { + email: T & { + text?: string; + html?: string; + captureTruncated?: boolean; + }; + truncated: boolean; +} { + const initial = captureTextAndHtmlForJsonRowMetadata(metadata, text, html); + if (!initial.truncated || !includeTruncationMarker) { + return initial; + } + return captureTextAndHtmlForJsonRowMetadata( + { ...metadata, captureTruncated: true }, + text, + html + ); +} + +function captureTextAndHtmlForJsonRowMetadata( + metadata: T, + text: string | undefined, + html: string | undefined +): { + email: T & { text?: string; html?: string }; + truncated: boolean; +} { + assertMetadataFits(metadata); + let email: T & { text?: string; html?: string } = { ...metadata }; + + let truncated = false; + if (text !== undefined) { + const captured = captureOptionalStringField(email, "text", text); + email = captured.email; + truncated ||= captured.truncated; + } + if (html !== undefined) { + const captured = captureOptionalStringField(email, "html", html); + email = captured.email; + truncated ||= captured.truncated; + } + return { email, truncated }; +} + +function assertMetadataFits(metadata: object): void { + if (jsonByteLength(metadata) > MAX_EMAIL_ROW_VALUE_BYTES) { + throw new RangeError("Email metadata exceeds the 2 MB storage row limit"); + } +} + +function captureRawForBase64Budget( + raw: Uint8Array, + maxBase64Bytes: number +): CapturedRaw { + const maxEncodedGroups = Math.max(0, Math.floor(maxBase64Bytes / 4)); + const maxRawBytes = maxEncodedGroups * 3; + const truncated = raw.byteLength > maxRawBytes; + const captured = truncated ? raw.subarray(0, maxRawBytes) : raw; + return { rawBase64: bytesToBase64(captured), truncated }; +} + +function captureOptionalStringField< + T extends object, + K extends "text" | "html", +>( + email: T & { text?: string; html?: string }, + field: K, + value: string +): { + email: T & { text?: string; html?: string }; + truncated: boolean; +} { + const emptyEmail = { ...email, [field]: "" }; + if (jsonByteLength(emptyEmail) > MAX_EMAIL_ROW_VALUE_BYTES) { + return { email, truncated: value.length > 0 }; + } + return captureStringField(emptyEmail, field, value); +} + +function captureStringField( + email: T & { text?: string; html?: string }, + field: K, + value: string +): { + email: T & { text?: string; html?: string }; + truncated: boolean; +} { + const fullEmail = { ...email, [field]: value }; + if (jsonByteLength(fullEmail) <= MAX_EMAIL_ROW_VALUE_BYTES) { + return { email: fullEmail, truncated: false }; + } + + let lower = 0; + let upper = value.length; + while (lower < upper) { + const middle = Math.ceil((lower + upper) / 2); + const candidate = safeStringPrefix(value, middle); + const candidateEmail = { ...email, [field]: candidate }; + if (jsonByteLength(candidateEmail) <= MAX_EMAIL_ROW_VALUE_BYTES) { + lower = middle; + } else { + upper = middle - 1; + } + } + return { + email: { ...email, [field]: safeStringPrefix(value, lower) }, + truncated: true, + }; +} + +function safeStringPrefix(value: string, length: number): string { + let end = Math.min(length, value.length); + if ( + end > 0 && + end < value.length && + value.charCodeAt(end - 1) >= 0xd800 && + value.charCodeAt(end - 1) <= 0xdbff && + value.charCodeAt(end) >= 0xdc00 && + value.charCodeAt(end) <= 0xdfff + ) { + end--; + } + return value.slice(0, end); +} + +function findHeaderLine( + raw: Uint8Array, + start: number +): { contentEnd: number; end: number } { + for (let index = start; index < raw.byteLength; index++) { + if (raw[index] !== 0x0a) { + continue; + } + const contentEnd = + index > start && raw[index - 1] === 0x0d ? index - 1 : index; + return { contentEnd, end: index + 1 }; + } + return { contentEnd: raw.byteLength, end: raw.byteLength }; +} + +function headerNameMatches( + raw: Uint8Array, + start: number, + end: number, + headerName: string +): boolean { + let colon = start; + while (colon < end && raw[colon] !== 0x3a) { + colon++; + } + if (colon === end || colon - start !== headerName.length) { + return false; + } + for (let index = 0; index < headerName.length; index++) { + const byte = raw[start + index]; + const lowerByte = byte >= 0x41 && byte <= 0x5a ? byte + 0x20 : byte; + const expected = headerName.charCodeAt(index); + const lowerExpected = + expected >= 0x41 && expected <= 0x5a ? expected + 0x20 : expected; + if (lowerByte !== lowerExpected) { + return false; + } + } + return 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/contracts.ts b/packages/miniflare/src/workers/email/contracts.ts new file mode 100644 index 00000000000..20a033b9670 --- /dev/null +++ b/packages/miniflare/src/workers/email/contracts.ts @@ -0,0 +1,243 @@ +import { z } from "zod"; + +export type EmailHandlerEvent = + | { + type: "received" | "reject" | "unhandled"; + timestamp: string; + } + | { + type: "forward" | "reply"; + timestamp: string; + messageId: string; + }; + +export const zEmailHandlerEvent = z + .discriminatedUnion("type", [ + z.object({ + type: z.enum(["received", "reject", "unhandled"]), + timestamp: z + .string() + .describe("ISO 8601 timestamp of when the event occurred."), + }), + z.object({ + type: z.enum(["forward", "reply"]), + timestamp: z + .string() + .describe("ISO 8601 timestamp of when the event occurred."), + messageId: z + .string() + .describe("Correlates with the matching `forwards`/`replies` entry."), + }), + ]) + .describe( + "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." + ) satisfies z.ZodType; + +export interface EmailHandlerForward { + messageId: string; + recipient: string; + headers: [string, string][]; +} + +export const zEmailHandlerForward = z.object({ + messageId: z.string(), + recipient: z + .string() + .describe("Envelope recipient the message was forwarded to."), + headers: z + .array(z.tuple([z.string(), z.string()])) + .describe("Headers added to the forwarded message."), +}) satisfies z.ZodType; + +const zEmailHandlerReplyBase = z.object({ + messageId: z.string(), + sender: z.string().describe("Address the reply was sent from."), +}); + +export const zEmailHandlerReplyApi = zEmailHandlerReplyBase.extend({ + raw: z + .string() + .describe( + "Raw MIME content of the reply. Omitted from the routing list; present on the detail response." + ) + .optional(), + rawBase64: z + .string() + .describe("Lossless base64 representation of the reply MIME.") + .optional(), +}); + +export const zEmailHandlerReply = zEmailHandlerReplyBase.extend({ + raw: z.string().describe("Raw MIME content of the reply."), + rawBase64: z + .string() + .describe("Lossless base64 representation of the reply MIME.") + .optional(), +}); + +export interface EmailHandlerReply { + messageId: string; + sender: string; + raw: string; + rawBase64?: string; +} + +export interface EmailHandlerResult { + outcome: "ok" | "exception"; + rejectReason?: string; + forwards: EmailHandlerForward[]; + replies: EmailHandlerReply[]; + events: EmailHandlerEvent[]; +} + +export const zEmailHandlerResult = z.object({ + outcome: z.enum(["ok", "exception"]), + rejectReason: z + .string() + .describe( + "Reason passed to `setReject()`, if the handler rejected the message." + ) + .optional(), + forwards: z.array(zEmailHandlerForward), + replies: z.array(zEmailHandlerReply), + events: z + .array(zEmailHandlerEvent) + .describe( + "Ordered lifecycle of everything the handler did to the message." + ), +}) satisfies z.ZodType; + +export const zEmailAttachment = z + .object({ + filename: z.string(), + contentType: z.string(), + disposition: z.enum(["inline", "attachment"]), + size: z.number(), + }) + .describe( + "Metadata describing an attachment on a captured email, without its content." + ); + +export type EmailAttachment = z.infer; + +export const zEmailBase = z.object({ + worker: z + .string() + .describe("Worker associated with the email, if known.") + .optional(), + from: z.string().describe("Envelope MAIL FROM address."), + subject: z.string(), + messageId: z + .string() + .describe( + "RFC Message-ID header value. Identifies the email in the store." + ), + attachments: z + .array(zEmailAttachment) + .describe( + "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME." + ), +}); + +export const zEmailRoutingItem = zEmailBase.extend({ + to: z.string().describe("Envelope RCPT TO address."), + cc: z.array(z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), + receivedAt: z.string(), + rawSize: z.number(), + outcome: z + .enum(["ok", "exception"]) + .describe("Whether the handler ran to completion or threw."), + rejectReason: z + .string() + .describe( + "Reason passed to setReject(), if the handler rejected the message." + ) + .optional(), + forwards: z.array(zEmailHandlerForward), + replies: z.array(zEmailHandlerReplyApi), + events: z.array(zEmailHandlerEvent), +}); + +export type EmailRoutingItem = z.infer; + +export const zEmailRoutingDetail = zEmailRoutingItem.extend({ + raw: z.string().describe("Raw MIME content of the received email."), + rawBase64: z + .string() + .describe("Lossless base64 representation of the received MIME.") + .optional(), +}); + +export type EmailRoutingDetail = z.infer; + +const zEmailSendAttachment = z.object({ + filename: z.string().describe("Name the attachment is presented under."), + type: z + .string() + .describe("MIME type of the attachment, e.g. 'application/pdf'."), + content: z + .string() + .describe( + "Attachment content, base64-encoded. MessageBuilder takes raw bytes here, but this endpoint accepts JSON so the bytes must be base64-encoded." + ), + contentId: z + .string() + .describe("Content-ID for an inline attachment.") + .optional(), + disposition: z + .enum(["inline", "attachment"]) + .describe("How the attachment is presented. Defaults to 'attachment'.") + .optional(), +}); + +export const zEmailSendRequest = z + .object({ + from: z.string().describe("Sender address."), + to: z.array(z.string()).min(1).describe("Recipient addresses."), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + replyTo: z.string().optional(), + subject: z.string(), + text: z.string().describe("Plain text body.").optional(), + html: z.string().describe("HTML body.").optional(), + headers: z + .record(z.string(), z.string()) + .describe("Custom headers to include on the message.") + .optional(), + attachments: z + .array(zEmailSendAttachment) + .describe( + "Attachments to include on the message, mirroring the MessageBuilder `attachments` entries accepted by a send_email binding. Adding any attachment composes the message as multipart/mixed." + ) + .optional(), + }) + .describe("Fields for composing a test email, mirroring MessageBuilder."); + +export type EmailSendRequest = z.infer; + +export const zEmailSendingItem = zEmailBase.extend({ + to: z.array(z.string()), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + replyTo: z.string().optional(), + sentAt: z.string(), + headers: z.record(z.string(), z.string()).optional(), +}); + +export type EmailSendingItem = z.infer; + +export const zEmailSendingDetail = zEmailSendingItem.extend({ + text: z.string().optional(), + html: z.string().optional(), + raw: z + .string() + .describe("Raw MIME content, present when sent via the EmailMessage API.") + .optional(), + rawBase64: z + .string() + .describe("Lossless base64 representation of sent MIME.") + .optional(), +}); + +export type EmailSendingDetail = z.infer; 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..9058e72541c --- /dev/null +++ b/packages/miniflare/src/workers/email/email-store.ts @@ -0,0 +1,559 @@ +/** + * 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. + * + * Metadata records are stored as JSON blobs, discriminated by kind and ordered + * by capture time. Received and reply MIME bodies are stored through separate + * direct RPCs into separate rows, then the metadata row is published last. + * 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 { + base64ToBytes, + bytesToBase64, + MAX_EMAIL_ROW_VALUE_BYTES, +} from "./capture"; +import { + zEmailBase, + zEmailHandlerForward, + zEmailHandlerReplyApi, + zEmailSendingDetail, +} from "./contracts"; +import { messageIdToStorageId } from "./message-id"; +import type { + EmailListPage, + StoredRoutingEmail, + StoredRoutingEmailMetadata, + StoredRoutingEmailSummary, + StoredSendingEmail, + StoredSendingEmailSummary, +} from "./storage"; + +export type { StoredSendingEmail }; + +function decodeCapturedRaw(rawBase64: string, truncated: boolean): string { + const bytes = base64ToBytes(rawBase64); + return new TextDecoder().decode( + truncated ? trimIncompleteUtf8Suffix(bytes) : bytes + ); +} + +function trimIncompleteUtf8Suffix(bytes: Uint8Array): Uint8Array { + if (bytes.byteLength === 0) { + return bytes; + } + let sequenceStart = bytes.byteLength - 1; + while ( + sequenceStart > 0 && + (bytes[sequenceStart] & 0xc0) === 0x80 && + bytes.byteLength - sequenceStart < 4 + ) { + sequenceStart--; + } + const leadingByte = bytes[sequenceStart]; + const expectedLength = + (leadingByte & 0x80) === 0 + ? 1 + : (leadingByte & 0xe0) === 0xc0 + ? 2 + : (leadingByte & 0xf0) === 0xe0 + ? 3 + : (leadingByte & 0xf8) === 0xf0 + ? 4 + : 1; + return bytes.byteLength - sequenceStart < expectedLength + ? bytes.subarray(0, sequenceStart) + : bytes; +} + +function materialiseReceivedEmail( + email: StoredRoutingEmailMetadata, + rawBase64: string, + replyRawBase64: Map +): StoredRoutingEmail { + return { + ...email, + raw: decodeCapturedRaw(rawBase64, email.captureTruncated === true), + rawBase64, + replies: email.replies.map((reply, index) => { + const encoded = replyRawBase64.get(index); + if (encoded === undefined) { + throw new Error( + `Received email ${email.messageId} has no captured reply body at index ${index}` + ); + } + return { + ...reply, + raw: decodeCapturedRaw(encoded, reply.captureTruncated === true), + rawBase64: encoded, + }; + }), + }; +} + +/** Decodes a sent record's `raw` when it was stored base64-only. */ +function materialiseSentEmail(email: StoredSendingEmail): StoredSendingEmail { + if (email.raw !== undefined || email.rawBase64 === undefined) { + return email; + } + return { + ...email, + raw: decodeCapturedRaw(email.rawBase64, email.captureTruncated === true), + }; +} + +const SCHEMA = [ + `CREATE TABLE IF NOT EXISTS email_store_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, + `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_created_seq ON emails ( + kind, created_at DESC, seq DESC + )`, + `CREATE INDEX IF NOT EXISTS emails_by_kind_id ON emails (kind, id)`, + `CREATE INDEX IF NOT EXISTS emails_by_kind_worker_seq ON emails ( + kind, json_extract(data, '$.worker'), seq DESC + )`, + `CREATE INDEX IF NOT EXISTS emails_by_kind_worker_created_seq ON emails ( + kind, json_extract(data, '$.worker'), created_at DESC, seq DESC + )`, + `CREATE TABLE IF NOT EXISTS received_email_bodies ( + capture_id TEXT NOT NULL, + part INTEGER NOT NULL, + raw_base64 TEXT NOT NULL, + PRIMARY KEY (capture_id, part) + )`, +]; + +const zStoredEmailReply = zEmailHandlerReplyApi.omit({ + raw: true, + rawBase64: true, +}); +const zStoredEmailReplyMetadata = zStoredEmailReply.extend({ + captureTruncated: z.boolean().optional(), +}); +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 zStoredRoutingEmailSummary = zEmailBase.extend({ + to: z.string(), + cc: z.array(z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), + receivedAt: z.string(), + rawSize: z.number(), + outcome: z.enum(["ok", "exception"]), + rejectReason: z.string().optional(), + forwards: z.array(zEmailHandlerForward), + replies: z.array(zStoredEmailReply), + events: z.array(zStoredEmailEvent), +}); +const zStoredRoutingEmailMetadata = zStoredRoutingEmailSummary.extend({ + captureTruncated: z.boolean().optional(), + replies: z.array(zStoredEmailReplyMetadata), +}); +export const zStoredRoutingEmail = zStoredRoutingEmailMetadata.extend({ + raw: z.string(), + rawBase64: z.string(), + replies: z.array( + zEmailHandlerReplyApi.extend({ + raw: z.string(), + rawBase64: z.string(), + captureTruncated: z.boolean().optional(), + }) + ), +}); + +type EmailTable = "received" | "sent"; +type EmailCursor = { createdAt: string; seq: number }; +const encoder = new TextEncoder(); + +function assertEmailRowValueFits(value: string, description: string): void { + if (encoder.encode(value).byteLength > MAX_EMAIL_ROW_VALUE_BYTES) { + throw new RangeError( + `${description} exceeds the ${MAX_EMAIL_ROW_VALUE_BYTES}-byte email storage row value limit` + ); + } +} + +function createStatements(kind: EmailTable) { + return { + insert: `INSERT INTO emails (kind, id, created_at, data) + VALUES ('${kind}', ?, ?, ?) RETURNING seq`, + list: `SELECT seq, created_at, data FROM emails + WHERE kind = '${kind}' + ORDER BY created_at DESC, seq DESC LIMIT ?`, + listForWorker: `SELECT seq, created_at, data FROM emails + WHERE kind = '${kind}' AND json_extract(data, '$.worker') = ? + ORDER BY created_at DESC, seq DESC LIMIT ?`, + listAfter: `SELECT seq, created_at, data FROM emails + WHERE kind = '${kind}' + AND (created_at < ? OR (created_at = ? AND seq < ?)) + ORDER BY created_at DESC, seq DESC LIMIT ?`, + listAfterForWorker: `SELECT seq, created_at, data FROM emails + WHERE kind = '${kind}' + AND (created_at < ? OR (created_at = ? AND seq < ?)) + AND json_extract(data, '$.worker') = ? + ORDER BY created_at DESC, seq DESC LIMIT ?`, + find: `SELECT seq, data FROM emails WHERE kind = '${kind}' AND id = ? + ORDER BY seq DESC LIMIT 1`, + findForWorker: `SELECT seq, data FROM emails + WHERE kind = '${kind}' AND id = ? + AND json_extract(data, '$.worker') = ? + ORDER BY seq DESC LIMIT 1`, + }; +} + +const STATEMENTS = { + received: createStatements("received"), + sent: createStatements("sent"), + insertReceivedBody: `INSERT INTO received_email_bodies + (capture_id, part, raw_base64) VALUES (?, ?, ?)`, + countReceivedBodies: `SELECT COUNT(*) AS count, MIN(part) AS first_part, + MAX(part) AS last_part FROM received_email_bodies WHERE capture_id = ?`, + findReceivedBodies: `SELECT part, raw_base64 FROM received_email_bodies + WHERE capture_id = ? ORDER BY part`, + discardReceivedBodies: + "DELETE FROM received_email_bodies WHERE capture_id = ?", + discardReceivedMetadata: + "DELETE FROM emails WHERE kind = 'received' AND json_extract(data, '$.bodyId') = ?", + insertMetadata: `INSERT OR IGNORE INTO email_store_metadata (key, value) + VALUES (?, ?)`, + findMetadata: "SELECT value FROM email_store_metadata WHERE key = ?", + clearReceivedBodies: "DELETE FROM received_email_bodies", + 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.createdAt !== "string" || + 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 getSentSummary(email: StoredSendingEmail): StoredSendingEmailSummary { + const { + text: _text, + html: _html, + raw: _raw, + rawBase64: _rawBase64, + captureTruncated: _captureTruncated, + ...summary + } = email; + return summary; +} + +export class EmailStore extends DurableObject { + private sql = this.ctx.storage.sql; + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env as never); + this.ctx.blockConcurrencyWhile(async () => { + for (const stmt of SCHEMA) { + this.sql.exec(stmt); + } + this.sql.exec( + STATEMENTS.insertMetadata, + "source_id", + crypto.randomUUID() + ); + }); + } + + getSourceId(): string { + const row = this.sql + .exec<{ value: string }>(STATEMENTS.findMetadata, "source_id") + .toArray()[0]; + if (row === undefined) { + throw new Error("Email store source ID is unavailable"); + } + return row.value; + } + + #insert( + table: EmailTable, + id: string, + createdAt: string, + data: unknown + ): number { + const encoded = JSON.stringify(data); + assertEmailRowValueFits(encoded, `${table} email metadata`); + const row = this.sql + .exec<{ seq: number }>(STATEMENTS[table].insert, id, createdAt, encoded) + .toArray()[0]; + if (row === undefined) { + throw new Error(`Failed to store ${table} email`); + } + return row.seq; + } + + /** Newest-first cursor page of records from a table. */ + #list( + table: EmailTable, + parse: (data: string) => T, + cursor: string | undefined, + limit: number | undefined, + worker: string | undefined + ): EmailListPage { + const pageSize = normaliseLimit(limit); + const rows = + cursor === undefined + ? this.sql + .exec<{ seq: number; created_at: string; data: string }>( + worker === undefined + ? STATEMENTS[table].list + : STATEMENTS[table].listForWorker, + ...(worker === undefined + ? [pageSize + 1] + : [worker, pageSize + 1]) + ) + .toArray() + : (() => { + const decoded = decodeCursor(cursor); + return this.sql + .exec<{ seq: number; created_at: string; data: string }>( + worker === undefined + ? STATEMENTS[table].listAfter + : STATEMENTS[table].listAfterForWorker, + ...(worker === undefined + ? [ + decoded.createdAt, + decoded.createdAt, + decoded.seq, + pageSize + 1, + ] + : [ + decoded.createdAt, + decoded.createdAt, + decoded.seq, + worker, + 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({ + createdAt: last.created_at, + seq: last.seq, + }), + } + : {}), + }; + } + + /** Most recently stored full record with the given message ID. */ + #find(table: EmailTable, id: string, worker?: string): T | undefined { + const row = this.sql + .exec<{ data: string }>( + worker === undefined + ? STATEMENTS[table].find + : STATEMENTS[table].findForWorker, + ...(worker === undefined ? [id] : [id, worker]) + ) + .toArray()[0]; + return row === undefined ? undefined : (JSON.parse(row.data) as T); + } + + storeReceivedBody(captureId: string, part: number, rawBase64: string): void { + if (!Number.isSafeInteger(part) || part < 0) { + throw new RangeError("Invalid received email body part"); + } + assertEmailRowValueFits(rawBase64, "Received email body"); + this.sql.exec(STATEMENTS.insertReceivedBody, captureId, part, rawBase64); + } + + storeReceivedMetadata( + captureId: string, + expectedBodyParts: number, + email: StoredRoutingEmailMetadata + ): void { + if (!Number.isSafeInteger(expectedBodyParts) || expectedBodyParts < 1) { + throw new RangeError("Invalid received email body count"); + } + this.ctx.storage.transactionSync(() => { + const bodies = this.sql + .exec<{ + count: number; + first_part: number | null; + last_part: number | null; + }>(STATEMENTS.countReceivedBodies, captureId) + .toArray()[0]; + if ( + bodies === undefined || + bodies.count !== expectedBodyParts || + bodies.first_part !== 0 || + bodies.last_part !== expectedBodyParts - 1 + ) { + throw new Error( + `Received email ${email.messageId} has incomplete captured bodies` + ); + } + this.#insert( + "received", + messageIdToStorageId(email.messageId), + email.receivedAt, + { ...email, bodyId: captureId } + ); + }); + } + + discardReceived(captureId: string): void { + this.ctx.storage.transactionSync(() => { + this.sql.exec(STATEMENTS.discardReceivedBodies, captureId); + this.sql.exec(STATEMENTS.discardReceivedMetadata, captureId); + }); + } + + findReceived(id: string, worker?: string): StoredRoutingEmail | undefined { + const row = this.sql + .exec<{ data: string }>( + worker === undefined + ? STATEMENTS.received.find + : STATEMENTS.received.findForWorker, + ...(worker === undefined ? [id] : [id, worker]) + ) + .toArray()[0]; + if (row === undefined) { + return undefined; + } + const stored = JSON.parse(row.data) as unknown; + const bodyId = + typeof stored === "object" && + stored !== null && + "bodyId" in stored && + typeof stored.bodyId === "string" + ? stored.bodyId + : undefined; + if (bodyId === undefined) { + throw new Error(`Received email ${id} has no body identifier`); + } + const bodies = this.sql + .exec<{ part: number; raw_base64: string }>( + STATEMENTS.findReceivedBodies, + bodyId + ) + .toArray(); + const rawBase64 = bodies.find(({ part }) => part === 0)?.raw_base64; + if (rawBase64 === undefined) { + throw new Error(`Received email ${id} has no captured body`); + } + return materialiseReceivedEmail( + zStoredRoutingEmailMetadata.parse(stored), + rawBase64, + new Map( + bodies + .filter(({ part }) => part > 0) + .map(({ part, raw_base64 }) => [part - 1, raw_base64]) + ) + ); + } + + listReceived( + cursor?: string, + limit?: number, + worker?: string + ): EmailListPage { + return this.#list( + "received", + (data) => zStoredRoutingEmailSummary.parse(JSON.parse(data)), + cursor, + limit, + worker + ); + } + + storeSent(email: StoredSendingEmail): void { + this.#insert( + "sent", + messageIdToStorageId(email.messageId), + email.sentAt, + email + ); + } + + findSent(id: string, worker?: string): StoredSendingEmail | undefined { + const email = this.#find("sent", id, worker); + return email === undefined ? undefined : materialiseSentEmail(email); + } + + listSent( + cursor?: string, + limit?: number, + worker?: string + ): EmailListPage { + return this.#list( + "sent", + (data) => getSentSummary(zEmailSendingDetail.parse(JSON.parse(data))), + cursor, + limit, + worker + ); + } + + clear(): void { + this.ctx.storage.transactionSync(() => { + this.sql.exec(STATEMENTS.clearReceivedBodies); + 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..6641c12c73c --- /dev/null +++ b/packages/miniflare/src/workers/email/email-store.worker.ts @@ -0,0 +1,107 @@ +/** + * 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, + StoredRoutingEmailSummary, + StoredSendingEmail, + 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 getSourceId(): Promise { + return await this.#store().getSourceId(); + } + + async storeReceivedBody( + captureId: string, + part: number, + rawBase64: string + ): Promise { + await this.#store().storeReceivedBody(captureId, part, rawBase64); + } + + async storeReceivedMetadata( + captureId: string, + expectedBodyParts: number, + email: StoredRoutingEmailMetadata + ): Promise { + await this.#store().storeReceivedMetadata( + captureId, + expectedBodyParts, + email + ); + } + + async discardReceived(captureId: string): Promise { + await this.#store().discardReceived(captureId); + } + + async findReceived( + id: string, + worker?: string + ): Promise { + const email = await this.#store().findReceived(id, worker); + return email === undefined ? undefined : zStoredRoutingEmail.parse(email); + } + + async listReceived( + cursor?: string, + limit?: number, + worker?: string + ): Promise> { + const page = await this.#store().listReceived(cursor, limit, worker); + return { + ...page, + items: zStoredRoutingEmailSummary.array().parse(page.items), + }; + } + + async storeSent(email: StoredSendingEmail): Promise { + await this.#store().storeSent(email); + } + + async findSent( + id: string, + worker?: string + ): Promise { + return await this.#store().findSent(id, worker); + } + + async listSent( + cursor?: string, + limit?: number, + worker?: string + ): Promise> { + return await this.#store().listSent(cursor, limit, worker); + } + + 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/input-validation.ts b/packages/miniflare/src/workers/email/input-validation.ts new file mode 100644 index 00000000000..45620b9ad97 --- /dev/null +++ b/packages/miniflare/src/workers/email/input-validation.ts @@ -0,0 +1,29 @@ +const TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; +const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/u; + +export function hasControlCharacters(value: string): boolean { + return /[\u0000-\u001f\u007f]/u.test(value); +} + +export function isMimeType(value: string): boolean { + const separator = value.indexOf("/"); + return ( + separator > 0 && + separator === value.lastIndexOf("/") && + TOKEN_PATTERN.test(value.slice(0, separator)) && + TOKEN_PATTERN.test(value.slice(separator + 1)) + ); +} + +export function normalizeBase64(value: string): string | undefined { + const normalized = value.replace(/\s/gu, ""); + if (normalized.length % 4 !== 0 || !BASE64_PATTERN.test(normalized)) { + return undefined; + } + try { + atob(normalized); + return normalized; + } catch { + return undefined; + } +} diff --git a/packages/miniflare/src/workers/email/loopback.ts b/packages/miniflare/src/workers/email/loopback.ts new file mode 100644 index 00000000000..8615ce7585d --- /dev/null +++ b/packages/miniflare/src/workers/email/loopback.ts @@ -0,0 +1,45 @@ +import { LogLevel, SharedHeaders } from "miniflare:shared"; + +export function logEmailToLoopback( + loopback: Fetcher, + message: string, + level: LogLevel = LogLevel.INFO +): Promise { + return loopback.fetch("http://localhost/core/log", { + method: "POST", + headers: { [SharedHeaders.LOG_LEVEL]: level.toString() }, + body: message, + }); +} + +export function storeEmailTempFile( + loopback: Fetcher, + content: string | ArrayBuffer | ArrayBufferView, + options: { + extension: string; + prefix: string; + id: string; + } +): Promise { + let body: string | Uint8Array; + if (typeof content === "string") { + body = content; + } else if (content instanceof ArrayBuffer) { + body = new Uint8Array(content); + } else { + body = new Uint8Array( + content.buffer, + content.byteOffset, + content.byteLength + ); + } + + const params = new URLSearchParams(options); + return loopback.fetch( + `http://localhost/core/store-temp-file?${params.toString()}`, + { + method: "POST", + body, + } + ); +} 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..cfd97231295 --- /dev/null +++ b/packages/miniflare/src/workers/email/message-id.ts @@ -0,0 +1,123 @@ +// 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. + +const ID_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + +/** + * Builds a Message-ID in the shape the production `send_email` binding returns: + * `<{36 base-62 characters}@{sender domain}>`. + */ +export function synthesizeMessageId(senderEmail: string): string { + const bytes = crypto.getRandomValues(new Uint8Array(36)); + const id = Array.from( + bytes, + (byte) => ID_ALPHABET[byte % ID_ALPHABET.length] + ).join(""); + const domain = senderEmail.slice(senderEmail.lastIndexOf("@") + 1); + return `<${id}@${domain}>`; +} + +/** + * Sets the top-level Message-ID header without decoding or rewriting the MIME + * body. Existing folded or duplicate Message-ID headers are replaced by one + * normalized header. + */ +export function setMessageIdHeader( + rawEmail: Uint8Array, + messageId: string +): Uint8Array { + const crlfSeparator = new Uint8Array([13, 10, 13, 10]); + const lfSeparator = new Uint8Array([10, 10]); + const crlfHeaderEnd = findSequence(rawEmail, crlfSeparator); + const lfHeaderEnd = findSequence(rawEmail, lfSeparator); + const usesCrlf = + crlfHeaderEnd !== -1 && + (lfHeaderEnd === -1 || crlfHeaderEnd <= lfHeaderEnd); + const headerEnd = usesCrlf ? crlfHeaderEnd : lfHeaderEnd; + if (headerEnd === -1) { + throw new Error("could not find end of email headers"); + } + + const lineEnding = usesCrlf ? "\r\n" : "\n"; + const header = new TextDecoder().decode(rawEmail.subarray(0, headerEnd)); + const lines = header.split(/\r?\n/u); + const normalizedLines: string[] = []; + let foundMessageId = false; + let skippingContinuation = false; + + for (const line of lines) { + if (/^[ \t]/u.test(line)) { + if (!skippingContinuation) { + normalizedLines.push(line); + } + continue; + } + + skippingContinuation = /^message-id\s*:/iu.test(line); + if (skippingContinuation) { + if (!foundMessageId) { + normalizedLines.push(`Message-ID: ${messageId}`); + foundMessageId = true; + } + continue; + } + normalizedLines.push(line); + } + + if (!foundMessageId) { + normalizedLines.unshift(`Message-ID: ${messageId}`); + } + + const encodedHeaders = new TextEncoder().encode( + normalizedLines.join(lineEnding) + ); + const separator = usesCrlf ? crlfSeparator : lfSeparator; + const body = rawEmail.subarray(headerEnd + separator.byteLength); + const normalizedEmail = new Uint8Array( + encodedHeaders.byteLength + separator.byteLength + body.byteLength + ); + normalizedEmail.set(encodedHeaders); + normalizedEmail.set(separator, encodedHeaders.byteLength); + normalizedEmail.set(body, encodedHeaders.byteLength + separator.byteLength); + return normalizedEmail; +} + +function findSequence(bytes: Uint8Array, sequence: Uint8Array): number { + for ( + let index = 0; + index <= bytes.byteLength - sequence.byteLength; + index++ + ) { + if ( + sequence.every( + (value, sequenceIndex) => bytes[index + sequenceIndex] === value + ) + ) { + return index; + } + } + return -1; +} + +/** + * 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, ""); +} + +/** + * Extracts the bare email address from a string that may be in `"Name" + *
`, `Name
`, or plain `address` form. + */ +export function extractAddressFromString(value: string): string { + const match = value.match(/<([^>]+)>\s*$/u); + return (match ? match[1] : value).trim(); +} diff --git a/packages/miniflare/src/workers/email/mime.ts b/packages/miniflare/src/workers/email/mime.ts new file mode 100644 index 00000000000..def85d39d74 --- /dev/null +++ b/packages/miniflare/src/workers/email/mime.ts @@ -0,0 +1,255 @@ +import { extractEmailAddress, formatEmailAddress } from "./address"; +import { bytesToBase64 } from "./capture"; +import { + hasControlCharacters, + isMimeType, + normalizeBase64, +} from "./input-validation"; +import { synthesizeMessageId } from "./message-id"; +import type { EmailReplyMessageBuilder } from "./types"; +import type { Email } from "postal-mime"; + +export interface MimeAttachment { + disposition?: "inline" | "attachment"; + contentId?: string; + filename: string; + type: string; + content: string; +} + +export interface MimeMessage { + from: string; + to: string[]; + cc?: string[]; + replyTo?: string; + subject: string; + headers?: Record; + text?: string; + html?: string; + attachments?: MimeAttachment[]; +} + +export function buildMimeMessage( + message: MimeMessage, + messageId: string, + generatedHeaders: Record = {} +): string { + const headers: string[] = [ + `From: ${message.from}`, + `To: ${message.to.join(", ")}`, + ]; + if (message.cc?.length) { + headers.push(`Cc: ${message.cc.join(", ")}`); + } + if (message.replyTo) { + headers.push(`Reply-To: ${message.replyTo}`); + } + headers.push(`Subject: ${message.subject}`); + headers.push(`Message-ID: ${messageId}`); + headers.push(`Date: ${new Date().toUTCString()}`); + headers.push("MIME-Version: 1.0"); + for (const [key, value] of Object.entries(generatedHeaders)) { + headers.push(`${key}: ${value}`); + } + + const managedHeaders = new Set([ + "bcc", + "message-id", + "content-type", + "content-transfer-encoding", + ...Object.keys(generatedHeaders).map((name) => name.toLowerCase()), + ]); + for (const [key, value] of Object.entries(message.headers ?? {})) { + if (managedHeaders.has(key.toLowerCase())) { + continue; + } + headers.push(`${key}: ${value}`); + } + + const text = message.text ?? ""; + const html = message.html; + + let contentHeaders: string[]; + let content: string; + + if (html && message.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 = message.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}>`}`, + ] + : []), + "", + 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")}`; +} + +function attachmentContentToBase64( + content: string | ArrayBuffer | ArrayBufferView +): string { + if (typeof content === "string") { + const normalized = normalizeBase64(content); + if (normalized === undefined) { + throw new Error("invalid attachment content"); + } + return normalized; + } + const bytes = + content instanceof ArrayBuffer + ? new Uint8Array(content) + : new Uint8Array(content.buffer, content.byteOffset, content.byteLength); + return bytesToBase64(bytes); +} + +export function buildReplyFromMessageBuilder( + builder: EmailReplyMessageBuilder, + incomingMessage: Email, + recipient: string +): { raw: Uint8Array; messageId: string; sender: string } { + const sender = formatEmailAddress(builder.from); + const replyTo = + builder.replyTo === undefined + ? undefined + : formatEmailAddress(builder.replyTo); + const headerValues = [ + sender, + recipient, + replyTo, + builder.subject, + ...(builder.attachments ?? []).flatMap((attachment) => [ + attachment.filename, + attachment.contentId, + ]), + ].filter((value): value is string => value !== undefined); + if (headerValues.some(hasControlCharacters)) { + throw new Error("invalid headers set"); + } + for (const attachment of builder.attachments ?? []) { + if ( + !isMimeType(attachment.type) || + (attachment.disposition !== undefined && + attachment.disposition !== "inline" && + attachment.disposition !== "attachment") || + (attachment.disposition === "inline" && !attachment.contentId) + ) { + throw new Error("invalid attachment"); + } + } + + if (Object.values(builder.headers ?? {}).some(hasControlCharacters)) { + throw new Error("invalid headers set"); + } + let customHeaders: Headers; + try { + customHeaders = new Headers(builder.headers); + } catch { + throw new Error("invalid headers set"); + } + if (customHeaders.has("received")) { + throw new Error("invalid headers set"); + } + for (const name of [ + "from", + "to", + "cc", + "bcc", + "reply-to", + "subject", + "message-id", + "in-reply-to", + "references", + "date", + "mime-version", + "content-type", + "content-transfer-encoding", + ]) { + customHeaders.delete(name); + } + + const incomingMessageId = incomingMessage.messageId; + if (incomingMessageId === undefined) { + throw new Error("Original email has no Message-ID"); + } + const messageId = synthesizeMessageId(extractEmailAddress(builder.from)); + const references = + incomingMessage.references === undefined + ? incomingMessageId + : `${incomingMessage.references} ${incomingMessageId}`; + const raw = buildMimeMessage( + { + from: sender, + to: [recipient], + replyTo, + subject: builder.subject, + headers: Object.fromEntries(customHeaders), + text: builder.text, + html: builder.html, + attachments: builder.attachments?.map((attachment) => ({ + disposition: attachment.disposition, + contentId: attachment.contentId, + filename: attachment.filename, + type: attachment.type, + content: attachmentContentToBase64(attachment.content), + })), + }, + messageId, + { + "In-Reply-To": incomingMessageId, + References: references, + } + ); + return { + raw: new TextEncoder().encode(raw), + messageId, + sender, + }; +} diff --git a/packages/miniflare/src/workers/email/send_email.worker.ts b/packages/miniflare/src/workers/email/send_email.worker.ts index 725eba84a76..f25d35fc7b4 100644 --- a/packages/miniflare/src/workers/email/send_email.worker.ts +++ b/packages/miniflare/src/workers/email/send_email.worker.ts @@ -1,47 +1,48 @@ import { WorkerEntrypoint } from "cloudflare:workers"; -import { blue } from "kleur/colors"; +import { $, blue } from "kleur/colors"; +import { LogLevel } from "miniflare:shared"; import PostalMime from "postal-mime"; -import { RAW_EMAIL } from "./constants"; +import { CoreBindings } from "../core/constants"; +import { extractEmailAddress, formatEmailAddress } from "./address"; +import { + captureRawForJsonRow, + captureTextAndHtmlForJsonRow, + RAW_EMAIL, +} from "./capture"; +import { + contentByteLength, + getParsedEmailCaptureFields, +} from "./capture-metadata"; import { type MiniflareEmailMessage as EmailMessage } from "./email.worker"; +import { logEmailToLoopback, storeEmailTempFile } from "./loopback"; +import { + messageIdToStorageId, + setMessageIdHeader, + synthesizeMessageId, +} from "./message-id"; +import type { + EmailStoreService, + StoredEmailAttachment, + StoredSendingEmail, +} from "./storage"; import type { EmailAddress, MessageBuilder } from "./types"; import type { Email } from "postal-mime"; -/** - * 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. - */ -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}>`; -} - -/** - * Extracts the bare email address from a string (which may be in - * `"Name"
` or plain address format) or EmailAddress object. - */ -function extractEmailAddress(addr: string | EmailAddress): string { - if (typeof addr !== "string") { - return addr.email; - } - // Match "Name"
or Name
or just address - const match = addr.match(/<([^>]+)>$/); - return match ? match[1].trim() : addr.trim(); -} - -/** - * Formats an email address for display - */ -function formatEmailAddress(addr: string | EmailAddress): string { - if (typeof addr === "string") { - return addr; - } - return `"${addr.name}" <${addr.email}>`; +// Force-enable colours. +$.enabled = true; + +// Cap the extension length so a pathological filename (e.g. `file.` followed +// by thousands of chars) can't produce a temp-file suffix that overruns the +// filesystem's name-length limit. +const MAX_ATTACHMENT_EXTENSION_LENGTH = 32; + +function getAttachmentExtension(filename: string): string { + const extension = filename.match(/\.([^.]+)$/u)?.[1]; + return extension !== undefined && + extension.length <= MAX_ATTACHMENT_EXTENSION_LENGTH && + /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(extension) + ? extension + : "bin"; } /** @@ -70,100 +71,84 @@ 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. */ + 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}`); - } - return binding; + private async log( + message: string, + level: LogLevel = LogLevel.INFO + ): Promise { + await logEmailToLoopback(this.env.MINIFLARE_LOOPBACK, message, level); } /** - * Logs a message via the runtime console. + * Builds and captures a sent email into the local email store for the explorer. + * + * Capture is a dev-only inspection aid: any failure here (row budgeting, + * store RPC) is swallowed so it never affects the result of `send()`. */ - private log(message: string): void { - console.log(message); + private async captureSentEmail( + build: () => { email: StoredSendingEmail; truncated: boolean } + ): Promise { + try { + const captured = build(); + await this.env[CoreBindings.SERVICE_EMAIL_STORE].storeSent( + captured.email + ); + } catch { + this.ctx.waitUntil( + 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. + }) + ); + } } /** - * 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") { - body = content; - } else if (content instanceof ArrayBuffer) { - body = new Uint8Array(content); - } else { - // ArrayBufferView - body = new Uint8Array( - content.buffer, - content.byteOffset, - content.byteLength - ); - } - - const fileName = messageUUID - ? `${messageUUID}.${extension}` - : `${crypto.randomUUID()}.${extension}`; - const url = new URL(`${prefix}/${fileName}`, "http://placeholder/"); - - // Find the disk service config for the requested location. - const diskConfig = this.env.email_disk_services.find( - (config) => config.location === location + const resp = await storeEmailTempFile( + this.env.MINIFLARE_LOOPBACK, + content, + { + prefix: `email/${prefix}`, + extension, + id, + } ); - 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 +215,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 +257,55 @@ export class SendEmailBinding extends WorkerEntrypoint { throw new Error("invalid headers set"); } - const locations = this.env.email_disk_services.map( - (service) => service.location + // Always synthesise new ID for user sent emails. + const messageId = synthesizeMessageId(emailMessage.from); + const id = messageIdToStorageId(messageId); + const normalizedRawEmailBuffer = setMessageIdHeader( + rawEmailBuffer, + messageId ); - const filePaths = await Promise.all( - locations.map((location) => - this.storeTempFile( - rawEmailBuffer, - "eml", - "email", - location, - messageUUID - ) + const normalizedParsedEmail = await PostalMime.parse( + normalizedRawEmailBuffer + ); + + // Complete the workerd-side capture before resolving send(). File writes + // remain deferred because they cross the Node loopback service. + await this.captureSentEmail(() => + captureRawForJsonRow( + { + worker: this.env.SEND_EMAIL_OWNER_WORKER, + from: emailMessage.from, + to: [emailMessage.to], + ...getParsedEmailCaptureFields(normalizedParsedEmail), + sentAt: new Date().toISOString(), + messageId, + }, + normalizedRawEmailBuffer, + true ) ); - // 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}` + this.ctx.waitUntil( + (async () => { + const filePath = await this.storeTempFile( + normalizedRawEmailBuffer, + "eml", + "email", + 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 +313,105 @@ 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, + const sentAttachments: StoredEmailAttachment[] = ( + builder.attachments ?? [] + ).map((attachment) => ({ + filename: attachment.filename, + contentType: attachment.type, + disposition: attachment.disposition ?? "attachment", + size: contentByteLength(attachment.content), + })); + + // Complete the workerd-side capture before resolving send() + await this.captureSentEmail(() => + captureTextAndHtmlForJsonRow( + { + 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, + headers: builder.headers, + attachments: sentAttachments, + }, + builder.text, + builder.html, + true + ) + ); + + // 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", - 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(`HTML: ${htmlResults[logIndex]}`); - } + id + ); + files.push(`HTML: ${htmlPath}`); + } + + if (builder.attachments) { + for (const [index, attachment] of builder.attachments.entries()) { + const extension = getAttachmentExtension(attachment.filename); - // 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( + const attachmentPath = await this.storeTempFile( attachment.content, extension, "email-attachment", - location, - attachmentUUID - ) - ) + `${id}-${index + 1}` + ); + files.push( + `Attachment (${attachment.disposition ?? "attachment"}): ${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}` ); - // 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]}` - ); - } - } - - // 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..0364b9fce8b --- /dev/null +++ b/packages/miniflare/src/workers/email/storage.ts @@ -0,0 +1,121 @@ +// 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, + EmailHandlerReply, + EmailHandlerResult, + EmailRoutingDetail, + EmailRoutingItem, + EmailSendingDetail, + EmailSendingItem, +} from "./contracts"; + +export type { + EmailHandlerEvent, + EmailHandlerForward, + EmailHandlerReply, + EmailHandlerResult, +} from "./contracts"; + +interface StoredCaptureMetadata { + captureTruncated?: boolean; +} + +type StoredEmailHandlerReply = EmailHandlerReply & StoredCaptureMetadata; + +export type StoredRoutingEmail = Omit< + EmailRoutingDetail, + "forwards" | "replies" +> & + Omit & + StoredCaptureMetadata & { + replies: StoredEmailHandlerReply[]; + }; + +export type StoredRoutingEmailMetadata = Omit< + StoredRoutingEmail, + "raw" | "rawBase64" | "replies" +> & { + // Raw bodies are stored in separate rows, so the metadata record carries + // only reply envelope fields. + replies: Array< + Omit + >; +}; + +export type StoredRoutingEmailSummary = Omit< + EmailRoutingItem, + "forwards" | "replies" +> & { + forwards: EmailHandlerForward[]; + replies: Array>; +}; + +export type StoredEmailAttachment = EmailAttachment; + +export type StoredSendingEmail = EmailSendingDetail & StoredCaptureMetadata; + +export type StoredSendingEmailSummary = EmailSendingItem; + +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 { + getSourceId(): Promise; + storeReceivedBody( + captureId: string, + part: number, + rawBase64: string + ): Promise; + storeReceivedMetadata( + captureId: string, + expectedBodyParts: number, + email: StoredRoutingEmailMetadata + ): Promise; + discardReceived(captureId: string): Promise; + /** Looks up a received email by local storage ID and optional worker. */ + findReceived( + id: string, + worker?: string + ): Promise; + listReceived( + cursor?: string, + limit?: number, + worker?: string + ): Promise>; + storeSent(email: StoredSendingEmail): Promise; + /** Looks up a sent email by its local storage ID and optional worker. */ + findSent( + id: string, + worker?: string + ): Promise; + listSent( + cursor?: string, + limit?: number, + worker?: string + ): Promise>; + clear(): Promise; +} diff --git a/packages/miniflare/src/workers/email/types.ts b/packages/miniflare/src/workers/email/types.ts index cfc51b3ad03..415f6e680d7 100644 --- a/packages/miniflare/src/workers/email/types.ts +++ b/packages/miniflare/src/workers/email/types.ts @@ -21,15 +21,18 @@ export interface EmailAddress { email: string; } -export interface MessageBuilder { +export interface EmailReplyMessageBuilder { from: string | EmailAddress; - to: string | EmailAddress | (string | EmailAddress)[]; subject: string; replyTo?: string | EmailAddress; - cc?: string | EmailAddress | (string | EmailAddress)[]; - bcc?: string | EmailAddress | (string | EmailAddress)[]; headers?: Record; text?: string; html?: string; attachments?: EmailAttachment[]; } + +export interface MessageBuilder extends EmailReplyMessageBuilder { + to: string | EmailAddress | (string | EmailAddress)[]; + cc?: string | EmailAddress | (string | EmailAddress)[]; + bcc?: string | EmailAddress | (string | EmailAddress)[]; +} diff --git a/packages/miniflare/src/workers/email/validate.ts b/packages/miniflare/src/workers/email/validate.ts index b9e0883cd82..942a4b9fad1 100644 --- a/packages/miniflare/src/workers/email/validate.ts +++ b/packages/miniflare/src/workers/email/validate.ts @@ -1,7 +1,8 @@ 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 { setMessageIdHeader, synthesizeMessageId } from "./message-id"; import type { Email } from "postal-mime"; // Email Routing has some limits on what emails can be responded to, documented at https://developers.cloudflare.com/email-routing/email-workers/reply-email-workers/ @@ -64,7 +65,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( @@ -83,10 +84,16 @@ export async function validateReply( throw new Error("From: header does not match mail from"); } - if (parsedReply.messageId === undefined) { + const hasMessageIdHeader = parsedReply.headers.some( + (header) => header.key.toLowerCase() === "message-id" + ); + if (parsedReply.messageId === undefined && hasMessageIdHeader) { throw new Error("invalid message-id"); } + const messageId = synthesizeMessageId(replyMessage.from); + const headersToPrepend: string[] = []; + const replyEmailHeaders = new Headers( parsedReply.headers.map((header) => [header.key, header.value]) ); @@ -119,19 +126,20 @@ export async function validateReply( } } else { // Otherwise, we need to construct a new References header according to https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.4 - const replyReferences = `References: ${incomingMessage.messageId}${incomingReferences.length > 0 ? " " : ""}${incomingReferences}\r\n`; - - const encodedReferences = new TextEncoder().encode(replyReferences); - - const finalReplyEmail = new Uint8Array( - encodedReferences.byteLength + rawEmailBuffer.byteLength + headersToPrepend.push( + `References: ${incomingReferences}${incomingReferences.length > 0 ? " " : ""}${incomingMessage.messageId}\r\n` ); - - // 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 rawEmailBuffer; + let finalReplyEmail = rawEmailBuffer; + if (headersToPrepend.length > 0) { + const encodedHeaders = new TextEncoder().encode(headersToPrepend.join("")); + const replyWithReferences = new Uint8Array( + encodedHeaders.byteLength + finalReplyEmail.byteLength + ); + replyWithReferences.set(encodedHeaders, 0); + replyWithReferences.set(finalReplyEmail, encodedHeaders.byteLength); + finalReplyEmail = replyWithReferences; + } + return { raw: setMessageIdHeader(finalReplyEmail, messageId), 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/aggregation.ts b/packages/miniflare/src/workers/local-explorer/aggregation.ts index d5288cad0bf..0289acf1a65 100644 --- a/packages/miniflare/src/workers/local-explorer/aggregation.ts +++ b/packages/miniflare/src/workers/local-explorer/aggregation.ts @@ -58,6 +58,16 @@ export async function getPeerUrlsIfAggregating( ); } +export function getPeerEntrypoint( + peerDebugPortAddress: string, + service: string +): Fetcher { + const client = (env as AppContext["env"]).DEV_REGISTRY_DEBUG_PORT.connect( + peerDebugPortAddress + ); + return client.getEntrypoint(service); +} + /** * Fetch data from a peer instance's explorer API. * Returns null on any error (silent omission policy). @@ -72,10 +82,7 @@ export async function fetchFromPeer( init?: RequestInit ): Promise { try { - const client = (env as AppContext["env"]).DEV_REGISTRY_DEBUG_PORT.connect( - peerDebugPortAddress - ); - const fetcher = client.getEntrypoint("core:entry"); + const fetcher = getPeerEntrypoint(peerDebugPortAddress, "core:entry"); const url = new URL(`http://localhost${EXPLORER_API_PATH}${apiPath}`); const response = await fetcher.fetch(url.toString(), { ...init, diff --git a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts index 3258ee6f6c5..4ecc4d86319 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, @@ -25,6 +28,13 @@ import { import openApiSpec from "./openapi.local.json"; import { listD1Databases, rawD1Database } from "./resources/d1"; import { listDONamespaces, listDOObjects, queryDOSqlite } from "./resources/do"; +import { + getReceivedEmail, + getSentEmail, + listReceivedEmails, + listSentEmails, + sendTestEmail, +} from "./resources/email"; import { bulkGetKVValues, deleteKVValue, @@ -61,6 +71,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 = { @@ -80,6 +91,8 @@ 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. Backs the Email tab's routing/sending views. + [CoreBindings.SERVICE_EMAIL_STORE]: EmailStoreService; }; export type AppBindings = { Bindings: Env }; @@ -379,6 +392,39 @@ app.post( app.post("/api/local/observability/clear", (c) => clearTraces(c)); +// ============================================================================ +// Email Endpoints +// ============================================================================ + +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 92472cf8f41..d636ff44abf 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/index.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/index.ts @@ -46,12 +46,38 @@ export type { DurableObjectsNamespaceQuerySqliteErrors, DurableObjectsNamespaceQuerySqliteResponse, DurableObjectsNamespaceQuerySqliteResponses, + EmailAttachment, + EmailBase, + 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, LocalExplorerListWorkersErrors, LocalExplorerListWorkersResponse, LocalExplorerListWorkersResponses, + LocalExplorerNamedBinding, LocalExplorerResourceBinding, LocalExplorerWorker, LocalExplorerWorkerBindings, 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 5d017920996..07a684f6072 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,17 @@ export type LocalExplorerWorkerBindings = { * Workflow bindings */ workflows?: Array; + /** + * Send Email bindings + */ + sendEmail?: Array; +}; + +export type LocalExplorerNamedBinding = { + /** + * Name of the binding in the worker's env + */ + bindingName: string; }; export type LocalExplorerResourceBinding = { @@ -778,6 +789,417 @@ 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 = + | { + type: "received" | "reject" | "unhandled"; + /** + * ISO 8601 timestamp of when the event occurred. + */ + timestamp: string; + } + | { + 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<[string, 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<{ + filename: string; + contentType: string; + disposition: "inline" | "attachment"; + size: number; + }>; +}; + +export type EmailRoutingItem = { + /** + * 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<{ + filename: string; + contentType: string; + disposition: "inline" | "attachment"; + size: number; + }>; + /** + * Envelope RCPT TO address. + */ + to: string; + cc?: Array; + headers?: { + [key: string]: 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<{ + messageId: string; + /** + * Envelope recipient the message was forwarded to. + */ + recipient: string; + /** + * Headers added to the forwarded message. + */ + headers: Array<[string, string]>; + }>; + replies: Array<{ + 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; + }>; + /** + * 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. + */ + events: Array< + | { + type: "received" | "reject" | "unhandled"; + /** + * ISO 8601 timestamp of when the event occurred. + */ + timestamp: string; + } + | { + type: "forward" | "reply"; + /** + * ISO 8601 timestamp of when the event occurred. + */ + timestamp: string; + /** + * Correlates with the matching `forwards`/`replies` entry. + */ + messageId: string; + } + >; +}; + +export type EmailRoutingDetail = { + /** + * 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<{ + filename: string; + contentType: string; + disposition: "inline" | "attachment"; + size: number; + }>; + /** + * Envelope RCPT TO address. + */ + to: string; + cc?: Array; + headers?: { + [key: string]: 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<{ + messageId: string; + /** + * Envelope recipient the message was forwarded to. + */ + recipient: string; + /** + * Headers added to the forwarded message. + */ + headers: Array<[string, string]>; + }>; + replies: Array<{ + 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; + }>; + /** + * 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. + */ + events: Array< + | { + type: "received" | "reject" | "unhandled"; + /** + * ISO 8601 timestamp of when the event occurred. + */ + timestamp: string; + } + | { + type: "forward" | "reply"; + /** + * ISO 8601 timestamp of when the event occurred. + */ + timestamp: string; + /** + * Correlates with the matching `forwards`/`replies` entry. + */ + messageId: string; + } + >; + /** + * 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 = { + /** + * 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<{ + filename: string; + contentType: string; + disposition: "inline" | "attachment"; + size: number; + }>; + to: Array; + cc?: Array; + bcc?: Array; + replyTo?: string; + sentAt: string; + headers?: { + [key: string]: string; + }; +}; + +export type EmailSendingDetail = { + /** + * 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<{ + filename: string; + contentType: string; + disposition: "inline" | "attachment"; + size: number; + }>; + to: Array; + cc?: Array; + bcc?: Array; + replyTo?: string; + sentAt: string; + headers?: { + [key: string]: string; + }; + 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 +1888,157 @@ 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 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 9481bbb513a..f4067d8477d 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts @@ -411,6 +411,10 @@ export const zDoRawQueryResult = z.object({ .optional(), }); +export const zLocalExplorerNamedBinding = z.object({ + bindingName: z.string(), +}); + export const zLocalExplorerResourceBinding = z.object({ id: z.string(), bindingName: z.string(), @@ -440,6 +444,7 @@ export const zLocalExplorerWorkerBindings = z.object({ r2: z.array(zLocalExplorerResourceBinding).optional(), do: z.array(zLocalExplorerDoBinding).optional(), workflows: z.array(zLocalExplorerWorkflowBinding).optional(), + sendEmail: z.array(zLocalExplorerNamedBinding).optional(), }); export const zLocalExplorerWorker = z.object({ @@ -528,6 +533,233 @@ 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.tuple([z.string(), z.string()])), +}); + +export const zEmailHandlerReply = z.object({ + messageId: z.string(), + sender: z.string(), + raw: z.string().optional(), + rawBase64: z.string().optional(), +}); + +export const zEmailBase = z.object({ + worker: z.string().optional(), + from: z.string(), + subject: z.string(), + messageId: z.string(), + attachments: z.array( + z.object({ + filename: z.string(), + contentType: z.string(), + disposition: z.enum(["inline", "attachment"]), + size: z.number(), + }) + ), +}); + +export const zEmailRoutingItem = z.object({ + worker: z.string().optional(), + from: z.string(), + subject: z.string(), + messageId: z.string(), + attachments: z.array( + z.object({ + filename: z.string(), + contentType: z.string(), + disposition: z.enum(["inline", "attachment"]), + size: z.number(), + }) + ), + to: z.string(), + cc: z.array(z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), + receivedAt: z.string(), + rawSize: z.number(), + outcome: z.enum(["ok", "exception"]), + rejectReason: z.string().optional(), + forwards: z.array( + z.object({ + messageId: z.string(), + recipient: z.string(), + headers: z.array(z.tuple([z.string(), z.string()])), + }) + ), + replies: z.array( + z.object({ + messageId: z.string(), + sender: z.string(), + raw: z.string().optional(), + rawBase64: z.string().optional(), + }) + ), + events: z.array( + 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 zEmailRoutingDetail = z.object({ + worker: z.string().optional(), + from: z.string(), + subject: z.string(), + messageId: z.string(), + attachments: z.array( + z.object({ + filename: z.string(), + contentType: z.string(), + disposition: z.enum(["inline", "attachment"]), + size: z.number(), + }) + ), + to: z.string(), + cc: z.array(z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), + receivedAt: z.string(), + rawSize: z.number(), + outcome: z.enum(["ok", "exception"]), + rejectReason: z.string().optional(), + forwards: z.array( + z.object({ + messageId: z.string(), + recipient: z.string(), + headers: z.array(z.tuple([z.string(), z.string()])), + }) + ), + replies: z.array( + z.object({ + messageId: z.string(), + sender: z.string(), + raw: z.string().optional(), + rawBase64: z.string().optional(), + }) + ), + events: z.array( + 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(), + }), + ]) + ), + raw: z.string(), + 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 zEmailSendingItem = z.object({ + worker: z.string().optional(), + from: z.string(), + subject: z.string(), + messageId: z.string(), + attachments: z.array( + z.object({ + filename: z.string(), + contentType: z.string(), + disposition: z.enum(["inline", "attachment"]), + size: z.number(), + }) + ), + 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 = z.object({ + worker: z.string().optional(), + from: z.string(), + subject: z.string(), + messageId: z.string(), + attachments: z.array( + z.object({ + filename: z.string(), + contentType: z.string(), + disposition: z.enum(["inline", "attachment"]), + size: z.number(), + }) + ), + 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(), + 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 +1160,93 @@ 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.int().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.int().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 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.int().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.int().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 54f2b65389f..497fff1f977 100644 --- a/packages/miniflare/src/workers/local-explorer/openapi.local.json +++ b/packages/miniflare/src/workers/local-explorer/openapi.local.json @@ -1282,6 +1282,296 @@ "tags": ["Local Explorer"] } }, + "/local/email/routing": { + "get": { + "description": "Lists emails received by any email() handler during this dev session. Use the optional `worker` query parameter to filter by worker, or `email_id` to return one email's details.", + "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": "integer", + "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": "integer" + }, + "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/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": "integer", + "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": "integer" + }, + "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.", @@ -3274,6 +3564,23 @@ "$ref": "#/components/schemas/local-explorer_workflow-binding" }, "description": "Workflow bindings" + }, + "sendEmail": { + "type": "array", + "items": { + "$ref": "#/components/schemas/local-explorer_named-binding" + }, + "description": "Send Email bindings" + } + } + }, + "local-explorer_named-binding": { + "type": "object", + "required": ["bindingName"], + "properties": { + "bindingName": { + "type": "string", + "description": "Name of the binding in the worker's env" } } }, @@ -3509,6 +3816,830 @@ } }, "required": ["columns", "rows"] + }, + "email_handler-event": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["received", "reject", "unhandled"] + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp of when the event occurred." + } + }, + "required": ["type", "timestamp"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["forward", "reply"] + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp of when the event occurred." + }, + "messageId": { + "type": "string", + "description": "Correlates with the matching `forwards`/`replies` entry." + } + }, + "required": ["type", "timestamp", "messageId"], + "additionalProperties": false + } + ], + "description": "One entry in the ordered lifecycle of what the handler did to the message. `received` is first for any message actually delivered to an `email()` handler. The exception is `unhandled`: when the Worker exports no `email()` handler the message never reaches one, so the timeline is a single `unhandled` event with no preceding `received`. `forward`/`reply` events carry a `messageId` correlating with the matching `forwards`/`replies` entry." + }, + "email_handler-forward": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "recipient": { + "type": "string", + "description": "Envelope recipient the message was forwarded to." + }, + "headers": { + "type": "array", + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "minItems": 2, + "maxItems": 2 + }, + "description": "Headers added to the forwarded message." + } + }, + "required": ["messageId", "recipient", "headers"], + "additionalProperties": false + }, + "email_handler-reply": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "sender": { + "type": "string", + "description": "Address the reply was sent from." + }, + "raw": { + "type": "string", + "description": "Raw MIME content of the reply. Omitted from the routing list; present on the detail response." + }, + "rawBase64": { + "type": "string", + "description": "Lossless base64 representation of the reply MIME." + } + }, + "required": ["messageId", "sender"], + "additionalProperties": false + }, + "email_base": { + "type": "object", + "properties": { + "worker": { + "type": "string", + "description": "Worker associated with the email, if known." + }, + "from": { + "type": "string", + "description": "Envelope MAIL FROM address." + }, + "subject": { + "type": "string" + }, + "messageId": { + "type": "string", + "description": "RFC Message-ID header value. Identifies the email in the store." + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "disposition": { + "type": "string", + "enum": ["inline", "attachment"] + }, + "size": { + "type": "number" + } + }, + "required": ["filename", "contentType", "disposition", "size"], + "additionalProperties": false, + "description": "Metadata describing an attachment on a captured email, without its content." + }, + "description": "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME." + } + }, + "required": ["from", "subject", "messageId", "attachments"], + "additionalProperties": false + }, + "email_routing-item": { + "type": "object", + "properties": { + "worker": { + "type": "string", + "description": "Worker 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": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "disposition": { + "type": "string", + "enum": ["inline", "attachment"] + }, + "size": { + "type": "number" + } + }, + "required": ["filename", "contentType", "disposition", "size"], + "additionalProperties": false, + "description": "Metadata describing an attachment on a captured email, without its content." + }, + "description": "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME." + }, + "to": { + "type": "string", + "description": "Envelope RCPT TO address." + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "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": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "recipient": { + "type": "string", + "description": "Envelope recipient the message was forwarded to." + }, + "headers": { + "type": "array", + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "minItems": 2, + "maxItems": 2 + }, + "description": "Headers added to the forwarded message." + } + }, + "required": ["messageId", "recipient", "headers"], + "additionalProperties": false + } + }, + "replies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "sender": { + "type": "string", + "description": "Address the reply was sent from." + }, + "raw": { + "type": "string", + "description": "Raw MIME content of the reply. Omitted from the routing list; present on the detail response." + }, + "rawBase64": { + "type": "string", + "description": "Lossless base64 representation of the reply MIME." + } + }, + "required": ["messageId", "sender"], + "additionalProperties": false + } + }, + "events": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["received", "reject", "unhandled"] + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp of when the event occurred." + } + }, + "required": ["type", "timestamp"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["forward", "reply"] + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp of when the event occurred." + }, + "messageId": { + "type": "string", + "description": "Correlates with the matching `forwards`/`replies` entry." + } + }, + "required": ["type", "timestamp", "messageId"], + "additionalProperties": false + } + ], + "description": "One entry in the ordered lifecycle of what the handler did to the message. `received` is first for any message actually delivered to an `email()` handler. The exception is `unhandled`: when the Worker exports no `email()` handler the message never reaches one, so the timeline is a single `unhandled` event with no preceding `received`. `forward`/`reply` events carry a `messageId` correlating with the matching `forwards`/`replies` entry." + } + } + }, + "required": [ + "from", + "subject", + "messageId", + "attachments", + "to", + "receivedAt", + "rawSize", + "outcome", + "forwards", + "replies", + "events" + ], + "additionalProperties": false + }, + "email_routing-detail": { + "type": "object", + "properties": { + "worker": { + "type": "string", + "description": "Worker associated with the email, if known." + }, + "from": { + "type": "string", + "description": "Envelope MAIL FROM address." + }, + "subject": { + "type": "string" + }, + "messageId": { + "type": "string", + "description": "RFC Message-ID header value. Identifies the email in the store." + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "disposition": { + "type": "string", + "enum": ["inline", "attachment"] + }, + "size": { + "type": "number" + } + }, + "required": ["filename", "contentType", "disposition", "size"], + "additionalProperties": false, + "description": "Metadata describing an attachment on a captured email, without its content." + }, + "description": "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME." + }, + "to": { + "type": "string", + "description": "Envelope RCPT TO address." + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "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": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "recipient": { + "type": "string", + "description": "Envelope recipient the message was forwarded to." + }, + "headers": { + "type": "array", + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string" + } + ] + }, + "minItems": 2, + "maxItems": 2 + }, + "description": "Headers added to the forwarded message." + } + }, + "required": ["messageId", "recipient", "headers"], + "additionalProperties": false + } + }, + "replies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "sender": { + "type": "string", + "description": "Address the reply was sent from." + }, + "raw": { + "type": "string", + "description": "Raw MIME content of the reply. Omitted from the routing list; present on the detail response." + }, + "rawBase64": { + "type": "string", + "description": "Lossless base64 representation of the reply MIME." + } + }, + "required": ["messageId", "sender"], + "additionalProperties": false + } + }, + "events": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["received", "reject", "unhandled"] + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp of when the event occurred." + } + }, + "required": ["type", "timestamp"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["forward", "reply"] + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp of when the event occurred." + }, + "messageId": { + "type": "string", + "description": "Correlates with the matching `forwards`/`replies` entry." + } + }, + "required": ["type", "timestamp", "messageId"], + "additionalProperties": false + } + ], + "description": "One entry in the ordered lifecycle of what the handler did to the message. `received` is first for any message actually delivered to an `email()` handler. The exception is `unhandled`: when the Worker exports no `email()` handler the message never reaches one, so the timeline is a single `unhandled` event with no preceding `received`. `forward`/`reply` events carry a `messageId` correlating with the matching `forwards`/`replies` entry." + } + }, + "raw": { + "type": "string", + "description": "Raw MIME content of the received email." + }, + "rawBase64": { + "type": "string", + "description": "Lossless base64 representation of the received MIME." + } + }, + "required": [ + "from", + "subject", + "messageId", + "attachments", + "to", + "receivedAt", + "rawSize", + "outcome", + "forwards", + "replies", + "events", + "raw" + ], + "additionalProperties": false + }, + "email_send-request": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "Sender address." + }, + "to": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + }, + "description": "Recipient addresses." + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "replyTo": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "text": { + "type": "string", + "description": "Plain text body." + }, + "html": { + "type": "string", + "description": "HTML body." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Custom headers to include on the message." + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "Name the attachment is presented under." + }, + "type": { + "type": "string", + "description": "MIME type of the attachment, e.g. 'application/pdf'." + }, + "content": { + "type": "string", + "description": "Attachment content, base64-encoded. MessageBuilder takes raw bytes here, but this endpoint accepts JSON so the bytes must be base64-encoded." + }, + "contentId": { + "type": "string", + "description": "Content-ID for an inline attachment." + }, + "disposition": { + "type": "string", + "enum": ["inline", "attachment"], + "description": "How the attachment is presented. Defaults to 'attachment'." + } + }, + "required": ["filename", "type", "content"], + "additionalProperties": false + }, + "description": "Attachments to include on the message, mirroring the MessageBuilder `attachments` entries accepted by a send_email binding. Adding any attachment composes the message as multipart/mixed." + } + }, + "required": ["from", "to", "subject"], + "additionalProperties": false, + "description": "Fields for composing a test email, mirroring MessageBuilder." + }, + "email_attachment": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "disposition": { + "type": "string", + "enum": ["inline", "attachment"] + }, + "size": { + "type": "number" + } + }, + "required": ["filename", "contentType", "disposition", "size"], + "additionalProperties": false, + "description": "Metadata describing an attachment on a captured email, without its content." + }, + "email_sending-item": { + "type": "object", + "properties": { + "worker": { + "type": "string", + "description": "Worker associated with the email, if known." + }, + "from": { + "type": "string", + "description": "Envelope MAIL FROM address." + }, + "subject": { + "type": "string" + }, + "messageId": { + "type": "string", + "description": "RFC Message-ID header value. Identifies the email in the store." + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "disposition": { + "type": "string", + "enum": ["inline", "attachment"] + }, + "size": { + "type": "number" + } + }, + "required": ["filename", "contentType", "disposition", "size"], + "additionalProperties": false, + "description": "Metadata describing an attachment on a captured email, without its content." + }, + "description": "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME." + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "replyTo": { + "type": "string" + }, + "sentAt": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "from", + "subject", + "messageId", + "attachments", + "to", + "sentAt" + ], + "additionalProperties": false + }, + "email_sending-detail": { + "type": "object", + "properties": { + "worker": { + "type": "string", + "description": "Worker associated with the email, if known." + }, + "from": { + "type": "string", + "description": "Envelope MAIL FROM address." + }, + "subject": { + "type": "string" + }, + "messageId": { + "type": "string", + "description": "RFC Message-ID header value. Identifies the email in the store." + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "disposition": { + "type": "string", + "enum": ["inline", "attachment"] + }, + "size": { + "type": "number" + } + }, + "required": ["filename", "contentType", "disposition", "size"], + "additionalProperties": false, + "description": "Metadata describing an attachment on a captured email, without its content." + }, + "description": "Metadata for attachments parsed out of the email. The content itself is only available in the raw MIME." + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "replyTo": { + "type": "string" + }, + "sentAt": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "text": { + "type": "string" + }, + "html": { + "type": "string" + }, + "raw": { + "type": "string", + "description": "Raw MIME content, present when sent via the EmailMessage API." + }, + "rawBase64": { + "type": "string", + "description": "Lossless base64 representation of sent MIME." + } + }, + "required": [ + "from", + "subject", + "messageId", + "attachments", + "to", + "sentAt" + ], + "additionalProperties": false } } } 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..35b391eb00e --- /dev/null +++ b/packages/miniflare/src/workers/local-explorer/resources/email.ts @@ -0,0 +1,1135 @@ +import { decodeWords } from "postal-mime"; +import { z } from "zod"; +import { EMAIL_STORE_SERVICE_NAME } from "../../../plugins/core/constants"; +import { CoreBindings, CorePaths } from "../../core"; +import { handleEmail } from "../../core/email"; +import { base64ToBytes, bytesToBase64 } from "../../email/capture"; +import { + zEmailHandlerResult, + zEmailRoutingDetail, + zEmailRoutingItem, + zEmailSendingDetail, + zEmailSendingItem, +} from "../../email/contracts"; +import { + hasControlCharacters, + isMimeType, + normalizeBase64, +} from "../../email/input-validation"; +import { + extractAddressFromString, + messageIdToStorageId, + synthesizeMessageId, +} from "../../email/message-id"; +import { buildMimeMessage } from "../../email/mime"; +import { + fetchFromPeer, + getPeerEntrypoint, + getPeerUrlsIfAggregating, +} from "../aggregation"; +import { errorResponse, wrapResponse } from "../common"; +import { zLocalExplorerListWorkersResponse } from "../generated/zod.gen"; +import type { + EmailRoutingItem, + EmailSendingItem, + EmailSendRequest, +} from "../../email/contracts"; +import type { + EmailListPage, + EmailStoreService, + StoredRoutingEmail, +} from "../../email/storage"; +import type { AppContext } from "../common"; +import type { zEmailListRoutingData } from "../generated/zod.gen"; + +const EMAIL_ERROR_NOT_FOUND = 10601; +const EMAIL_ERROR_SEND_FAILED = 10602; +const EMAIL_ERROR_PEER_UNAVAILABLE = 10603; +const EMAIL_WARNING_CAPTURE_TRUNCATED = 10604; + +function getEmailStore(c: AppContext): EmailStoreService { + return c.env[CoreBindings.SERVICE_EMAIL_STORE]; +} + +type EmailPeerSource = { id: string; url: string }; +type EmailSourceService = Fetcher & Pick; + +async function getEmailPeerSourcesIfAggregating( + c: AppContext +): Promise { + const peerUrls = await getPeerUrlsIfAggregating(c); + const discovered = await Promise.all( + peerUrls.map(async (url): Promise => { + try { + const store = getPeerEntrypoint( + url, + EMAIL_STORE_SERVICE_NAME + ) as EmailSourceService; + const sourceId = await store.getSourceId(); + return sourceId === "" ? undefined : { id: `peer:${sourceId}`, url }; + } catch { + return; + } + }) + ); + return [ + ...new Map( + discovered + .filter((source) => source !== undefined) + .map((source) => [source.id, source]) + ).values(), + ].sort((a, b) => a.id.localeCompare(b.id)); +} + +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 EmailCursorResource = "routing" | "sending"; +type EmailCursorEnvelope = { + resource: EmailCursorResource; + worker?: string; + sources: EmailCursorState; +}; +const EMAIL_CURSOR_START = ""; +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), + result_info: z.object({ + cursor: z.string().optional(), + has_more: z.boolean(), + }), + }) + .parse(value); +} + +function buildEmailListResponse( + c: AppContext, + query: EmailListQuery, + items: T[], + hasMore: boolean, + cursor?: string +): Response { + return c.json({ + ...wrapResponse(items), + result_info: { + count: items.length, + per_page: query.per_page, + has_more: hasMore, + ...(cursor === undefined ? {} : { cursor }), + }, + }); +} + +function encodeAggregateCursor( + resource: EmailCursorResource, + worker: string | undefined, + sources: EmailCursorState +): string { + const json = JSON.stringify({ resource, worker, sources }); + return `a.${bytesToBase64(new TextEncoder().encode(json))}`; +} + +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 isEmailCursorEnvelope(value: unknown): value is EmailCursorEnvelope { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "resource" in value && + (value.resource === "routing" || value.resource === "sending") && + (!("worker" in value) || typeof value.worker === "string") && + "sources" in value && + isEmailCursorState(value.sources) + ); +} + +function isInvalidEmailCursor(error: unknown): boolean { + return ( + error instanceof TypeError && + error.message === "Invalid email pagination cursor" + ); +} + +/** + * Decodes an aggregate pagination cursor into per-source state. + * + * State for sources not currently in `sources` (e.g. a peer that dropped out of + * the dev session mid-pagination) is preserved verbatim rather than discarded: + * we simply don't fetch from those sources, but carrying their position forward + * in the cursor means that if the peer rejoins within the same pagination + * sequence it resumes where it left off instead of replaying from its newest + * item, which would surface duplicates. + */ +function decodeAggregateCursor( + cursor: string | undefined, + resource: EmailCursorResource, + worker: string | undefined +): EmailCursorState { + if (cursor === undefined) { + return {}; + } + try { + if (!cursor.startsWith("a.")) { + throw new Error("Invalid cursor"); + } + const envelope = JSON.parse( + new TextDecoder().decode(base64ToBytes(cursor.slice(2))) + ) as unknown; + if ( + !isEmailCursorEnvelope(envelope) || + envelope.resource !== resource || + envelope.worker !== worker + ) { + throw new Error("Invalid cursor"); + } + return envelope.sources; + } catch { + throw new TypeError("Invalid email pagination cursor"); + } +} + +async function listLocalEmails( + query: EmailListQuery, + resource: EmailCursorResource, + list: (cursor: string | undefined, limit: number) => Promise> +): Promise> { + const state = decodeAggregateCursor(query.cursor, resource, query.worker); + const localCursor = + state.local === EMAIL_CURSOR_START ? undefined : state.local; + if (localCursor === null) { + return { items: [], hasMore: false }; + } + const { cursor, ...page } = await list(localCursor, query.per_page); + return { + ...page, + ...(cursor === undefined + ? {} + : { + cursor: encodeAggregateCursor(resource, query.worker, { + ...state, + local: cursor, + }), + }), + }; +} + +function getEmailTimestamp(email: EmailListItem): string { + return "receivedAt" in email ? email.receivedAt : email.sentAt; +} + +function compareEmailCandidates( + [sourceA, candidateA]: [string, EmailCandidate], + [sourceB, candidateB]: [string, EmailCandidate] +): number { + const timestampOrder = getEmailTimestamp(candidateB.item).localeCompare( + getEmailTimestamp(candidateA.item) + ); + return timestampOrder || sourceA.localeCompare(sourceB); +} + +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<{ + candidate?: EmailCandidate; +} | null> { + 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?.status === 400) { + throw new TypeError("Invalid email pagination cursor"); + } + if (!response?.ok) { + return null; + } + try { + const data = parsePeerEmailList(await response.json(), itemSchema); + const item = data.result[0]; + if (item === undefined) { + return {}; + } + const nextCursor = data.result_info.cursor; + const hasMore = data.result_info.has_more; + if (hasMore && (!nextCursor || nextCursor === cursor)) { + return null; + } + return { + candidate: { item, nextCursor, hasMore }, + }; + } catch { + return null; + } +} + +async function listAggregatedEmails(options: { + c: AppContext; + query: EmailListQuery; + basePath: string; + resource: EmailCursorResource; + peerSources: EmailPeerSource[]; + localList: (cursor?: string) => Promise>; + itemSchema: z.ZodType; +}): Promise<{ + items: T[]; + cursor?: string; + hasMore: boolean; +}> { + const state = decodeAggregateCursor( + options.query.cursor, + options.resource, + options.query.worker + ); + const peers = new Map( + (options.query.cursor === undefined + ? options.peerSources + : options.peerSources.filter(({ id }) => Object.hasOwn(state, id)) + ).map((peer) => [peer.id, peer]) + ); + const sourceIds = ["local", ...peers.keys()]; + for (const source of sourceIds) { + if (!Object.hasOwn(state, source)) { + state[source] = EMAIL_CURSOR_START; + } + } + const candidates = new Map>(); + const unavailableSources = new Set(); + + async function getCandidate(source: string) { + if (state[source] === null) { + return; + } + let candidate: EmailCandidate | null | undefined; + if (source === "local") { + candidate = await getNextLocalEmail( + options.localList, + state[source] === EMAIL_CURSOR_START ? undefined : state[source], + options.query.worker + ); + } else { + const peer = peers.get(source); + if (peer === undefined) { + return; + } + const result = await getNextPeerEmail( + peer.url, + options.basePath, + state[source] === EMAIL_CURSOR_START ? undefined : state[source], + options.query.worker, + options.itemSchema + ); + if (result === null) { + candidate = null; + } else { + candidate = result.candidate; + } + } + if (candidate === null) { + unavailableSources.add(source); + return; + } + if (candidate === undefined) { + state[source] = null; + return; + } + candidates.set(source, candidate); + } + + await Promise.all(sourceIds.map((source) => getCandidate(source))); + const items: T[] = []; + while (items.length < options.query.per_page && candidates.size > 0) { + const source = [...candidates.entries()].sort( + compareEmailCandidates + )[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); + } + } + + // Only the sources consulted this page can advance pagination. Absent + // sources (e.g. a peer that shut down mid-pagination) keep a preserved + // cursor in `state` but are never fetched here, so their cursor never gets + // cleared to null. Counting them towards `hasMore` would keep it true + // forever and hand the client an endless run of empty pages once the live + // sources are exhausted. Temporarily unavailable sources have the same + // treatment for this page, so `hasMore` only counts reachable `sourceIds`. + // + // While `hasMore` is true the full `state` is encoded, so a preserved + // absent-source cursor round-trips through the client and lets that peer + // resume where it left off if it rejoins during the same run. Once + // `hasMore` is false the run is over and the client discards the cursor, so + // there is nothing to preserve — dropping it is correct. + const hasMore = + candidates.size > 0 || + sourceIds.some((source) => { + if (unavailableSources.has(source)) { + return false; + } + const cursor = state[source]; + return cursor !== null && cursor !== undefined; + }); + return { + items, + hasMore, + ...(hasMore + ? { + cursor: encodeAggregateCursor( + options.resource, + options.query.worker, + state + ), + } + : {}), + }; +} + +/** + * Finds the peer instance that serves `worker` by asking each peer which workers + * it hosts. Tracks unavailable peers separately so callers can distinguish + * "worker does not exist" from "ownership could not be determined". + */ +async function findWorkerOwner( + c: AppContext, + peerUrls: string[], + worker: string +): Promise<{ owner: string | null; unavailable: boolean }> { + const responses = await Promise.all( + peerUrls.map(async (url) => { + const response = await fetchFromPeer(url, "/local/workers"); + if (!response?.ok) { + return { owner: null, unavailable: true }; + } + try { + const data = zLocalExplorerListWorkersResponse.parse( + await response.json() + ); + const owns = + data.result?.some((w) => w.isSelf === true && w.name === worker) ?? + false; + return { owner: owns ? url : null, unavailable: false }; + } catch { + return { owner: null, unavailable: true }; + } + }) + ); + return { + owner: responses.find(({ owner }) => owner !== null)?.owner ?? null, + unavailable: responses.some(({ unavailable }) => unavailable), + }; +} + +async function fetchWorkerScopedListFromOwner( + ownerUrl: string, + basePath: string, + query: EmailListQuery +): Promise { + if (query.worker === undefined) { + return null; + } + const params = new URLSearchParams({ + per_page: String(query.per_page), + worker: query.worker, + }); + if (query.cursor !== undefined) { + params.set("cursor", query.cursor); + } + return (await fetchFromPeer(ownerUrl, `${basePath}?${params}`)) ?? null; +} + +function peerUnavailableResponse(worker?: string): Response { + return errorResponse( + 502, + EMAIL_ERROR_PEER_UNAVAILABLE, + worker === undefined + ? "One or more workers are temporarily unavailable in this dev session." + : `Worker '${worker}' is temporarily unavailable in this dev session.` + ); +} + +/** + * Decodes MIME "encoded-word" sequences (e.g. `=?utf-8?B?...?=`) in a message's + * header block for display, leaving the body untouched. + * + * The header block ends at the first blank line (`\r\n\r\n`). When that + * separator is absent the input is not a well-formed message — most commonly a + * body that was truncated during capture (see `captureRawForBodyRow` in + * ../../email/capture), whose bytes may be arbitrary and must not be fed to + * `decodeWords`. In that case we decode only the leading run of lines that look + * like header fields (`Name:` or a folded continuation) and pass everything from + * the first non-header line through verbatim, so a truncated body is never + * mangled. + */ +function decodeEmailHeaders(raw: string): string { + const separator = /\r?\n\r?\n/u.exec(raw); + if (separator?.index !== undefined) { + return `${decodeWords(raw.slice(0, separator.index))}${raw.slice(separator.index)}`; + } + + // No header/body separator: decode only the leading header-shaped lines. + const lines = raw.split(/(\r?\n)/u); + let headerEnd = 0; + for (let index = 0; index < lines.length; index += 2) { + const line = lines[index]; + const isFieldStart = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+:/u.test(line); + const isFoldedContinuation = index > 0 && /^[ \t]/u.test(line); + if (line === "" || (!isFieldStart && !isFoldedContinuation)) { + break; + } + // Include this content line and its following newline separator. + headerEnd = index + 2; + } + const headerPart = lines.slice(0, headerEnd).join(""); + const rest = lines.slice(headerEnd).join(""); + return `${decodeWords(headerPart)}${rest}`; +} + +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(hasControlCharacters)) { + return "Email fields must not contain control characters."; + } + + if (Object.values(body.headers ?? {}).some(hasControlCharacters)) { + return "Custom headers must use valid names and values."; + } + try { + new Headers(body.headers); + } catch { + return "Custom headers must use valid names and values."; + } + + for (const attachment of body.attachments ?? []) { + if ( + hasControlCharacters(attachment.filename) || + (attachment.contentId !== undefined && + hasControlCharacters(attachment.contentId)) || + !isMimeType(attachment.type) || + normalizeBase64(attachment.content) === undefined + ) { + return "Attachments must have valid filenames, MIME types, and base64 content."; + } + } + + return undefined; +} + +type EmailListDescriptor = { + resource: EmailCursorResource; + basePath: string; + itemSchema: z.ZodType; + listStorePage: ( + store: EmailStoreService, + cursor: string | undefined, + limit: number, + worker?: string + ) => Promise>; +}; + +const receivedEmailListDescriptor: EmailListDescriptor = { + resource: "routing", + basePath: "/local/email/routing", + itemSchema: zEmailRoutingItem, + async listStorePage(store, cursor, limit, worker) { + using result = (await store.listReceived(cursor, limit, worker)) as Awaited< + ReturnType + > & + Disposable; + return structuredClone(result); + }, +}; + +const sentEmailListDescriptor: EmailListDescriptor = { + resource: "sending", + basePath: "/local/email/sending", + itemSchema: zEmailSendingItem, + async listStorePage(store, cursor, limit, worker) { + using result = (await store.listSent(cursor, limit, worker)) as Awaited< + ReturnType + > & + Disposable; + return structuredClone(result); + }, +}; + +function parseEmailListPage( + page: EmailListPage, + itemSchema: z.ZodType +): EmailListPage { + return { + ...page, + items: z.array(itemSchema).parse(page.items), + }; +} + +async function listEmails( + c: AppContext, + query: EmailListQuery, + descriptor: EmailListDescriptor +): Promise { + const store = getEmailStore(c); + try { + if (query.worker !== undefined) { + decodeAggregateCursor(query.cursor, descriptor.resource, query.worker); + if (isLocalWorker(c, query.worker)) { + const page = await listLocalEmails( + query, + descriptor.resource, + async (cursor, limit) => + parseEmailListPage( + await descriptor.listStorePage( + store, + cursor, + limit, + query.worker + ), + descriptor.itemSchema + ) + ); + return buildEmailListResponse( + c, + query, + page.items, + page.hasMore, + page.cursor + ); + } + const ownerLookup = await findWorkerOwner( + c, + await getPeerUrlsIfAggregating(c), + query.worker + ); + const owner = ownerLookup.owner; + if (owner !== null) { + const response = await fetchWorkerScopedListFromOwner( + owner, + descriptor.basePath, + query + ); + if (response !== null) { + return response; + } + return peerUnavailableResponse(query.worker); + } + if (ownerLookup.unavailable) { + return peerUnavailableResponse(query.worker); + } + return buildEmailListResponse(c, query, [], false); + } + + const peerSources = await getEmailPeerSourcesIfAggregating(c); + if (peerSources.length === 0) { + const page = await listLocalEmails( + query, + descriptor.resource, + async (cursor, limit) => + parseEmailListPage( + await descriptor.listStorePage(store, cursor, limit), + descriptor.itemSchema + ) + ); + return buildEmailListResponse( + c, + query, + page.items, + page.hasMore, + page.cursor + ); + } + const page = await listAggregatedEmails({ + c, + query, + basePath: descriptor.basePath, + resource: descriptor.resource, + peerSources, + itemSchema: descriptor.itemSchema, + localList: async (cursor) => + parseEmailListPage( + await descriptor.listStorePage(store, cursor, 1, query.worker), + descriptor.itemSchema + ), + }); + return buildEmailListResponse( + c, + query, + page.items, + page.hasMore, + page.cursor + ); + } catch (error) { + if (!isInvalidEmailCursor(error)) { + throw error; + } + return errorResponse(400, 10000, "Invalid email pagination cursor"); + } +} + +export async function listReceivedEmails( + c: AppContext, + query: EmailListQuery +): Promise { + return listEmails(c, query, receivedEmailListDescriptor); +} + +export async function getReceivedEmail( + c: AppContext, + emailId: string, + worker?: string +): Promise { + const store = getEmailStore(c); + using email = (await store.findReceived( + messageIdToStorageId(emailId), + worker + )) as (StoredRoutingEmail & Disposable) | undefined; + if (!email) { + // The email may have been captured by a worker in another Miniflare + // instance; look it up there before giving up. + return getReceivedEmailFromPeers(c, emailId, worker); + } + // When a worker is requested, only return the email if it belongs to it so + // selecting a worker never leaks another worker's messages. + if (worker !== undefined && email.worker !== worker) { + return getReceivedEmailFromPeers(c, emailId, worker); + } + // Decode MIME "encoded-word" headers (e.g. `=?utf-8?B?...?=`) in each reply's + // display text so the explorer shows readable subjects. + const { captureTruncated, replies: storedReplies, ...storedEmail } = email; + const replyCaptureTruncated = storedReplies.some( + (reply) => reply.captureTruncated + ); + const decoded = { + ...storedEmail, + replies: storedReplies.map( + ({ captureTruncated: _captureTruncated, ...reply }) => ({ + ...reply, + raw: decodeEmailHeaders(reply.raw), + }) + ), + }; + const messages = []; + if (captureTruncated) { + messages.push({ + code: EMAIL_WARNING_CAPTURE_TRUNCATED, + message: + "Displayed received email content was truncated during local capture. The complete message was still delivered to the Worker.", + }); + } + if (replyCaptureTruncated) { + messages.push({ + code: EMAIL_WARNING_CAPTURE_TRUNCATED, + message: + "Displayed reply content was truncated during local capture. The complete reply is available in the local filesystem; see the development log for its path.", + }); + } + return c.json({ + ...wrapResponse(zEmailRoutingDetail.parse(decoded)), + messages, + }); +} + +/** + * 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 ownerLookup = await findWorkerOwner( + c, + await getPeerUrlsIfAggregating(c), + worker + ); + const owner = ownerLookup.owner; + if (owner) { + const response = await fetchFromPeer(owner, `${basePath}${query}`); + if (response !== null) { + return response; + } + return peerUnavailableResponse(worker); + } + if (ownerLookup.unavailable) { + return peerUnavailableResponse(worker); + } + } + } 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; + } + const peerError = responses.find( + (response): response is Response => + response !== null && response.status !== 404 + ); + if (peerError !== undefined) { + return peerError; + } + if (responses.some((response) => response === null)) { + return peerUnavailableResponse(); + } + } + + 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) + ).owner; + 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 = extractAddressFromString(body.from); + const to = extractAddressFromString(body.to[0] ?? ""); + + if (!to) { + return errorResponse(400, 10000, "At least one recipient is required."); + } + + // Locally composed messages use the same Message-ID shape as production. + // buildMimeMessage() ignores any caller-supplied Message-ID header. + const messageId = synthesizeMessageId(from); + const id = messageIdToStorageId(messageId); + const mime = buildMimeMessage(body, messageId); + + 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." + ); + } + + // Depending on whether the missing RPC method throws synchronously or when + // awaited, `handleEmail` returns either plain text or a JSON result containing + // one `unhandled` event. Surface the same descriptive send failure for both. + const contentType = response.headers.get("Content-Type") ?? ""; + if (!contentType.includes("application/json")) { + // Drain the (plain-text) body so the underlying stream is consumed. + await response.text(); + return errorResponse( + 400, + EMAIL_ERROR_SEND_FAILED, + `Worker '${worker}' does not export an email() handler.` + ); + } + + const result = zEmailHandlerResult.parse(await response.json()); + if (result.events.length === 1 && result.events[0]?.type === "unhandled") { + return errorResponse( + 400, + EMAIL_ERROR_SEND_FAILED, + `Worker '${worker}' does not export an email() handler.` + ); + } + return c.json( + wrapResponse({ + messageId, + outcome: result.outcome, + ...(result.rejectReason !== undefined + ? { rejectReason: result.rejectReason } + : {}), + }) + ); +} + +export async function listSentEmails( + c: AppContext, + query: EmailListQuery +): Promise { + return listEmails(c, query, sentEmailListDescriptor); +} + +export async function getSentEmail( + c: AppContext, + emailId: string, + worker?: string +): Promise { + const store = getEmailStore(c); + using email = (await store.findSent(messageIdToStorageId(emailId), worker)) as + | (NonNullable>> & + Disposable) + | undefined; + 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); + } + const { captureTruncated, ...storedEmail } = email; + return c.json({ + ...wrapResponse(zEmailSendingDetail.parse(storedEmail)), + messages: captureTruncated + ? [ + { + code: EMAIL_WARNING_CAPTURE_TRUNCATED, + message: + "Displayed sent email content was truncated during local capture. The complete email is available in the local filesystem; see the development log for its path.", + }, + ] + : [], + }); +} + +/** + * 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 5e1e2be3b32..90e0e318005 100644 --- a/packages/miniflare/src/workers/local-explorer/route-names.ts +++ b/packages/miniflare/src/workers/local-explorer/route-names.ts @@ -34,6 +34,9 @@ const ROUTE_PATTERNS: [RegExp, string][] = [ [/^\/workflows$/, "workflows.list"], [/^\/local\/observability\/query$/, "observability.query"], [/^\/local\/observability\/clear$/, "observability.clear"], + [/^\/local\/email\/routing\/send$/, "email.routing.send"], + [/^\/local\/email\/routing$/, "email.routing.list"], + [/^\/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 708ad3886ae..efcd6816dff 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -30,6 +30,7 @@ import { import { afterEach, test, vi } from "vitest"; import { WebSocketServer } from "ws"; import { assertIsV2ModuleFallbackProtocol } from "../src/plugins/core/module-fallback"; +import { MAX_EMAIL_BODY_BYTES } from "../src/workers/email/capture"; import { FIXTURES_PATH, singleModuleManifest, @@ -2476,6 +2477,66 @@ 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 raw = `From: someone +To: someone else +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This is a random email body. +`; + const res = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?from=someone@example.com&to=someone-else@example.com", + { + body: raw, + method: "POST", + } + ); + const body = await res.text(); + expect(res.status).toBe(500); + expect(body).toBe( + "Worker does not export an email() handler; message stored without delivery." + ); + + const jsonRes = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?format=json&from=someone@example.com&to=someone-else@example.com", + { body: raw, method: "POST" } + ); + expect(jsonRes.status).toBe(500); + expect(await jsonRes.json()).toEqual({ + outcome: "exception", + forwards: [], + replies: [], + events: [{ type: "unhandled", timestamp: expect.any(String) }], + }); +}); + test("Miniflare: manually triggered email handler - reply handler works", async ({ expect, }) => { @@ -2549,7 +2610,9 @@ This is a random email body. test("Miniflare: manually triggered email handler - structured result", async ({ expect, }) => { + const log = new TestLog(); const mf = new Miniflare({ + log, unsafeTriggerHandlers: true, workers: [ { @@ -2572,15 +2635,29 @@ test("Miniflare: manually triggered email handler - structured result", async ({ "archive@example.com", new Headers({ "X-Test": mode }) ); + const replyPrefix = + \`From: reply-\${mode}@example.com\\r\\n\` + + \`To: \${message.from}\\r\\n\` + + \`In-Reply-To: <\${mode}@example.com>\\r\\n\` + + \`References: <\${mode}@example.com>\\r\\n\` + + \`Message-ID: \\r\\n\` + + "Content-Type: text/plain\\r\\n\\r\\n"; + const replyBody = mode === "large" + ? "x".repeat( + ${MAX_EMAIL_BODY_BYTES} - + new TextEncoder().encode(replyPrefix).byteLength - + 1 + ) + "€complete reply" + : \`Reply for \${mode}\\r\\n\`; await message.reply(new EmailMessage( \`reply-\${mode}@example.com\`, message.from, - \`From: reply-\${mode}@example.com\r\nTo: \${message.from}\r\nIn-Reply-To: <\${mode}@example.com>\r\nMessage-ID: \r\nContent-Type: text/plain\r\n\r\nReply for \${mode}\r\n\` + replyPrefix + replyBody )); if (mode === "exception") { message.setReject("triggered exception"); - throw new Error("sensitive handler error"); + throw new Error("email handler failed"); } } }`), @@ -2617,7 +2694,10 @@ test("Miniflare: manually triggered email handler - structured result", async ({ timestamp: string; messageId: string; } - | { type: "reject"; timestamp: string } + | { + type: "received" | "reject" | "unhandled"; + timestamp: string; + } )[]; }; } @@ -2641,6 +2721,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), @@ -2653,6 +2737,14 @@ test("Miniflare: manually triggered email handler - structured result", async ({ }, ]); + const largeResult = await dispatchEmail("large"); + const largeReply = largeResult.replies[0]?.raw ?? ""; + expect(new TextEncoder().encode(largeReply).byteLength).toBeGreaterThan( + MAX_EMAIL_BODY_BYTES + ); + expect(largeReply).toContain("€complete reply"); + expect(largeReply).not.toContain("\uFFFD"); + const rejectedResult = await dispatchEmail("rejected"); expect(rejectedResult).toMatchObject({ outcome: "ok", @@ -2661,6 +2753,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) }, ]); @@ -2683,6 +2776,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), @@ -2695,6 +2789,9 @@ test("Miniflare: manually triggered email handler - structured result", async ({ }, { type: "reject", timestamp: expect.any(String) }, ]); + expect(log.logsAtLevel(LogLevel.ERROR)).toContainEqual( + expect.stringContaining("Error: email handler failed") + ); }); test("Miniflare: unrecognised /cdn-cgi/local/ routes fall through to user worker", async ({ diff --git a/packages/miniflare/test/plugins/email/capture.spec.ts b/packages/miniflare/test/plugins/email/capture.spec.ts new file mode 100644 index 00000000000..459874a8897 --- /dev/null +++ b/packages/miniflare/test/plugins/email/capture.spec.ts @@ -0,0 +1,185 @@ +import { Buffer } from "node:buffer"; +import { test } from "vitest"; +import { + captureRawForBodyRow, + captureRawForJsonRow, + captureTextAndHtmlForJsonRow, + jsonByteLength, + MAX_EMAIL_BODY_BYTES, + MAX_EMAIL_ROW_VALUE_BYTES, + stripEmailHeader, +} from "../../../src/workers/email/capture"; + +test("fits raw Base64 into a body row", ({ expect }) => { + const raw = new TextEncoder().encode("x".repeat(2_000_000)); + const captured = captureRawForBodyRow(raw); + + expect(captured.truncated).toBe(true); + expect(captured.rawBase64.length).toBeLessThanOrEqual( + MAX_EMAIL_ROW_VALUE_BYTES + ); + expect(Buffer.from(captured.rawBase64, "base64").byteLength).toBe( + MAX_EMAIL_BODY_BYTES + ); +}); + +test("preserves the exact binary prefix when truncating raw MIME", ({ + expect, +}) => { + const raw = new Uint8Array(MAX_EMAIL_BODY_BYTES + 1); + raw.fill(0x80, MAX_EMAIL_BODY_BYTES - 100); + + const captured = captureRawForBodyRow(raw); + const decoded = Buffer.from(captured.rawBase64, "base64"); + + expect(decoded.byteLength).toBe(MAX_EMAIL_BODY_BYTES); + expect(decoded.subarray(-100)).toEqual(Buffer.alloc(100, 0x80)); +}); + +test("reserves JSON metadata before capturing raw Base64", ({ expect }) => { + const metadata = { + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Metadata first", + headers: { + "X-Large": '"\\'.repeat(250_000), + }, + }; + const raw = new TextEncoder().encode("x".repeat(2_000_000)); + const captured = captureRawForJsonRow(metadata, raw); + + expect(captured.email).toMatchObject(metadata); + expect(captured.truncated).toBe(true); + expect(jsonByteLength(captured.email)).toBeLessThanOrEqual( + MAX_EMAIL_ROW_VALUE_BYTES + ); + expect(captured.email.rawBase64).toBeDefined(); + expect( + Buffer.from(captured.email.rawBase64 ?? "", "base64").byteLength + ).toBeLessThan(MAX_EMAIL_BODY_BYTES); + + const emptyMetadata = { subject: "" }; + const exactMetadata = { + subject: "x".repeat( + MAX_EMAIL_ROW_VALUE_BYTES - jsonByteLength(emptyMetadata) + ), + }; + expect(jsonByteLength(exactMetadata)).toBe(MAX_EMAIL_ROW_VALUE_BYTES); + + const exactCaptured = captureRawForJsonRow( + exactMetadata, + new TextEncoder().encode("raw") + ); + expect(exactCaptured.email).toEqual(exactMetadata); + expect(exactCaptured.email).not.toHaveProperty("rawBase64"); + expect(exactCaptured.truncated).toBe(true); + + const empty = captureRawForJsonRow(exactMetadata, new Uint8Array()); + expect(empty.email).toEqual(exactMetadata); + expect(empty.email).not.toHaveProperty("rawBase64"); + expect(empty.truncated).toBe(false); +}); + +test("gives MessageBuilder text priority over html", ({ expect }) => { + const metadata = { + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Text first", + headers: { + "X-Escaped": '"\\'.repeat(100_000), + }, + }; + const text = "t".repeat(1_000_000); + const html = "h".repeat(2_000_000); + const captured = captureTextAndHtmlForJsonRow(metadata, text, html); + + expect(captured.email).toMatchObject(metadata); + expect(captured.email.text).toBe(text); + expect(captured.email.html?.length).toBeGreaterThan(0); + expect(captured.email.html?.length).toBeLessThan(html.length); + expect(captured.truncated).toBe(true); + expect(jsonByteLength(captured.email)).toBeLessThanOrEqual( + MAX_EMAIL_ROW_VALUE_BYTES + ); + + const emptyMetadata = { subject: "" }; + const exactMetadata = { + subject: "x".repeat( + MAX_EMAIL_ROW_VALUE_BYTES - jsonByteLength(emptyMetadata) + ), + }; + expect(jsonByteLength(exactMetadata)).toBe(MAX_EMAIL_ROW_VALUE_BYTES); + + const exactCaptured = captureTextAndHtmlForJsonRow( + exactMetadata, + "text", + "html" + ); + expect(exactCaptured.email).toEqual(exactMetadata); + expect(exactCaptured.email).not.toHaveProperty("text"); + expect(exactCaptured.email).not.toHaveProperty("html"); + expect(exactCaptured.truncated).toBe(true); + + const empty = captureTextAndHtmlForJsonRow(exactMetadata, "", ""); + expect(empty.email).toEqual(exactMetadata); + expect(empty.email).not.toHaveProperty("text"); + expect(empty.email).not.toHaveProperty("html"); + expect(empty.truncated).toBe(false); +}); + +test("omits html when text consumes the remaining row", ({ expect }) => { + const captured = captureTextAndHtmlForJsonRow( + { + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "No HTML budget", + }, + "t".repeat(2_000_000), + "h".repeat(2_000_000) + ); + + expect(captured.email.text?.length).toBeGreaterThan(0); + expect(captured.email).not.toHaveProperty("html"); + expect(captured.truncated).toBe(true); + expect(jsonByteLength(captured.email)).toBeLessThanOrEqual( + MAX_EMAIL_ROW_VALUE_BYTES + ); +}); + +test("rejects metadata that cannot fit in a row", ({ expect }) => { + expect(() => + captureTextAndHtmlForJsonRow( + { + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Metadata overflow", + headers: { "X-Large": "x".repeat(MAX_EMAIL_ROW_VALUE_BYTES) }, + }, + "text", + "html" + ) + ).toThrow("Email metadata exceeds the 2 MB storage row limit"); +}); + +test("strips Bcc headers without modifying the captured body", ({ expect }) => { + const raw = new TextEncoder().encode( + [ + "From: sender@example.com", + "Bcc: hidden@example.com", + "\tsecond-hidden@example.com", + "Subject: BCC privacy", + "", + "Body with \u0000 binary data.", + ].join("\r\n") + ); + + const stripped = new TextDecoder().decode(stripEmailHeader(raw, "bcc")); + expect(stripped).toBe( + [ + "From: sender@example.com", + "Subject: BCC privacy", + "", + "Body with \u0000 binary data.", + ].join("\r\n") + ); +}); diff --git a/packages/miniflare/test/plugins/email/index.spec.ts b/packages/miniflare/test/plugins/email/index.spec.ts index 890cb0af66d..4fb45958a62 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 { @@ -7,6 +7,7 @@ import { LogLevel, Miniflare, } from "miniflare"; +import PostalMime from "postal-mime"; import dedent from "ts-dedent"; import { describe, type ExpectStatic, test, vi } from "vitest"; import { @@ -55,6 +56,49 @@ async function useProjectTmpPath(): Promise { return path.join(await useTmp(), "project-tmp"); } +async function expectPersistedEmail( + log: TestLog, + expectedEmail: string, + originalMessageId: string, + generatedMessageIdDomain: string, + expect: ExpectStatic +): Promise { + await vi.waitFor( + async () => { + const entry = log.logs.find( + ([type, message]) => + type === LogLevel.INFO && + message.match( + /send_email binding called with the following message:\n/ + ) + ); + if (!entry) { + throw new Error( + "send_email binding log not found in " + + JSON.stringify(log.logs, null, 2) + ); + } + const fileMatch = entry[1].match(/^Email: (.+)$/m); + expect(fileMatch).not.toBeNull(); + const file = fileMatch?.[1]; + expect(file).toBeDefined(); + const fileContent = await readFile(String(file), "utf-8"); + const messageId = fileContent.match(/^Message-ID: (.+)$/m)?.[1]; + expect(messageId).toEqual( + synthesizedMessageId(expect, generatedMessageIdDomain) + ); + expect(messageId).not.toBe(originalMessageId); + expect( + fileContent.replace( + `Message-ID: ${messageId}`, + `Message-ID: ${originalMessageId}` + ) + ).toBe(expectedEmail); + }, + { timeout: 5_000, interval: 100 } + ); +} + test("Unbound send_email binding works", async ({ expect }) => { const log = new TestLog(); const projectTmpPath = await useProjectTmpPath(); @@ -101,29 +145,12 @@ test("Unbound send_email binding works", async ({ expect }) => { ); expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); - await vi.waitFor( - async () => { - const entry = log.logs.find( - ([type, message]) => - type === LogLevel.INFO && - message.match( - /send_email binding called with the following message:\n/ - ) - ); - if (!entry) { - throw new Error( - "send_email binding log not found in " + - JSON.stringify(log.logs, null, 2) - ); - } - const message = entry[1]; - const fileMatch = message.match(/^Email: (.+)$/m); - expect(fileMatch).not.toBeNull(); - const file = fileMatch?.[1]; - expect(file).toBeDefined(); - expect(await readFile(String(file), "utf-8")).toBe(email); - }, - { timeout: 5_000, interval: 100 } + await expectPersistedEmail( + log, + email, + "", + "example.com", + expect ); }); @@ -156,7 +183,7 @@ test("Invalid email throws", async ({ expect }) => { } ); - expect((await res.text()).startsWith("Error: invalid message-id")); + expect(await res.text()).toMatch(/^Error: invalid message-id/); expect(res.status).toBe(500); }); @@ -217,29 +244,12 @@ test("Single allowed destination send_email binding works", async ({ expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); - await vi.waitFor( - async () => { - const entry = log.logs.find( - ([type, message]) => - type === LogLevel.INFO && - message.match( - /send_email binding called with the following message:\n/ - ) - ); - if (!entry) { - throw new Error( - "send_email binding log not found in " + - JSON.stringify(log.logs, null, 2) - ); - } - const message = entry[1]; - const fileMatch = message.match(/^Email: (.+)$/m); - expect(fileMatch).not.toBeNull(); - const file = fileMatch?.[1]; - expect(file).toBeDefined(); - expect(await readFile(String(file), "utf-8")).toBe(email); - }, - { timeout: 5_000, interval: 100 } + await expectPersistedEmail( + log, + email, + "", + "example.com", + expect ); }); @@ -286,10 +296,8 @@ This is a random email body. } ); - expect( - (await res.text()).startsWith( - "Error: email to someone-else@example.com not allowed" - ) + expect(await res.text()).toMatch( + /^Error: email to someone-else@example\.com not allowed/ ); expect(res.status).toBe(500); }); @@ -440,10 +448,8 @@ This is a random email body. } ); - expect( - (await res.text()).startsWith( - "Error: email from notallowed@example.com not allowed" - ) + expect(await res.text()).toMatch( + /^Error: email from notallowed@example\.com not allowed/ ); expect(res.status).toBe(500); }); @@ -494,10 +500,8 @@ This is a random email body. } ); - expect( - (await res.text()).startsWith( - "Error: email to helly.r@example.com not allowed" - ) + expect(await res.text()).toMatch( + /^Error: email to helly\.r@example\.com not allowed/ ); expect(res.status).toBe(500); }); @@ -545,7 +549,7 @@ test("reply validation: x-auto-response-suppress", async ({ expect }) => { method: "POST", } ); - expect((await res.text()).includes("Original email is not replyable")); + expect(await res.text()).toContain("Original email is not replyable"); }); test("reply validation: Auto-Submitted", async ({ expect }) => { @@ -591,7 +595,7 @@ test("reply validation: Auto-Submitted", async ({ expect }) => { method: "POST", } ); - expect((await res.text()).includes("Original email is not replyable")); + expect(await res.text()).toContain("Original email is not replyable"); }); test("reply validation: only In-Reply-To", async ({ expect }) => { @@ -637,7 +641,7 @@ test("reply validation: only In-Reply-To", async ({ expect }) => { method: "POST", } ); - expect((await res.text()).includes("Original email is not replyable")); + expect(await res.text()).toContain("Original email is not replyable"); }); test("reply validation: only References", async ({ expect }) => { @@ -683,7 +687,7 @@ test("reply validation: only References", async ({ expect }) => { method: "POST", } ); - expect((await res.text()).includes("Original email is not replyable")); + expect(await res.text()).toContain("Original email is not replyable"); }); test("reply validation: >100 References", async ({ expect }) => { @@ -730,7 +734,7 @@ test("reply validation: >100 References", async ({ expect }) => { method: "POST", } ); - expect((await res.text()).includes("Original email is not replyable")); + expect(await res.text()).toContain("Original email is not replyable"); expect(log.logs[1][0]).toBe(LogLevel.ERROR); expect(log.logs[1][1].split("\n")[0]).toBe( 'The incoming email\'s "References" header has more than 100 entries. As such, your Worker cannot respond to this email. Refer to https://developers.cloudflare.com/email-routing/email-workers/reply-email-workers/' @@ -780,7 +784,7 @@ test("reply: mismatched From: header", async ({ expect }) => { } ); - expect((await res.text()).includes("From: header does not match mail from")); + expect(await res.text()).toContain("From: header does not match mail from"); }); test("reply: unparseable", async ({ expect }) => { @@ -826,10 +830,10 @@ test("reply: unparseable", async ({ expect }) => { } ); - expect((await res.text()).includes("could not parse email")); + expect(await res.text()).toContain("could not parse email"); }); -test("reply: no message id", async ({ expect }) => { +test("reply: generates a message id when omitted", async ({ expect }) => { const log = new TestLog(); const mf = new Miniflare({ log, @@ -850,6 +854,7 @@ test("reply: no message id", async ({ expect }) => { To: someone MIME-Version: 1.0 Content-Type: text/plain + In-Reply-To: This is a random email body.`) ) @@ -882,7 +887,254 @@ test("reply: no message id", async ({ expect }) => { } ); - expect((await res.text()).includes("invalid message-id")); + expect(await res.text()).toBe("Worker successfully processed email"); + expect(res.status).toBe(200); + + const replyLog = log + .logsAtLevel(LogLevel.INFO) + .find((message) => + message.startsWith( + "Email handler replied to sender with the following message:" + ) + ); + expect(replyLog).toBeDefined(); + const file = replyLog?.match(/^ {2}(.+)$/m)?.[1]; + expect(file).toBeDefined(); + const fileContent = await readFile(String(file), "utf-8"); + expect(fileContent).toMatch(/^Message-ID: <[A-Za-z0-9]{36}@example\.com>$/m); + expect(fileContent).toContain( + "References: " + ); +}); + +test("reply: rejects an empty message id", async ({ expect }) => { + const mf = new Miniflare({ + unsafeTriggerHandlers: true, + workers: [ + { + config: { + type: "worker", + name: "", + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest( + REPLY_EMAIL_WORKER( + JSON.stringify(dedent` + From: someone else + To: someone + Message-ID: + In-Reply-To: + MIME-Version: 1.0 + Content-Type: text/plain + + This is a random email body.`) + ) + ), + }, + }, + ], + }); + + useDispose(mf); + + const res = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?" + + new URLSearchParams({ + from: "someone@example.com", + to: "someone-else@example.com", + }).toString(), + { + body: dedent` + 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()).toContain("invalid message-id"); + expect(res.status).toBe(500); +}); + +test("reply: supports EmailReplyMessageBuilder", async ({ expect }) => { + const mf = new Miniflare({ + unsafeTriggerHandlers: true, + workers: [ + { + config: { + type: "worker", + name: "", + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest(dedent /* javascript */ ` + export default { + fetch() {}, + async email(message) { + await message.reply({ + from: { + name: 'Reply "Sender" \\\\ Team', + email: "reply@example.com", + }, + replyTo: { + name: "Support", + email: "support@example.com", + }, + subject: "Builder reply", + headers: { + "X-Builder": "yes", + "In-Reply-To": "", + "References": "", + "Subject": "Wrong subject", + }, + text: "Plain reply", + html: "

HTML reply

", + attachments: [{ + disposition: "inline", + contentId: "greeting", + filename: "greeting.png", + type: "image/png", + content: "aGVsbG8=", + }, { + disposition: "attachment", + filename: "bytes.bin", + type: "application/octet-stream", + content: new Uint8Array([0, 104, 105, 0]).subarray(1, 3), + }], + }); + }, + }; + `), + }, + }, + ], + }); + useDispose(mf); + + const res = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?format=json&from=sender@example.com&to=worker@example.com", + { + body: dedent` + From: Sender + To: Worker + Message-ID: + In-Reply-To: + References: + MIME-Version: 1.0 + Content-Type: text/plain + + Incoming body.`, + method: "POST", + } + ); + const result = (await res.json()) as { + replies: Array<{ messageId: string; sender: string; raw: string }>; + }; + + expect(res.status).toBe(200); + expect(result.replies).toHaveLength(1); + const reply = result.replies[0]; + expect(reply?.messageId).toMatch(/^<[A-Za-z0-9]{36}@example\.com>$/); + expect(reply?.sender).toBe( + '"Reply \\"Sender\\" \\\\ Team" ' + ); + + const parsed = await PostalMime.parse(reply?.raw ?? ""); + expect(parsed.from).toMatchObject({ + name: 'Reply "Sender" \\ Team', + address: "reply@example.com", + }); + expect(parsed.to).toEqual([ + expect.objectContaining({ address: "sender@example.com" }), + ]); + expect(parsed.replyTo).toEqual([ + expect.objectContaining({ + name: "Support", + address: "support@example.com", + }), + ]); + expect(parsed.subject).toBe("Builder reply"); + expect(parsed.inReplyTo).toBe(""); + expect(parsed.references).toBe(" "); + expect(parsed.headers).toContainEqual( + expect.objectContaining({ key: "x-builder", value: "yes" }) + ); + expect(parsed.text).toContain("Plain reply"); + expect(parsed.html).toContain("

HTML reply

"); + expect(parsed.attachments).toHaveLength(2); + expect(parsed.attachments[0]).toMatchObject({ + filename: "greeting.png", + mimeType: "image/png", + disposition: "inline", + contentId: "", + }); + const attachmentContent = parsed.attachments[0]?.content; + expect( + typeof attachmentContent === "string" + ? attachmentContent + : new TextDecoder().decode(attachmentContent) + ).toBe("hello"); + expect(parsed.attachments[1]).toMatchObject({ + filename: "bytes.bin", + mimeType: "application/octet-stream", + disposition: "attachment", + }); + const binaryAttachmentContent = parsed.attachments[1]?.content; + expect( + typeof binaryAttachmentContent === "string" + ? binaryAttachmentContent + : new TextDecoder().decode(binaryAttachmentContent) + ).toBe("hi"); +}); + +test("reply: rejects invalid custom header names", async ({ expect }) => { + const mf = new Miniflare({ + unsafeTriggerHandlers: true, + workers: [ + { + config: { + type: "worker", + name: "", + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest(dedent /* javascript */ ` + export default { + fetch() {}, + async email(message) { + await message.reply({ + from: "reply@example.com", + subject: "Invalid custom header", + headers: { + "Invalid Header": "value", + }, + text: "Reply body", + }); + }, + }; + `), + }, + }, + ], + }); + useDispose(mf); + + const response = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?from=sender@example.com&to=worker@example.com", + { + body: dedent` + From: sender@example.com + To: worker@example.com + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain + + Incoming body.`, + method: "POST", + } + ); + + expect(response.status).toBe(500); + expect(await response.text()).toContain("invalid headers set"); }); test("reply: disallowed header", async ({ expect }) => { @@ -940,7 +1192,7 @@ test("reply: disallowed header", async ({ expect }) => { } ); - expect((await res.text()).includes("invalid headers set")); + expect(await res.text()).toContain("invalid headers set"); }); test("reply: missing In-Reply-To", async ({ expect }) => { @@ -997,8 +1249,8 @@ test("reply: missing In-Reply-To", async ({ expect }) => { } ); - expect( - (await res.text()).includes("no In-Reply-To header found in reply message") + expect(await res.text()).toContain( + "no In-Reply-To header found in reply message" ); }); @@ -1057,10 +1309,8 @@ test("reply: wrong In-Reply-To", async ({ expect }) => { } ); - expect( - (await res.text()).includes( - "In-Reply-To does not match original Message-ID" - ) + expect(await res.text()).toContain( + "In-Reply-To does not match original Message-ID" ); }); @@ -1119,7 +1369,7 @@ test("reply: invalid references", async ({ expect }) => { method: "POST", } ); - expect((await res.text()).includes("provided References header is invalid")); + expect(await res.text()).toContain("provided References header is invalid"); }); test("reply: references generated correctly", async ({ expect }) => { @@ -1160,6 +1410,8 @@ test("reply: references generated correctly", async ({ expect }) => { From: someone To: someone else Message-ID: + In-Reply-To: + References: MIME-Version: 1.0 Content-Type: text/plain @@ -1190,11 +1442,13 @@ test("reply: references generated correctly", async ({ expect }) => { expect(file).toBeDefined(); const fileContent = await readFile(String(file), "utf-8"); expect(fileContent).toBeTruthy(); - expect( - fileContent.includes( - `References: ` - ) - ).toBe(true); + expect(fileContent).toMatch(/^Message-ID: <[A-Za-z0-9]{36}@example\.com>$/m); + expect(fileContent).not.toContain( + "Message-ID: " + ); + expect(fileContent).toContain( + "References: " + ); }); const MESSAGE_BUILDER_WORKER = dedent /* javascript */ ` @@ -1207,120 +1461,140 @@ const MESSAGE_BUILDER_WORKER = dedent /* javascript */ ` }; `; -test("MessageBuilder with text only", async ({ expect }) => { - const log = new TestLog(); - const projectTmpPath = await useProjectTmpPath(); - const mf = new Miniflare({ - log, - handleStructuredLogs({ message }: { message: string }) { - log.info(message); +const MESSAGE_BUILDER_RETURNS_RESULT_WORKER = 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); }, - resourceTmpPath: projectTmpPath, + }; +`; + +interface MessageBuilderMiniflareOptions { + log?: TestLog; + resourceTmpPath?: string; + workerScript?: string; + allowedDestinationAddresses?: string[]; + allowedSenderAddresses?: string[]; +} + +function useMessageBuilderMiniflare({ + log, + resourceTmpPath, + workerScript = MESSAGE_BUILDER_WORKER, + allowedDestinationAddresses, + allowedSenderAddresses, +}: MessageBuilderMiniflareOptions = {}): Miniflare { + const mf = new Miniflare({ + ...(log === undefined + ? {} + : { + log, + handleStructuredLogs({ message }: { message: string }) { + log.info(message); + }, + }), + resourceTmpPath, workers: [ { config: { type: "worker", name: "", compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, + manifest: singleModuleManifest(workerScript), + env: { + SEND_EMAIL: { + type: "send-email", + allowedDestinationAddresses, + allowedSenderAddresses, + }, + }, }, }, ], }); - useDispose(mf); + return mf; +} - const res = await mf.dispatchFetch("http://localhost", { - method: "POST", - body: JSON.stringify({ - from: "sender@example.com", - to: "recipient@example.com", - subject: "Test Email", - text: "Hello, this is a test email!", - }), - }); - - expect(await res.text()).toBe("ok"); - expect(res.status).toBe(200); - - await vi.waitFor( - async () => { - const entry = log.logs.find( +async function waitForMessageBuilderLog(log: TestLog): Promise { + return vi.waitFor( + () => { + const message = log.logs.find( ([type, message]) => type === LogLevel.INFO && message.includes("send_email binding called with MessageBuilder:") - ); - if (!entry) { + )?.[1]; + if (message === undefined) { throw new Error( "send_email binding log not found in " + JSON.stringify(log.logs, null, 2) ); } - const message = entry[1]; - - // Verify the formatted message contains expected fields - expect(message).toContain("From: sender@example.com"); - expect(message).toContain("To: recipient@example.com"); - expect(message).toContain("Subject: Test Email"); - expect(message).toContain("Text: "); - const textFile = message.match(/^Text: (.+)$/m)?.[1]; - expect(textFile).toBeDefined(); - expect(await readFile(String(textFile), "utf-8")).toBe( - "Hello, this is a test email!" - ); + return message; }, { timeout: 5_000, interval: 100 } ); -}); +} -test("MessageBuilder with HTML only", async ({ expect }) => { - const mf = new Miniflare({ - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], - }); +function getLoggedArtifactPath(message: string, prefix: string): string { + const line = message.split("\n").find((line) => line.startsWith(prefix)); + if (line === undefined) { + throw new Error(`Artifact log line starting with "${prefix}" not found`); + } + return line.slice(prefix.length); +} - useDispose(mf); +test("MessageBuilder with text only", async ({ expect }) => { + const log = new TestLog(); + const projectTmpPath = await useProjectTmpPath(); + const mf = useMessageBuilderMiniflare({ + log, + resourceTmpPath: projectTmpPath, + }); const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ from: "sender@example.com", to: "recipient@example.com", - subject: "HTML Test", - html: "

Hello World

", + subject: "Test Email", + text: "Hello, this is a test email!", }), }); expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); + + const message = await waitForMessageBuilderLog(log); + expect(message).toContain("From: sender@example.com"); + expect(message).toContain("To: recipient@example.com"); + expect(message).toContain("Subject: Test Email"); + expect( + await readFile(getLoggedArtifactPath(message, "Text: "), "utf-8") + ).toBe("Hello, this is a test email!"); }); -test("MessageBuilder with both text and HTML", async ({ expect }) => { - const mf = new Miniflare({ - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], +test("MessageBuilder with HTML only", async ({ expect }) => { + const mf = useMessageBuilderMiniflare(); + + const res = await mf.dispatchFetch("http://localhost", { + method: "POST", + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "HTML Test", + html: "

Hello World

", + }), }); - useDispose(mf); + expect(await res.text()).toBe("ok"); + expect(res.status).toBe(200); +}); + +test("MessageBuilder with both text and HTML", async ({ expect }) => { + const mf = useMessageBuilderMiniflare(); const res = await mf.dispatchFetch("http://localhost", { method: "POST", @@ -1340,27 +1614,11 @@ test("MessageBuilder with both text and HTML", async ({ expect }) => { test("MessageBuilder with attachments", async ({ expect }) => { const log = new TestLog(); const projectTmpPath = await useProjectTmpPath(); - const mf = new Miniflare({ + const mf = useMessageBuilderMiniflare({ log, - handleStructuredLogs({ message }: { message: string }) { - log.info(message); - }, resourceTmpPath: projectTmpPath, - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], }); - useDispose(mf); - const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -1382,56 +1640,22 @@ test("MessageBuilder with attachments", async ({ expect }) => { expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); - await vi.waitFor( - async () => { - const entry = log.logs.find( - ([type, message]) => - type === LogLevel.INFO && - message.includes("send_email binding called with MessageBuilder:") - ); - if (!entry) { - throw new Error("send_email binding log not found"); - } - const message = entry[1]; - - // Verify attachment file path is logged - expect(message).toContain("Attachment (attachment): test.txt ->"); - const attachmentFile = message.match( - /^Attachment \(attachment\): test\.txt -> (.+)$/m - )?.[1]; - expect(attachmentFile).toBeDefined(); - expect(await readFile(String(attachmentFile), "utf-8")).toBe( - "base64content" - ); - }, - { timeout: 5_000, interval: 100 } + const message = await waitForMessageBuilderLog(log); + const attachmentFile = getLoggedArtifactPath( + message, + "Attachment (attachment): test.txt -> " ); + expect(await readFile(attachmentFile, "utf-8")).toBe("base64content"); }); test("MessageBuilder log output format snapshot", async ({ expect }) => { const log = new TestLog(); const projectTmpPath = await useProjectTmpPath(); - const mf = new Miniflare({ + const mf = useMessageBuilderMiniflare({ log, - handleStructuredLogs({ message }: { message: string }) { - log.info(message); - }, resourceTmpPath: projectTmpPath, - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], }); - useDispose(mf); - const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -1451,7 +1675,6 @@ test("MessageBuilder log output format snapshot", async ({ expect }) => { content: "iVBORw0KGgo=", }, { - disposition: "attachment", filename: "report.pdf", type: "application/pdf", content: "JVBERi0xLjc=", @@ -1463,64 +1686,31 @@ test("MessageBuilder log output format snapshot", async ({ expect }) => { expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); - await vi.waitFor( - async () => { - const entry = log.logs.find( - ([type, message]) => - type === LogLevel.INFO && - message.includes("send_email binding called with MessageBuilder:") - ); - if (!entry) { - throw new Error("send_email binding log not found"); - } - const message = entry[1]; - - // Strip ANSI color codes and normalize file paths for snapshot - const cleanMessage = message - .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, - "/$1/[FILE].$2" - ); + const message = await waitForMessageBuilderLog(log); + const cleanMessage = message + .replace(/\x1b\[[0-9;]*m/g, "") + .replace( + /(?:[A-Z]:\\|\/)[^\s]*[/\\](email-text|email-html|email-attachment)[/\\][^/\\\s]+\.(txt|html|png|pdf)/g, + "/$1/[FILE].$2" + ); - // Snapshot the entire formatted output. Because a project temp path is - // configured, the binding logs the "project" location in preference to - // the "system" one. - expect(cleanMessage).toMatchInlineSnapshot(` - "send_email binding called with MessageBuilder: - From: "Alice Sender" - To: bob@example.com, charlie@example.com - Cc: team@example.com - Bcc: boss@example.com - Subject: Quarterly Report - - Text: /email-text/[FILE].txt - HTML: /email-html/[FILE].html - Attachment (inline): logo.png -> /email-attachment/[FILE].png - Attachment (attachment): report.pdf -> /email-attachment/[FILE].pdf" - `); - }, - { timeout: 5_000, interval: 100 } - ); + expect(cleanMessage).toMatchInlineSnapshot(` + "send_email binding called with MessageBuilder: + From: "Alice Sender" + To: bob@example.com, charlie@example.com + Cc: team@example.com + Bcc: boss@example.com + Subject: Quarterly Report + + Text: /email-text/[FILE].txt + HTML: /email-html/[FILE].html + Attachment (inline): logo.png -> /email-attachment/[FILE].png + Attachment (attachment): report.pdf -> /email-attachment/[FILE].pdf" + `); }); test("MessageBuilder with inline attachment", async ({ expect }) => { - const mf = new Miniflare({ - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], - }); - - useDispose(mf); + const mf = useMessageBuilderMiniflare(); const res = await mf.dispatchFetch("http://localhost", { method: "POST", @@ -1547,26 +1737,10 @@ test("MessageBuilder with inline attachment", async ({ expect }) => { test("MessageBuilder with EmailAddress objects", async ({ expect }) => { const log = new TestLog(); - const mf = new Miniflare({ + const mf = useMessageBuilderMiniflare({ log, - handleStructuredLogs({ message }: { message: string }) { - log.info(message); - }, - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], }); - useDispose(mf); - const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -1580,49 +1754,18 @@ test("MessageBuilder with EmailAddress objects", async ({ expect }) => { expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); - await vi.waitFor( - async () => { - const entry = log.logs.find( - ([type, message]) => - type === LogLevel.INFO && - message.includes("send_email binding called with MessageBuilder:") - ); - if (!entry) { - throw new Error("send_email binding log not found"); - } - const message = entry[1]; - - // Verify named addresses are formatted correctly - expect(message).toContain('"John Doe" '); - expect(message).toContain('"Jane Smith" '); - expect(message).toContain("Subject: Named Address Test"); - }, - { timeout: 5_000, interval: 100 } - ); + const message = await waitForMessageBuilderLog(log); + expect(message).toContain('"John Doe" '); + expect(message).toContain('"Jane Smith" '); + expect(message).toContain("Subject: Named Address Test"); }); test("MessageBuilder with named recipient arrays", async ({ expect }) => { const log = new TestLog(); - const mf = new Miniflare({ + const mf = useMessageBuilderMiniflare({ log, - handleStructuredLogs({ message }: { message: string }) { - log.info(message); - }, - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], }); - useDispose(mf); - const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -1644,54 +1787,23 @@ test("MessageBuilder with named recipient arrays", async ({ expect }) => { expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); - await vi.waitFor( - async () => { - const entry = log.logs.find( - ([type, message]) => - type === LogLevel.INFO && - message.includes("send_email binding called with MessageBuilder:") - ); - if (!entry) { - throw new Error("send_email binding log not found"); - } - const message = entry[1]; - - // Verify named recipient arrays are formatted correctly - expect(message).toContain( - 'To: "Jane Smith" , "Bob Wilson" ' - ); - expect(message).toContain('Cc: "CC One" '); - expect(message).toContain( - 'Bcc: "BCC One" , "BCC Two" ' - ); - expect(message).toContain("Subject: Named Recipient Arrays Test"); - }, - { timeout: 5_000, interval: 100 } + const message = await waitForMessageBuilderLog(log); + expect(message).toContain( + 'To: "Jane Smith" , "Bob Wilson" ' + ); + expect(message).toContain('Cc: "CC One" '); + expect(message).toContain( + 'Bcc: "BCC One" , "BCC Two" ' ); + expect(message).toContain("Subject: Named Recipient Arrays Test"); }); test("MessageBuilder with mixed recipients", async ({ expect }) => { const log = new TestLog(); - const mf = new Miniflare({ + const mf = useMessageBuilderMiniflare({ log, - handleStructuredLogs({ message }: { message: string }) { - log.info(message); - }, - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], }); - useDispose(mf); - const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -1713,54 +1825,23 @@ test("MessageBuilder with mixed recipients", async ({ expect }) => { expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); - await vi.waitFor( - async () => { - const entry = log.logs.find( - ([type, message]) => - type === LogLevel.INFO && - message.includes("send_email binding called with MessageBuilder:") - ); - if (!entry) { - throw new Error("send_email binding log not found"); - } - const message = entry[1]; - - // Verify mixed recipients are formatted correctly - expect(message).toContain( - 'To: plain@example.com, "Jane Doe" ' - ); - expect(message).toContain( - 'Cc: "CC Person" , plain-cc@example.com' - ); - expect(message).toContain("Bcc: plain-bcc@example.com"); - expect(message).toContain("Subject: Mixed Recipients Test"); - }, - { timeout: 5_000, interval: 100 } + const message = await waitForMessageBuilderLog(log); + expect(message).toContain( + 'To: plain@example.com, "Jane Doe" ' ); + expect(message).toContain( + 'Cc: "CC Person" , plain-cc@example.com' + ); + expect(message).toContain("Bcc: plain-bcc@example.com"); + expect(message).toContain("Subject: Mixed Recipients Test"); }); test("MessageBuilder with multiple recipients", async ({ expect }) => { const log = new TestLog(); - const mf = new Miniflare({ + const mf = useMessageBuilderMiniflare({ log, - handleStructuredLogs({ message }: { message: string }) { - log.info(message); - }, - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], }); - useDispose(mf); - const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -1776,45 +1857,16 @@ test("MessageBuilder with multiple recipients", async ({ expect }) => { expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); - await vi.waitFor( - async () => { - const entry = log.logs.find( - ([type, message]) => - type === LogLevel.INFO && - message.includes("send_email binding called with MessageBuilder:") - ); - if (!entry) { - throw new Error("send_email binding log not found"); - } - const message = entry[1]; - - // Verify multiple recipients are listed - expect(message).toContain( - "To: recipient1@example.com, recipient2@example.com" - ); - expect(message).toContain("Cc: cc@example.com"); - expect(message).toContain("Bcc: bcc1@example.com, bcc2@example.com"); - }, - { timeout: 5_000, interval: 100 } + const message = await waitForMessageBuilderLog(log); + expect(message).toContain( + "To: recipient1@example.com, recipient2@example.com" ); + expect(message).toContain("Cc: cc@example.com"); + expect(message).toContain("Bcc: bcc1@example.com, bcc2@example.com"); }); test("MessageBuilder with custom headers", async ({ expect }) => { - const mf = new Miniflare({ - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], - }); - - useDispose(mf); + const mf = useMessageBuilderMiniflare(); const res = await mf.dispatchFetch("http://localhost", { method: "POST", @@ -1836,27 +1888,10 @@ test("MessageBuilder with custom headers", async ({ expect }) => { test("MessageBuilder respects allowed_destination_addresses", async ({ expect, }) => { - const mf = new Miniflare({ - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { - SEND_EMAIL: { - type: "send-email", - allowedDestinationAddresses: ["allowed@example.com"], - }, - }, - }, - }, - ], + const mf = useMessageBuilderMiniflare({ + allowedDestinationAddresses: ["allowed@example.com"], }); - useDispose(mf); - const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -1873,27 +1908,10 @@ test("MessageBuilder respects allowed_destination_addresses", async ({ }); test("MessageBuilder respects allowed_sender_addresses", async ({ expect }) => { - const mf = new Miniflare({ - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { - SEND_EMAIL: { - type: "send-email", - allowedSenderAddresses: ["allowed@example.com"], - }, - }, - }, - }, - ], + const mf = useMessageBuilderMiniflare({ + allowedSenderAddresses: ["allowed@example.com"], }); - useDispose(mf); - const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -1911,28 +1929,11 @@ test("MessageBuilder respects allowed_sender_addresses", async ({ expect }) => { test("MessageBuilder allowed_destination_addresses with named recipients", async ({ expect, -}) => { - const mf = new Miniflare({ - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { - SEND_EMAIL: { - type: "send-email", - allowedDestinationAddresses: ["allowed@example.com"], - }, - }, - }, - }, - ], +}) => { + const mf = useMessageBuilderMiniflare({ + allowedDestinationAddresses: ["allowed@example.com"], }); - useDispose(mf); - // Named allowed recipient should succeed const resAllowed = await mf.dispatchFetch("http://localhost", { method: "POST", @@ -1963,27 +1964,10 @@ test("MessageBuilder allowed_destination_addresses with named recipients", async test("MessageBuilder allowed_sender_addresses with named from", async ({ expect, }) => { - const mf = new Miniflare({ - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { - SEND_EMAIL: { - type: "send-email", - allowedSenderAddresses: ["allowed@example.com"], - }, - }, - }, - }, - ], + const mf = useMessageBuilderMiniflare({ + allowedSenderAddresses: ["allowed@example.com"], }); - useDispose(mf); - // Named allowed sender should succeed const resAllowed = await mf.dispatchFetch("http://localhost", { method: "POST", @@ -2013,26 +1997,10 @@ test("MessageBuilder allowed_sender_addresses with named from", async ({ test("MessageBuilder with RFC5322 string addresses", async ({ expect }) => { const log = new TestLog(); - const mf = new Miniflare({ + const mf = useMessageBuilderMiniflare({ log, - handleStructuredLogs({ message }: { message: string }) { - log.info(message); - }, - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], }); - useDispose(mf); - const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -2048,55 +2016,23 @@ test("MessageBuilder with RFC5322 string addresses", async ({ expect }) => { expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); - await vi.waitFor( - async () => { - const entry = log.logs.find( - ([type, message]) => - type === LogLevel.INFO && - message.includes("send_email binding called with MessageBuilder:") - ); - if (!entry) { - throw new Error("send_email binding log not found"); - } - const message = entry[1]; - - // Verify RFC5322 strings are passed through to the log as-is - expect(message).toContain('From: "John Doe" '); - expect(message).toContain( - 'To: "Jane Smith" , plain@example.com' - ); - expect(message).toContain('Cc: "CC Person" '); - expect(message).toContain('Bcc: "BCC Person" '); - expect(message).toContain("Subject: RFC5322 Address Test"); - }, - { timeout: 5_000, interval: 100 } + const message = await waitForMessageBuilderLog(log); + expect(message).toContain('From: "John Doe" '); + expect(message).toContain( + 'To: "Jane Smith" , plain@example.com' ); + expect(message).toContain('Cc: "CC Person" '); + expect(message).toContain('Bcc: "BCC Person" '); + expect(message).toContain("Subject: RFC5322 Address Test"); }); test("MessageBuilder allowed_destination_addresses with RFC5322 string recipients", async ({ expect, }) => { - const mf = new Miniflare({ - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { - SEND_EMAIL: { - type: "send-email", - allowedDestinationAddresses: ["allowed@example.com"], - }, - }, - }, - }, - ], + const mf = useMessageBuilderMiniflare({ + allowedDestinationAddresses: ["allowed@example.com"], }); - useDispose(mf); - // RFC5322-formatted allowed recipient should succeed const resAllowed = await mf.dispatchFetch("http://localhost", { method: "POST", @@ -2186,8 +2122,7 @@ 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. +// Both branches return a synthesized id with the sender's domain. function synthesizedMessageId(expect: ExpectStatic, domain: string) { return expect.stringMatching( new RegExp(`^<[A-Za-z0-9]{36}@${domain.replace(/\./g, "\\.")}>$`) @@ -2237,10 +2172,81 @@ test("send() on an EmailMessage returns a synthesized messageId", async ({ }); }); -test("send() on a MessageBuilder returns a synthesized messageId", async ({ +test("send() on an EmailMessage larger than 1 MiB is captured without the local explorer", async ({ + expect, +}) => { + const log = new TestLog(); + const mf = new Miniflare({ + log, + handleStructuredLogs({ message }: { message: string }) { + log.info(message); + }, + 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"), + }); + await vi.waitFor(() => { + expect(log.logsAtLevel(LogLevel.INFO)).toEqual( + expect.arrayContaining([ + expect.stringContaining( + "send_email binding called with the following message:" + ), + ]) + ); + }); + expect(log.logsAtLevel(LogLevel.WARN)).not.toEqual( + expect.arrayContaining([expect.stringContaining("local storage row")]) + ); + expect(log.logsAtLevel(LogLevel.WARN)).not.toEqual( + expect.arrayContaining([expect.stringContaining("Failed to capture")]) + ); +}); + +test("receiving an email larger than 1 MiB is captured without the local explorer", async ({ expect, }) => { + const log = new TestLog(); const mf = new Miniflare({ + log, + handleStructuredLogs({ message }: { message: string }) { + log.info(message); + }, + unsafeTriggerHandlers: true, workers: [ { config: { @@ -2249,14 +2255,11 @@ test("send() on a MessageBuilder returns a synthesized messageId", async ({ 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); + async email(message) { + await message.forward("forwarded@example.com"); }, }; `), - env: { SEND_EMAIL: { type: "send-email" } }, }, }, ], @@ -2264,6 +2267,49 @@ test("send() on a MessageBuilder returns a synthesized messageId", async ({ 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/cdn-cgi/local/email?" + + new URLSearchParams({ + from: "someone@sender.domain", + to: "someone-else@example.com", + format: "json", + }).toString(), + { body: email, method: "POST" } + ); + + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ outcome: "ok" }); + expect(log.logsAtLevel(LogLevel.INFO)).toEqual( + expect.arrayContaining([ + expect.stringContaining("Email handler forwarded message"), + ]) + ); + expect(log.logsAtLevel(LogLevel.WARN)).not.toEqual( + expect.arrayContaining([expect.stringContaining("local storage row")]) + ); + expect(log.logsAtLevel(LogLevel.WARN)).not.toEqual( + expect.arrayContaining([expect.stringContaining("Failed to capture")]) + ); +}); + +test("send() on a MessageBuilder returns a synthesized messageId", async ({ + expect, +}) => { + const mf = useMessageBuilderMiniflare({ + workerScript: MESSAGE_BUILDER_RETURNS_RESULT_WORKER, + }); + const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -2280,6 +2326,38 @@ test("send() on a MessageBuilder returns a synthesized messageId", async ({ }); }); +test("send() on a MessageBuilder larger than 1 MiB is captured without the local explorer", async ({ + expect, +}) => { + const log = new TestLog(); + const mf = useMessageBuilderMiniflare({ + log, + workerScript: MESSAGE_BUILDER_RETURNS_RESULT_WORKER, + }); + + 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"), + }); + await waitForMessageBuilderLog(log); + expect(log.logsAtLevel(LogLevel.WARN)).not.toEqual( + expect.arrayContaining([expect.stringContaining("local storage row")]) + ); + expect(log.logsAtLevel(LogLevel.WARN)).not.toEqual( + expect.arrayContaining([expect.stringContaining("Failed to capture")]) + ); +}); + test("send_email binding is available from getBindings", async ({ expect }) => { const mf = new Miniflare({ workers: [ @@ -2331,132 +2409,63 @@ 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" } }, }, }, ], }); - - 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([]); - - await mf.dispose(); - - expect(readdirSpy).not.toHaveBeenCalled(); - expect(existsSync(concurrentSessionPath)).toBe(true); -}); - -describe("EMAIL_PLUGIN.getServices", () => { - test("creates disk services for system temp and project directories", 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 }, - tmpPath: tmp, - workerNames: ["default"], - workerIndex: 0, - } as unknown as Parameters[0]); - - if (!Array.isArray(result)) { - throw new Error("Expected getServices to return an array of services"); - } - 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" + 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 } ); - const projectDisk = diskServices.find( - (s) => s.name === "email:disk:project" - ); - if (!systemTempDisk || !projectDisk) { - throw new Error("Expected both disk services to be present"); - } + expect(await response.text()).toBe("ok"); - // 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)) + 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" ); - 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) { - throw new Error("Expected send_email worker service to be present"); - } - - const bindings = workerService.worker.bindings; + await mkdir(concurrentSessionPath); - // 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"); + await mf.dispose(); + disposed = true; - const emailDiskServicesBinding = bindings.find( - (b) => b.name === "email_disk_services" - ); - if (!emailDiskServicesBinding?.json) { - throw new Error("Expected email_disk_services binding with JSON value"); + expect(existsSync(path.join(emailParentPath, sessionName))).toBe(false); + expect(existsSync(concurrentSessionPath)).toBe(true); + } finally { + if (!disposed) { + await mf.dispose(); } + } +}); - 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); - }); - - test("creates only system disk service when resourceTmpPath is undefined", async ({ +describe("EMAIL_PLUGIN.getServices", () => { + test("creates a worker-scoped send_email service with capture bindings", async ({ expect, }) => { const tmp = await useTmp(); @@ -2477,62 +2486,22 @@ 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); + 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; - - 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") }); }); }); @@ -2561,26 +2530,10 @@ test("MessageBuilder writes files to system temp when resourceTmpPath is unset", expect, }) => { const log = new TestLog(); - const mf = new Miniflare({ + const mf = useMessageBuilderMiniflare({ log, - handleStructuredLogs({ message }: { message: string }) { - log.info(message); - }, - workers: [ - { - config: { - type: "worker", - name: "", - compatibilityDate: "2025-03-17", - manifest: singleModuleManifest(MESSAGE_BUILDER_WORKER), - env: { SEND_EMAIL: { type: "send-email" } }, - }, - }, - ], }); - useDispose(mf); - const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ @@ -2594,33 +2547,10 @@ test("MessageBuilder writes files to system temp when resourceTmpPath is unset", expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); - await vi.waitFor( - async () => { - const entry = log.logs.find( - ([type, message]) => - type === LogLevel.INFO && - message.includes("send_email binding called with MessageBuilder:") - ); - if (!entry) { - throw new Error( - "send_email binding log not found in " + - JSON.stringify(log.logs, null, 2) - ); - } - const message = entry[1]; - - // Should log text file path - const textMatch = message.match(/^Text: (.+)$/m); - expect(textMatch).not.toBeNull(); - - const textPath = String(textMatch?.[1]); - - // File exists in system temp - expect(existsSync(textPath)).toBe(true); - expect(await readFile(textPath, "utf-8")).toBe( - "This should appear in system temp only" - ); - }, - { timeout: 5_000, interval: 100 } + const message = await waitForMessageBuilderLog(log); + const textPath = getLoggedArtifactPath(message, "Text: "); + expect(existsSync(textPath)).toBe(true); + expect(await readFile(textPath, "utf-8")).toBe( + "This should appear in system temp only" ); }); 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..cfc4a5e07a3 --- /dev/null +++ b/packages/miniflare/test/plugins/local-explorer/email.spec.ts @@ -0,0 +1,2525 @@ +import { Buffer } from "node:buffer"; +import { mkdtempSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { removeDirSync } from "@cloudflare/workers-utils"; +import { getWorkerRegistry, LogLevel, Miniflare } from "miniflare"; +import dedent from "ts-dedent"; +import { + afterAll, + beforeAll, + describe, + test, + type ExpectStatic, + vi, +} from "vitest"; +import { z } from "zod"; +import { CoreBindings, CorePaths } from "../../../src/workers/core/constants"; +import { + jsonByteLength, + MAX_EMAIL_BODY_BYTES, + MAX_EMAIL_ROW_VALUE_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, + TestLog, + waitForWorkersInRegistry, +} from "../../test-shared"; +import { expectValidResponse } from "./helpers"; +import type { EmailStoreService } from "../../../src/workers/email/storage"; +import type { MiniflareOptions } from "miniflare"; + +const BASE_URL = `http://localhost${CorePaths.EXPLORER}/api`; +const WORKER_NAME = "email-worker"; +const UNICODE_WORKER_NAME = "email-a-\u{1f48c}"; +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; +} + +type TestEmailCursorState = Record; +type TestEmailCursorResource = "routing" | "sending"; + +function encodeTestAggregateCursor( + sources: TestEmailCursorState, + resource: TestEmailCursorResource = "routing", + worker?: string +): string { + return `a.${Buffer.from( + JSON.stringify({ resource, worker, sources }) + ).toString("base64")}`; +} + +function decodeTestAggregateCursor(cursor: string): TestEmailCursorState { + const envelope = JSON.parse( + Buffer.from(cursor.slice(2), "base64").toString() + ) as { sources: TestEmailCursorState }; + return envelope.sources; +} + +async function dispatchExplorerApi( + instance: Miniflare, + path: string, + init?: RequestInit +): Promise { + return instance.dispatchFetch(`${BASE_URL}${path}`, init); +} + +async function clearEmailStore(instance: Miniflare): Promise { + const store = (await instance._getProxyClient()).env[ + CoreBindings.SERVICE_EMAIL_STORE + ] as unknown as EmailStoreService; + await store.clear(); +} + +async function storeSentEmail( + instance: Miniflare, + email: { + worker: string; + messageId: string; + sentAt: string; + } +): Promise { + const store = (await instance._getProxyClient()).env[ + CoreBindings.SERVICE_EMAIL_STORE + ] as unknown as EmailStoreService; + await store.storeSent({ + ...email, + from: "sender@example.com", + to: ["recipient@example.com"], + subject: email.messageId, + attachments: [], + }); +} + +async function storeReceivedEmail( + instance: Miniflare, + email: { + worker: string; + messageId: string; + subject: string; + receivedAt?: string; + text?: string; + } +): Promise { + const store = (await instance._getProxyClient()).env[ + CoreBindings.SERVICE_EMAIL_STORE + ] as unknown as EmailStoreService; + const captureId = crypto.randomUUID(); + const raw = [ + "From: sender@example.com", + "To: recipient@example.com", + `Message-ID: ${email.messageId}`, + `Subject: ${email.subject}`, + "Content-Type: text/plain", + "", + email.text ?? email.subject, + ].join("\r\n"); + await store.storeReceivedBody( + captureId, + 0, + Buffer.from(raw).toString("base64") + ); + await store.storeReceivedMetadata(captureId, 1, { + worker: email.worker, + messageId: email.messageId, + from: "sender@example.com", + to: "recipient@example.com", + subject: email.subject, + headers: { + from: "sender@example.com", + to: "recipient@example.com", + "message-id": email.messageId, + subject: email.subject, + "content-type": "text/plain", + }, + attachments: [], + rawSize: Buffer.byteLength(raw), + receivedAt: email.receivedAt ?? new Date().toISOString(), + outcome: "ok", + forwards: [], + replies: [], + events: [{ type: "received", timestamp: new Date().toISOString() }], + }); +} + +async function expectExplorerApiResponse( + instance: Miniflare, + path: string, + schema: TSchema, + expect: ExpectStatic, + status?: number +): Promise> { + return expectValidResponse( + await dispatchExplorerApi(instance, path), + schema, + expect, + status + ); +} + +async function sendRoutingTestEmail( + instance: Miniflare, + worker: string, + email: { + messageId: string; + subject: string; + text: string; + from?: string; + to?: string[]; + }, + expect: ExpectStatic +): Promise { + const response = await dispatchExplorerApi( + instance, + `/local/email/routing/send?worker=${encodeURIComponent(worker)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: email.from ?? "sender@example.com", + to: email.to ?? ["recipient@example.com"], + subject: email.subject, + text: email.text, + headers: { "Message-ID": email.messageId }, + }), + } + ); + expect(response.status).toBe(200); + const result = (await response.json()) as { + result?: { messageId?: string }; + }; + const messageId = result.result?.messageId; + if (messageId === undefined) { + throw new Error("Expected send test email to return a Message-ID"); + } + return messageId; +} + +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") { + using 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") { + using message = await env.SEND_EMAIL.send(await request.json()); + return Response.json(message); + } + + return new Response("ok"); + }, + + async email(message) { + const mode = message.headers.get("x-test-mode"); + if (mode === "assert-large-body") { + const delivered = await new Response(message.raw).arrayBuffer(); + if (delivered.byteLength <= 1024 * 1024) { + throw new Error("Expected the full email body to be delivered"); + } + } else if (mode === "assert-raw-size") { + const delivered = await new Response(message.raw).arrayBuffer(); + const expected = Number(message.headers.get("x-expected-raw-size")); + if (delivered.byteLength !== expected) { + throw new Error( + "Expected " + expected + " bytes, received " + delivered.byteLength + ); + } + } else if (mode === "assert-no-bcc") { + const delivered = await new Response(message.raw).text(); + const bcc = message.headers.get("bcc"); + if (bcc != null) { + message.setReject("Recipient headers included Bcc: " + String(bcc)); + } else if (/^bcc:/imu.test(delivered)) { + message.setReject("Recipient raw MIME included Bcc"); + } + } else if (mode === "forward") { + using result = await message.forward("forwarded@example.com"); + } else if (mode === "reply") { + const incomingMessageId = message.headers.get("message-id"); + using result = 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: " + incomingMessageId + "\\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 incomingMessageId = message.headers.get("message-id"); + const replyPrefix = + "From: reply@example.com\\n" + + "To: sender@example.com\\n" + + "Subject: Large reply\\n" + + "In-Reply-To: " + incomingMessageId + "\\n" + + "References: " + incomingMessageId + "\\n" + + "Message-ID: \\n" + + "MIME-Version: 1.0\\n" + + "Content-Type: text/plain\\n\\n"; + const filler = "z".repeat( + ${MAX_EMAIL_BODY_BYTES} - + new TextEncoder().encode(replyPrefix).byteLength - + 1 + ); + using result = await message.reply( + new EmailMessage( + "reply@example.com", + message.from, + replyPrefix + filler + "€complete reply" + ) + ); + } else if (mode === "reply-many") { + const incomingMessageId = message.headers.get("message-id"); + const filler = "m".repeat(2 * 1024 * 1024); + for (let i = 0; i < 3; i++) { + using result = await message.reply( + new EmailMessage( + "reply@example.com", + message.from, + "From: reply@example.com\\n" + + "To: sender@example.com\\n" + + "Subject: Reply " + i + "\\n" + + "In-Reply-To: " + incomingMessageId + "\\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"); + } + }, + }; +`; + +function emailPeerOptions( + registryPath: string, + names: string | string[], + register: boolean +): MiniflareOptions { + const workerNames = Array.isArray(names) ? names : [names]; + return { + inspectorPort: 0, + unsafeLocalExplorer: true, + unsafeTriggerHandlers: true, + unsafeDevRegistryPath: registryPath, + workers: workerNames.map((name) => ({ + dev: { unsafeRegisterWorker: register }, + config: { + type: "worker", + name, + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest(EMAIL_WORKER), + env: { + SEND_EMAIL: { type: "send-email" }, + }, + }, + })), + }; +} + +const NO_EMAIL_HANDLER_WORKER_NAME = "no-email-handler-worker"; +const NO_EMAIL_HANDLER_WORKER = dedent /* javascript */ ` + export default { + async fetch(request, env) { + return new Response("ok"); + }, + }; +`; + +describe("Local Explorer email API", () => { + let mf: Miniflare; + const log = new TestLog(); + + beforeAll(async () => { + mf = new Miniflare({ + inspectorPort: 0, + log, + 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" }, + }, + }, + }, + { + config: { + type: "worker", + name: NO_EMAIL_HANDLER_WORKER_NAME, + compatibilityDate: "2025-03-17", + manifest: singleModuleManifest(NO_EMAIL_HANDLER_WORKER), + }, + }, + ], + }); + 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]{36}@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 + ); + const normalizedRaw = raw.replace( + "Message-ID: ", + `Message-ID: ${sentMessageId}` + ); + expect(detail.result).toMatchObject({ + worker: WORKER_NAME, + messageId: sentMessageId, + headers: expect.objectContaining({ "message-id": sentMessageId }), + raw: normalizedRaw, + rawBase64: Buffer.from(normalizedRaw).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]{36}@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 an EmailMessage at the production limit and captures a truncated copy", async ({ + expect, + }) => { + const warningCount = log.logsAtLevel(LogLevel.WARN).length; + const headers = + [ + "From: sender@example.com", + "To: recipient@example.com", + "Message-ID: ", + "Subject: Large raw message", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + ].join("\r\n") + "\r\n"; + const headerBytes = new TextEncoder().encode(headers).byteLength; + const bodyBytes = MAX_PRODUCTION_EMAIL_BYTES - headerBytes; + const raw = + headers + + "\u{1f4e7}".repeat(Math.floor(bodyBytes / 4)) + + "x".repeat(bodyBytes % 4); + expect(new TextEncoder().encode(raw).byteLength).toBe( + MAX_PRODUCTION_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]{36}@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 raw message"); + expect(detail.messages).toEqual([ + { + code: 10604, + message: + "Displayed sent email content was truncated during local capture. The complete email is available in the local filesystem; see the development log for its path.", + }, + ]); + const capturedBytes = Buffer.from( + String(detail.result?.rawBase64), + "base64" + ).byteLength; + expect(capturedBytes).toBeGreaterThan(1024 * 1024); + expect(capturedBytes).toBeLessThan( + new TextEncoder().encode(raw).byteLength + ); + expect(String(detail.result?.rawBase64).length).toBeLessThanOrEqual( + MAX_EMAIL_ROW_VALUE_BYTES + ); + expect(detail.result?.raw).not.toContain("\uFFFD"); + expect( + log.logsAtLevel(LogLevel.WARN).slice(warningCount) + ).not.toContainEqual(expect.stringContaining("local storage row")); + }); + + test("sends a production-limit MessageBuilder and captures a truncated copy", async ({ + expect, + }) => { + const warningCount = log.logsAtLevel(LogLevel.WARN).length; + const text = "y".repeat(MAX_PRODUCTION_EMAIL_BYTES); + 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]{36}@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(detail.messages).toEqual([ + { + code: 10604, + message: + "Displayed sent email content was truncated during local capture. The complete email is available in the local filesystem; see the development log for its path.", + }, + ]); + const capturedTextBytes = new TextEncoder().encode( + detail.result?.text ?? "" + ).byteLength; + expect(capturedTextBytes).toBeGreaterThan(1024 * 1024); + expect(capturedTextBytes).toBeLessThan( + new TextEncoder().encode(text).byteLength + ); + expect(jsonByteLength(detail.result)).toBeLessThanOrEqual( + MAX_EMAIL_ROW_VALUE_BYTES + ); + expect( + log.logsAtLevel(LogLevel.WARN).slice(warningCount) + ).not.toContainEqual(expect.stringContaining("local storage row")); + }); + + test("captures a MessageBuilder with large text, html, and headers", async ({ + expect, + }) => { + const largeHeader = "z".repeat(768 * 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 text and html", + text: "t".repeat(2 * 1024 * 1024), + html: "h".repeat(2 * 1024 * 1024), + headers: { + "Message-ID": "", + "X-Large-Header": largeHeader, + }, + }), + } + ); + + 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: "Large text and html", + headers: { + "Message-ID": "", + "X-Large-Header": largeHeader, + }, + }); + const textBytes = new TextEncoder().encode( + detail.result?.text ?? "" + ).byteLength; + const htmlBytes = new TextEncoder().encode( + detail.result?.html ?? "" + ).byteLength; + expect(textBytes).toBeGreaterThan(0); + expect(htmlBytes).toBe(0); + expect(jsonByteLength(detail.result)).toBeLessThanOrEqual( + MAX_EMAIL_ROW_VALUE_BYTES + ); + }); + + test("keeps sending when metadata cannot fit in the capture row", async ({ + expect, + }) => { + const warningCount = log.logsAtLevel(LogLevel.WARN).length; + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-builder", + { + method: "POST", + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Metadata overflow", + text: "Delivered despite capture failure.", + headers: { + "X-Large-Header": "z".repeat(MAX_EMAIL_ROW_VALUE_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)}` + ), + zWorkersApiResponseCommonFailure, + expect, + 404 + ); + expect(detail.result).toBeNull(); + + await vi.waitFor(() => { + expect(log.logsAtLevel(LogLevel.WARN).slice(warningCount)).toContain( + "Failed to capture sent email for the Local Explorer; the email was still sent." + ); + }); + }); + + test("keeps delivering when received metadata cannot fit in the capture row", async ({ + expect, + }) => { + const warningCount = log.logsAtLevel(LogLevel.WARN).length; + const messageId = ""; + const raw = + [ + "From: sender@example.com", + "To: recipient@example.com", + `Message-ID: ${messageId}`, + "Subject: Received metadata overflow", + `X-Large-Header: ${"z".repeat(MAX_EMAIL_ROW_VALUE_BYTES)}`, + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "Delivered despite capture failure.", + ].join("\r\n") + "\r\n"; + + 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 } + ); + + const responseBody = await response.text(); + expect(response.status, responseBody).toBe(200); + expect(JSON.parse(responseBody)).toMatchObject({ outcome: "ok" }); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent(messageId)}` + ), + zWorkersApiResponseCommonFailure, + expect, + 404 + ); + expect(detail.result).toBeNull(); + + await vi.waitFor(() => { + expect(log.logsAtLevel(LogLevel.WARN).slice(warningCount)).toContainEqual( + expect.stringContaining( + "Failed to capture received email for the Local Explorer; the email was still delivered. Cause:" + ) + ); + }); + }); + + test("captures a >1 MiB received email and reply as truncated copies", async ({ + expect, + }) => { + const warningCount = log.logsAtLevel(LogLevel.WARN).length; + const headers = + dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + X-Test-Mode: reply-large + MIME-Version: 1.0 + Content-Type: text/plain + ` + "\r\n\r\n"; + 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, + } + ); + + expect(response.status).toBe(200); + const handlerResult = (await response.json()) as { + outcome: string; + replies: Array<{ raw: string }>; + events: Array<{ type: string }>; + }; + expect(handlerResult).toMatchObject({ + outcome: "ok", + events: [{ type: "received" }, { type: "reply" }], + }); + const handlerReply = handlerResult.replies[0]?.raw ?? ""; + expect(new TextEncoder().encode(handlerReply).byteLength).toBeGreaterThan( + MAX_EMAIL_BODY_BYTES + ); + expect(handlerReply).toContain("€complete reply"); + expect(handlerReply).not.toContain("\uFFFD"); + expect( + log.logsAtLevel(LogLevel.WARN).slice(warningCount) + ).not.toContainEqual( + expect.stringContaining("Failed to capture received email") + ); + + 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).toMatch(/^<[A-Za-z0-9]{36}@example\.com>$/); + expect(reply?.messageId).not.toBe(""); + expect(reply?.raw).toContain(`Message-ID: ${reply?.messageId}`); + expect(detail.result?.events[1]).toMatchObject({ + type: "reply", + messageId: reply?.messageId, + }); + expect(detail.result?.raw).toContain("X-Test-Mode: reply-large"); + expect(reply?.raw).toContain("Subject: Large reply"); + expect(reply?.raw).not.toContain("\uFFFD"); + expect(detail.messages).toEqual([ + { + code: 10604, + message: + "Displayed received email content was truncated during local capture. The complete message was still delivered to the Worker.", + }, + { + code: 10604, + message: + "Displayed reply content was truncated during local capture. The complete reply is available in the local filesystem; see the development log for its path.", + }, + ]); + expect( + Buffer.from(String(detail.result?.rawBase64), "base64").byteLength + ).toBe(MAX_EMAIL_BODY_BYTES); + expect( + new TextEncoder().encode(reply?.raw ?? "").byteLength + ).toBeLessThanOrEqual(MAX_EMAIL_BODY_BYTES); + expect( + log.logsAtLevel(LogLevel.WARN).slice(warningCount) + ).not.toContainEqual(expect.stringContaining("local storage row")); + }); + + test("stores multiple reply bodies directly", 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: "Many replies", + text: "Many replies", + headers: { + "Message-ID": "", + "X-Test-Mode": "reply-many", + }, + }), + } + ); + expect(response.status).toBe(200); + const sendResult = (await response.json()) as { + result: { messageId: string }; + }; + const incomingMessageId = sendResult.result.messageId; + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent(incomingMessageId)}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result?.replies).toHaveLength(3); + expect(detail.result?.replies[0]).toMatchObject({ + messageId: expect.stringMatching(/^<[A-Za-z0-9]{36}@example\.com>$/), + raw: expect.stringContaining("Subject: Reply 0"), + }); + expect(detail.result?.replies[2]).toMatchObject({ + messageId: expect.stringMatching(/^<[A-Za-z0-9]{36}@example\.com>$/), + raw: expect.stringContaining("Subject: Reply 2"), + }); + }); + + test("delivers an email at the production limit and truncates only the captured copy", async ({ + expect, + }) => { + const headers = + dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + X-Test-Mode: assert-raw-size + X-Expected-Raw-Size: ${MAX_PRODUCTION_EMAIL_BYTES} + MIME-Version: 1.0 + Content-Type: text/plain + ` + "\r\n\r\n"; + const headerBytes = new TextEncoder().encode(headers).byteLength; + const raw = headers + "x".repeat(MAX_PRODUCTION_EMAIL_BYTES - headerBytes); + expect(new TextEncoder().encode(raw).byteLength).toBe( + MAX_PRODUCTION_EMAIL_BYTES + ); + + 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(MAX_PRODUCTION_EMAIL_BYTES); + expect( + Buffer.from(String(detail.result?.rawBase64), "base64").byteLength + ).toBe(MAX_EMAIL_BODY_BYTES); + expect(detail.messages).toEqual([ + { + code: 10604, + message: + "Displayed received email content was truncated during local capture. The complete message was still delivered to the Worker.", + }, + ]); + }); + + test("sends a >1 MiB test email and truncates only the captured copy", async ({ + expect, + }) => { + const suppliedMessageId = ""; + 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 test email", + text: "x".repeat(2 * 1024 * 1024), + headers: { + "Message-ID": suppliedMessageId, + "X-Test-Mode": "assert-large-body", + }, + }), + } + ); + + const responseBody = await response.text(); + expect(response.status, responseBody).toBe(200); + const result = JSON.parse(responseBody) as { + result: { messageId: string; outcome: string }; + }; + expect(result).toMatchObject({ result: { outcome: "ok" } }); + const messageId = result.result.messageId; + expect(messageId).toMatch(/^<[A-Za-z0-9]{36}@example\.com>$/); + expect(messageId).not.toBe(suppliedMessageId); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent(messageId)}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result?.messageId).toBe(messageId); + expect(detail.result?.raw).toContain(`Message-ID: ${messageId}`); + expect(detail.result?.raw).not.toContain(suppliedMessageId); + expect(detail.result?.raw?.match(/^Message-ID:/gim)).toHaveLength(1); + expect(detail.result?.rawSize).toBeGreaterThan(MAX_EMAIL_BODY_BYTES); + expect( + Buffer.from(String(detail.result?.rawBase64), "base64").byteLength + ).toBe(MAX_EMAIL_BODY_BYTES); + expect(detail.messages).toEqual([ + { + code: 10604, + message: + "Displayed received email content was truncated during local capture. The complete message was still delivered to the Worker.", + }, + ]); + }); + + 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 + ` + "\r\n\r\n"; + 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("omits BCC from routing items but retains it on sent items", async ({ + expect, + }) => { + const routingMessageId = ""; + const routingRaw = dedent` + From: sender@example.com + To: recipient@example.com + Cc: copy@example.com + Bcc: hidden@example.com + Message-ID: ${routingMessageId} + Subject: Routing BCC + X-Test-Mode: assert-no-bcc + MIME-Version: 1.0 + Content-Type: text/plain + + Recipients must not see BCC. + `; + const routingResponse = 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: routingRaw, + } + ); + const routingResponseBody = await routingResponse.text(); + expect(routingResponse.status, routingResponseBody).toBe(200); + const routingResult = JSON.parse(routingResponseBody); + expect(routingResult).toMatchObject({ outcome: "ok" }); + expect(routingResult).not.toHaveProperty("rejectReason"); + + const routingDetail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent(routingMessageId)}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(routingDetail.result).toMatchObject({ + cc: ["copy@example.com"], + }); + expect(routingDetail.result).not.toHaveProperty("bcc"); + expect( + Object.keys(routingDetail.result?.headers ?? {}).map((key) => + key.toLowerCase() + ) + ).not.toContain("bcc"); + expect(routingDetail.result?.raw).not.toMatch(/^bcc:/imu); + + const sentResponse = await mf.dispatchFetch( + "http://localhost/send-builder", + { + method: "POST", + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + bcc: "hidden@example.com", + subject: "Sent BCC", + text: "Sender may inspect BCC.", + }), + } + ); + expect(sentResponse.status).toBe(200); + const sentResult = (await sentResponse.json()) as { messageId: string }; + const sentDetail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/sending?email_id=${encodeURIComponent(sentResult.messageId)}` + ), + zEmailSendingDetailResponse, + expect + ); + expect(sentDetail.result?.bcc).toEqual(["hidden@example.com"]); + + const rawResponse = await mf.dispatchFetch( + "http://localhost/send-raw?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + }).toString(), + { + method: "POST", + body: dedent` + From: sender@example.com + To: recipient@example.com + Bcc: raw-hidden@example.com + Message-ID: + Subject: Sent raw BCC + MIME-Version: 1.0 + Content-Type: text/plain + + Sender may inspect raw BCC. + `, + } + ); + expect(rawResponse.status).toBe(200); + const rawResult = (await rawResponse.json()) as { messageId: string }; + const rawDetail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/sending?email_id=${encodeURIComponent(rawResult.messageId)}` + ), + zEmailSendingDetailResponse, + expect + ); + expect(rawDetail.result?.bcc).toEqual(["raw-hidden@example.com"]); + }); + + 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); + const sendResult = (await response.json()) as { + result: { + messageId: string; + outcome: string; + rejectReason?: string; + }; + }; + expect(sendResult.result).toMatchObject({ + outcome: "ok", + rejectReason: "Rejected by test worker", + }); + const messageId = sendResult.result.messageId; + + const rejectedDetail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent(messageId)}&worker=${WORKER_NAME}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(rejectedDetail.result).toMatchObject({ + rejectReason: "Rejected by test worker", + }); + expect(rejectedDetail.result?.events.map(({ type }) => type)).toEqual([ + "received", + "reject", + ]); + + 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(messageId)}&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); + const sendResult = (await response.json()) as { + result: { messageId: string; outcome: string }; + }; + expect(sendResult.result).toMatchObject({ outcome: "ok" }); + const messageId = sendResult.result.messageId; + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent(messageId)}` + ), + 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("rejects invalid custom header names", 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: "Invalid custom header", + text: "Body text", + headers: { + "Invalid Header": "value", + }, + }), + } + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + errors: [ + { + message: "Custom headers must use valid names and values.", + }, + ], + }); + }); + + test("accepts a zero-byte attachment in a test email", 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: "Empty attachment", + text: "Attached file is empty.", + attachments: [ + { + filename: "empty.txt", + type: "text/plain", + content: "", + }, + ], + }), + } + ); + + const responseBody = await response.text(); + expect(response.status, responseBody).toBe(200); + const result = JSON.parse(responseBody) as { + result: { messageId: string }; + }; + const messageId = result.result.messageId; + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent(messageId)}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result?.attachments).toEqual([ + { + filename: "empty.txt", + contentType: "text/plain", + disposition: "attachment", + size: 0, + }, + ]); + }); + + test("reports a descriptive error when the target worker has no email() handler", async ({ + expect, + }) => { + const failure = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing/send?worker=${NO_EMAIL_HANDLER_WORKER_NAME}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "No handler", + text: "No handler", + headers: { "Message-ID": "" }, + }), + } + ), + zWorkersApiResponseCommonFailure, + expect, + 400 + ); + expect(failure.errors).toEqual([ + expect.objectContaining({ + message: `Worker '${NO_EMAIL_HANDLER_WORKER_NAME}' does not export an email() handler.`, + }), + ]); + + const list = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?worker=${NO_EMAIL_HANDLER_WORKER_NAME}` + ), + zEmailListRoutingResponse, + expect + ); + const detail = getListResult(list.result).find( + (email) => email.subject === "No handler" + ); + expect(detail).toMatchObject({ + worker: NO_EMAIL_HANDLER_WORKER_NAME, + messageId: expect.stringMatching(/^<[A-Za-z0-9]{36}@example\.com>$/), + outcome: "exception", + }); + expect(detail?.events.map(({ type }) => type)).toEqual(["unhandled"]); + }); + + 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); + const sendResult = (await response.json()) as { + result: { messageId: string; outcome: string }; + }; + expect(sendResult.result).toMatchObject({ outcome: "ok" }); + const incomingMessageId = sendResult.result.messageId; + expect(incomingMessageId).toMatch(/^<[A-Za-z0-9]{36}@example\.com>$/); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?email_id=${encodeURIComponent(incomingMessageId)}` + ), + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result?.events.map(({ type }) => type)).toEqual([ + "received", + "reply", + ]); + const reply = detail.result?.replies[0]; + expect(reply?.messageId).toMatch(/^<[A-Za-z0-9]{36}@example\.com>$/); + expect(reply).toMatchObject({ + sender: "reply@example.com", + raw: expect.stringContaining(`References: ${incomingMessageId}`), + rawBase64: expect.any(String), + }); + expect(reply?.messageId).not.toBe(""); + expect(reply?.raw).toContain(`Message-ID: ${reply?.messageId}`); + expect(reply?.raw).not.toContain("Message-ID: "); + expect(detail.result?.events[1]).toMatchObject({ + type: "reply", + messageId: reply?.messageId, + }); + 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 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 ${index}`, + text: `Message ${index}`, + }), + } + ); + 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.subject.startsWith("Retention test ")); + expect(retained).toHaveLength(201); + expect(firstPage.result_info).toMatchObject({ + count: 100, + per_page: 100, + has_more: true, + }); + expect(secondPage.result_info).toMatchObject({ + count: 100, + per_page: 100, + has_more: true, + }); + expect(thirdPage.result_info).toMatchObject({ + per_page: 100, + has_more: false, + }); + expect( + getListResult(thirdPage.result).filter((email) => + email.subject.startsWith("Retention test ") + ) + ).toHaveLength(1); + expect(getListResult(firstPage.result)[0]?.subject).toBe( + "Retention test 200" + ); + expect(retained.at(-1)?.subject).toBe("Retention test 0"); + } + ); + + 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("paginates storage by timestamp and insertion sequence", async ({ + expect, + }) => { + await clearEmailStore(mf); + for (const [messageId, sentAt] of [ + ["", "2026-08-20T03:00:00.000Z"], + ["", "2026-08-20T01:00:00.000Z"], + ["", "2026-08-20T02:00:00.000Z"], + ["", "2026-08-20T00:00:00.000Z"], + ["", "2026-08-20T00:00:00.000Z"], + ] as const) { + await storeSentEmail(mf, { + worker: WORKER_NAME, + messageId, + sentAt, + }); + } + + const messageIds: string[] = []; + let cursor: string | undefined; + do { + const params = new URLSearchParams({ per_page: "1" }); + if (cursor !== undefined) { + params.set("cursor", cursor); + } + const page = await expectExplorerApiResponse( + mf, + `/local/email/sending?${params}`, + zEmailListSendingResponse, + expect + ); + messageIds.push( + ...getListResult(page.result).map(({ messageId }) => messageId) + ); + cursor = page.result_info?.cursor; + } while (cursor !== undefined); + + expect(messageIds).toEqual([ + "", + "", + "", + "", + "", + ]); + }); + + test("rejects fractional email page sizes", async ({ expect }) => { + for (const resource of ["routing", "sending"]) { + const response = await mf.dispatchFetch( + `${BASE_URL}/local/email/${resource}?per_page=1.5` + ); + const responseBody = await response.text(); + expect(response.status, responseBody).toBe(400); + } + }); + + test("does not expose pagination source identity", async ({ expect }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/local/email/source-id` + ); + await response.text(); + expect(response.status).toBe(404); + }); + + test("does not restart an exhausted local source", async ({ expect }) => { + for (const [resource, schema] of [ + ["routing", zEmailListRoutingResponse], + ["sending", zEmailListSendingResponse], + ] as const) { + const cursor = encodeTestAggregateCursor({ local: null }, resource); + const page = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/${resource}?cursor=${encodeURIComponent(cursor)}` + ), + schema, + expect + ); + expect(page.result).toEqual([]); + expect(page.result_info).toMatchObject({ + count: 0, + has_more: false, + }); + expect(page.result_info).not.toHaveProperty("cursor"); + } + }); + + test("accepts cursors scoped to Unicode worker names", async ({ expect }) => { + const cursor = encodeTestAggregateCursor( + { local: null }, + "routing", + UNICODE_WORKER_NAME + ); + const params = new URLSearchParams({ + worker: UNICODE_WORKER_NAME, + cursor, + }); + const page = await expectValidResponse( + await mf.dispatchFetch(`${BASE_URL}/local/email/routing?${params}`), + zEmailListRoutingResponse, + expect + ); + expect(page.result).toEqual([]); + expect(page.result_info?.has_more).toBe(false); + }); + + test("preserves aggregate cursor sources that are no longer available", async ({ + expect, + }) => { + for (let index = 0; index < 2; index++) { + 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: "Stale cursor", + text: "Stale cursor", + headers: { + "Message-ID": ``, + }, + }), + } + ); + expect(response.status).toBe(200); + await response.json(); + } + const cursor = encodeTestAggregateCursor({ "stale-peer": "cursor" }); + const page = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/local/email/routing?per_page=1&cursor=${encodeURIComponent(cursor)}` + ), + zEmailListRoutingResponse, + expect + ); + expect(page.result_info?.cursor).toEqual(expect.any(String)); + expect( + decodeTestAggregateCursor(String(page.result_info?.cursor)) + ).toMatchObject({ + "stale-peer": "cursor", + local: expect.any(String), + }); + }); +}); + +describe("Local Explorer email aggregation", () => { + let registryPath: string; + let instanceA: Miniflare; + let instanceB: Miniflare; + let instanceC: Miniflare; + + beforeAll(async () => { + registryPath = mkdtempSync(path.join(tmpdir(), "mf-email-registry-")); + instanceA = new Miniflare(emailPeerOptions(registryPath, "email-a", true)); + instanceB = new Miniflare( + emailPeerOptions(registryPath, ["email-b", "email-b-secondary"], true) + ); + instanceC = new Miniflare(emailPeerOptions(registryPath, "email-c", true)); + await Promise.all([instanceA.ready, instanceB.ready, instanceC.ready]); + await waitForWorkersInRegistry(registryPath, [ + "email-a", + "email-b", + "email-b-secondary", + "email-c", + ]); + }); + + afterAll(async () => { + await Promise.all([ + disposeWithRetry(instanceA), + disposeWithRetry(instanceB), + disposeWithRetry(instanceC), + ]); + removeDirSync(registryPath); + }); + + test("aggregates peer records and proxies peer details", async ({ + expect, + }) => { + const messageId = await sendRoutingTestEmail( + instanceA, + "email-b", + { + messageId: "", + subject: "Peer email", + text: "Stored by the peer instance", + }, + expect + ); + + const list = await expectExplorerApiResponse( + instanceA, + "/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 expectExplorerApiResponse( + instanceA, + "/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 expectExplorerApiResponse( + instanceA, + `/local/email/routing?email_id=${encodeURIComponent(messageId)}`, + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result).toMatchObject({ + worker: "email-b", + messageId, + }); + + const wrongWorker = await expectExplorerApiResponse( + instanceA, + `/local/email/routing?email_id=${encodeURIComponent(messageId)}&worker=email-a`, + zWorkersApiResponseCommonFailure, + expect, + 404 + ); + expect(wrongWorker.result).toBeNull(); + }); + + test("routes worker-scoped requests to the owning peer", async ({ + expect, + }) => { + const messageId = await sendRoutingTestEmail( + instanceA, + "email-c", + { + messageId: "", + subject: "Multi-peer email", + text: "Stored by the owning peer instance", + }, + expect + ); + + const detail = await expectExplorerApiResponse( + instanceA, + `/local/email/routing?email_id=${encodeURIComponent(messageId)}&worker=email-c`, + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result).toMatchObject({ + worker: "email-c", + messageId, + }); + }); + + test("rejects malformed cursors belonging to a peer source", async ({ + expect, + }) => { + await Promise.all([instanceA, instanceB, instanceC].map(clearEmailStore)); + await storeSentEmail(instanceB, { + worker: "email-b", + messageId: "", + sentAt: "2026-08-20T02:00:00.000Z", + }); + await storeSentEmail(instanceB, { + worker: "email-b", + messageId: "", + sentAt: "2026-08-20T01:00:00.000Z", + }); + + const firstPage = await expectExplorerApiResponse( + instanceC, + "/local/email/sending?per_page=1", + zEmailListSendingResponse, + expect + ); + const cursor = String(firstPage.result_info?.cursor); + const state = decodeTestAggregateCursor(cursor); + const peerSource = Object.entries(state).find( + ([source, sourceCursor]) => + source.startsWith("peer:") && typeof sourceCursor === "string" + )?.[0]; + if (peerSource === undefined) { + throw new Error("Expected an advancing peer cursor"); + } + + const malformed = encodeTestAggregateCursor( + { ...state, [peerSource]: "invalid-peer-cursor" }, + "sending" + ); + const response = await dispatchExplorerApi( + instanceC, + `/local/email/sending?per_page=1&cursor=${encodeURIComponent(malformed)}` + ); + const body = await response.json(); + expect(response.status).toBe(400); + expect(body).toMatchObject({ + errors: [{ message: "Invalid email pagination cursor" }], + }); + }); + + test("preserves errors returned by a known worker owner", async ({ + expect, + }) => { + await Promise.all([instanceA, instanceB, instanceC].map(clearEmailStore)); + const store = (await instanceB._getProxyClient()).env[ + CoreBindings.SERVICE_EMAIL_STORE + ] as unknown as { storeSent(email: unknown): Promise }; + await store.storeSent({ + worker: "email-b", + messageId: "", + sentAt: "2026-08-20T00:00:00.000Z", + }); + + const listResponse = await dispatchExplorerApi( + instanceA, + "/local/email/sending?worker=email-b" + ); + await listResponse.text(); + expect(listResponse.status).toBe(500); + + const detailResponse = await dispatchExplorerApi( + instanceA, + `/local/email/sending?worker=email-b&email_id=${encodeURIComponent("")}` + ); + await detailResponse.text(); + expect(detailResponse.status).toBe(500); + + const unfilteredDetailResponse = await dispatchExplorerApi( + instanceA, + `/local/email/sending?email_id=${encodeURIComponent("")}` + ); + await unfilteredDetailResponse.text(); + expect(unfilteredDetailResponse.status).toBe(500); + }); + + test("reports unavailable peers for email lookups", async ({ expect }) => { + const unavailableWorker = "email-unavailable"; + const definitionPath = path.join(registryPath, unavailableWorker); + writeFileSync( + definitionPath, + JSON.stringify({ + debugPortAddress: "127.0.0.1:1", + defaultEntrypointService: unavailableWorker, + userWorkerService: unavailableWorker, + }) + ); + try { + const response = await expectExplorerApiResponse( + instanceA, + `/local/email/routing?email_id=${encodeURIComponent("")}`, + zWorkersApiResponseCommonFailure, + expect, + 502 + ); + expect(response.errors).toEqual([ + expect.objectContaining({ + code: 10603, + message: + "One or more workers are temporarily unavailable in this dev session.", + }), + ]); + + const filteredList = await expectExplorerApiResponse( + instanceA, + `/local/email/routing?worker=${unavailableWorker}`, + zWorkersApiResponseCommonFailure, + expect, + 502 + ); + expect(filteredList.errors).toEqual([ + expect.objectContaining({ + code: 10603, + message: `Worker '${unavailableWorker}' is temporarily unavailable in this dev session.`, + }), + ]); + + const filteredDetail = await expectExplorerApiResponse( + instanceA, + `/local/email/routing?worker=${unavailableWorker}&email_id=${encodeURIComponent("")}`, + zWorkersApiResponseCommonFailure, + expect, + 502 + ); + expect(filteredDetail.errors).toEqual([ + expect.objectContaining({ + code: 10603, + message: `Worker '${unavailableWorker}' is temporarily unavailable in this dev session.`, + }), + ]); + } finally { + unlinkSync(definitionPath); + } + }); + + test("paginates and opens worker-scoped details across multi-worker peers", async ({ + expect, + }) => { + await Promise.all([instanceA, instanceB, instanceC].map(clearEmailStore)); + + async function sendRaw( + instance: Miniflare, + worker: string, + label: string + ): Promise<{ messageId: string; raw: string; worker: string }> { + const raw = [ + `From: ${label}@example.com`, + "To: recipient@example.com", + `Message-ID: <${label}@example.com>`, + `Subject: ${label}`, + "Content-Type: text/plain", + "", + `Body ${label}`, + ].join("\r\n"); + const fetcher = await instance.getWorker(worker); + const response = await fetcher.fetch( + "http://localhost/send-raw?" + + new URLSearchParams({ + from: `${label}@example.com`, + to: "recipient@example.com", + }), + { method: "POST", body: raw } + ); + expect(response.status).toBe(200); + const result = (await response.json()) as { messageId: string }; + return { + messageId: result.messageId, + raw: raw.replace( + `Message-ID: <${label}@example.com>`, + `Message-ID: ${result.messageId}` + ), + worker, + }; + } + + const sent = []; + sent.push(await sendRaw(instanceA, "email-a", "multi-sent-a")); + sent.push( + await sendRaw(instanceB, "email-b-secondary", "multi-sent-b-secondary") + ); + sent.push(await sendRaw(instanceB, "email-b", "multi-sent-b-1")); + sent.push(await sendRaw(instanceB, "email-b", "multi-sent-b-2")); + + const listedMessageIds: string[] = []; + let cursor: string | undefined; + do { + const params = new URLSearchParams({ per_page: "1" }); + if (cursor !== undefined) { + params.set("cursor", cursor); + } + const page = await expectExplorerApiResponse( + instanceC, + `/local/email/sending?${params}`, + zEmailListSendingResponse, + expect + ); + listedMessageIds.push( + ...getListResult(page.result).map(({ messageId }) => messageId) + ); + cursor = page.result_info?.cursor; + if (page.result_info?.has_more) { + expect(cursor).toEqual(expect.any(String)); + } + } while (cursor !== undefined); + + expect(listedMessageIds).toHaveLength(sent.length); + expect(new Set(listedMessageIds)).toEqual( + new Set(sent.map(({ messageId }) => messageId)) + ); + + for (const email of sent) { + const detail = await expectValidResponse( + await instanceC.dispatchFetch( + `${BASE_URL}/local/email/sending?email_id=${encodeURIComponent(email.messageId)}&worker=${email.worker}` + ), + zEmailSendingDetailResponse, + expect + ); + expect(detail.result).toMatchObject({ + worker: email.worker, + messageId: email.messageId, + raw: email.raw, + rawBase64: Buffer.from(email.raw).toString("base64"), + }); + } + const primarySentEmail = sent.find(({ worker }) => worker === "email-b"); + if (primarySentEmail === undefined) { + throw new Error("Expected a sent email from email-b"); + } + const wrongSentWorker = await expectExplorerApiResponse( + instanceC, + `/local/email/sending?email_id=${encodeURIComponent(primarySentEmail.messageId)}&worker=email-b-secondary`, + zWorkersApiResponseCommonFailure, + expect, + 404 + ); + expect(wrongSentWorker.result).toBeNull(); + + const filteredPage = await expectExplorerApiResponse( + instanceC, + "/local/email/sending?worker=email-b&per_page=1", + zEmailListSendingResponse, + expect + ); + expect(getListResult(filteredPage.result)).toEqual([ + expect.objectContaining({ worker: "email-b" }), + ]); + const filteredCursor = filteredPage.result_info?.cursor; + expect(filteredCursor).toEqual(expect.any(String)); + const filteredState = decodeTestAggregateCursor(String(filteredCursor)); + expect(Object.keys(filteredState)).toEqual(["local"]); + const filteredLastPage = await expectExplorerApiResponse( + instanceC, + `/local/email/sending?worker=email-b&per_page=1&cursor=${encodeURIComponent(String(filteredCursor))}`, + zEmailListSendingResponse, + expect + ); + expect(getListResult(filteredLastPage.result)).toEqual([ + expect.objectContaining({ worker: "email-b" }), + ]); + const filteredIds = [ + ...getListResult(filteredPage.result).map(({ messageId }) => messageId), + ...getListResult(filteredLastPage.result).map( + ({ messageId }) => messageId + ), + ]; + expect(new Set(filteredIds)).toEqual( + new Set( + sent + .filter(({ worker }) => worker === "email-b") + .map(({ messageId }) => messageId) + ) + ); + expect(filteredLastPage.result_info).toMatchObject({ + count: 1, + has_more: false, + }); + expect(filteredLastPage.result_info).not.toHaveProperty("cursor"); + for (const path of [ + `/local/email/sending?worker=email-b-secondary&cursor=${encodeURIComponent(String(filteredCursor))}`, + `/local/email/routing?worker=email-b&cursor=${encodeURIComponent(String(filteredCursor))}`, + ]) { + const response = await dispatchExplorerApi(instanceC, path); + await response.text(); + expect(response.status).toBe(400); + } + + const duplicateMessageId = ""; + for (const [worker, subject] of [ + ["email-b", "Primary worker detail"], + ["email-b-secondary", "Secondary worker detail"], + ] as const) { + await storeReceivedEmail(instanceC, { + worker, + messageId: duplicateMessageId, + subject, + text: `Body for ${worker}`, + }); + } + + for (const [worker, subject] of [ + ["email-b", "Primary worker detail"], + ["email-b-secondary", "Secondary worker detail"], + ] as const) { + const detail = await expectExplorerApiResponse( + instanceC, + `/local/email/routing?email_id=${encodeURIComponent(duplicateMessageId)}&worker=${worker}`, + zEmailRoutingDetailResponse, + expect + ); + expect(detail.result).toMatchObject({ + worker, + messageId: duplicateMessageId, + subject, + raw: expect.stringContaining(`Body for ${worker}`), + }); + } + }); + + test("paginates filtered received emails without an empty terminal page", async ({ + expect, + }) => { + for (const [worker, messageId] of [ + ["email-a", ""], + ["email-c", ""], + ["email-a", ""], + ]) { + await storeReceivedEmail(instanceA, { + worker, + messageId, + subject: "Filtered pagination test", + text: "Filtered pagination test", + }); + } + + const firstPage = await expectExplorerApiResponse( + instanceA, + "/local/email/routing?worker=email-a&per_page=1", + zEmailListRoutingResponse, + expect + ); + expect(getListResult(firstPage.result)).toEqual([ + expect.objectContaining({ + worker: "email-a", + messageId: "", + }), + ]); + expect(firstPage.result_info).toMatchObject({ count: 1, has_more: true }); + const cursor = firstPage.result_info?.cursor; + expect(cursor).toEqual(expect.any(String)); + const state = decodeTestAggregateCursor(String(cursor)); + expect(Object.keys(state)).toEqual(["local"]); + + const lastPage = await expectExplorerApiResponse( + instanceA, + `/local/email/routing?worker=email-a&per_page=1&cursor=${encodeURIComponent(String(cursor))}`, + zEmailListRoutingResponse, + expect + ); + expect(getListResult(lastPage.result)).toEqual([ + expect.objectContaining({ + worker: "email-a", + messageId: "", + }), + ]); + expect(lastPage.result_info).toMatchObject({ count: 1, has_more: false }); + expect(lastPage.result_info).not.toHaveProperty("cursor"); + }); +}); + +describe("Local Explorer email pagination source churn", () => { + test("resumes a peer without replaying after it unregisters", async ({ + expect, + }) => { + const registryPath = mkdtempSync(path.join(tmpdir(), "mf-email-churn-")); + const optionsA = emailPeerOptions(registryPath, "churn-a", true); + const optionsB = emailPeerOptions(registryPath, "churn-b", true); + const instanceA = new Miniflare(optionsA); + const instanceB = new Miniflare(optionsB); + + try { + await Promise.all([instanceA.ready, instanceB.ready]); + await waitForWorkersInRegistry(registryPath, ["churn-a", "churn-b"]); + + for (let index = 0; index < 3; index++) { + await storeReceivedEmail(instanceA, { + worker: "churn-a", + messageId: ``, + subject: "Source churn", + text: "Source churn", + }); + } + for (let index = 0; index < 2; index++) { + await storeReceivedEmail(instanceB, { + worker: "churn-b", + messageId: ``, + subject: "Source churn", + text: "Source churn", + }); + } + + const firstPage = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing?per_page=1` + ), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(firstPage.result)[0]).toMatchObject({ + worker: "churn-b", + messageId: "", + }); + const firstCursor = String(firstPage.result_info?.cursor); + const firstState = decodeTestAggregateCursor(firstCursor); + const peerState = Object.entries(firstState).find( + ([source]) => source !== "local" + ); + if (peerState === undefined) { + throw new Error("Expected a peer cursor"); + } + expect(peerState[1]).toEqual(expect.any(String)); + expect(peerState[0]).toMatch(/^peer:/); + + await instanceB.setOptions( + emailPeerOptions(registryPath, "churn-b", false) + ); + await vi.waitFor(() => { + expect(getWorkerRegistry(registryPath)["churn-b"]).toBeUndefined(); + }); + + const secondPage = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing?per_page=1&cursor=${encodeURIComponent(firstCursor)}` + ), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(secondPage.result)[0]).toMatchObject({ + worker: "churn-a", + messageId: "", + }); + const secondCursor = String(secondPage.result_info?.cursor); + const secondState = decodeTestAggregateCursor(secondCursor); + expect(secondState[peerState[0]]).toBe(peerState[1]); + + await instanceB.setOptions(optionsB); + await waitForWorkersInRegistry(registryPath, ["churn-a", "churn-b"]); + + const thirdPage = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing?per_page=1&cursor=${encodeURIComponent(secondCursor)}` + ), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(thirdPage.result)[0]).toMatchObject({ + worker: "churn-b", + messageId: "", + }); + } finally { + await Promise.all([ + disposeWithRetry(instanceA), + disposeWithRetry(instanceB), + ]); + removeDirSync(registryPath); + } + }); + + test("does not add a newly registered peer to an existing pagination run", async ({ + expect, + }) => { + const registryPath = mkdtempSync(path.join(tmpdir(), "mf-email-churn-")); + const optionsA = emailPeerOptions(registryPath, "opening-a", true); + const optionsB = emailPeerOptions(registryPath, "opening-b", false); + const instanceA = new Miniflare(optionsA); + const instanceB = new Miniflare(optionsB); + + try { + await Promise.all([instanceA.ready, instanceB.ready]); + await waitForWorkersInRegistry(registryPath, ["opening-a"]); + for (let index = 0; index < 2; index++) { + await storeReceivedEmail(instanceA, { + worker: "opening-a", + messageId: ``, + subject: "Opening source", + text: "Opening source", + }); + } + await storeReceivedEmail(instanceB, { + worker: "opening-b", + messageId: "", + subject: "Opening source", + text: "Opening source", + }); + + const firstPage = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing?per_page=1` + ), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(firstPage.result)[0]?.messageId).toBe( + "" + ); + const cursor = String(firstPage.result_info?.cursor); + + await instanceB.setOptions( + emailPeerOptions(registryPath, "opening-b", true) + ); + await waitForWorkersInRegistry(registryPath, ["opening-a", "opening-b"]); + + const secondPage = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing?per_page=1&cursor=${encodeURIComponent(cursor)}` + ), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(secondPage.result)[0]?.messageId).toBe( + "" + ); + expect(secondPage.result_info?.has_more).toBe(false); + + const freshPage = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/local/email/routing?per_page=1` + ), + zEmailListRoutingResponse, + expect + ); + expect(getListResult(freshPage.result)[0]).toMatchObject({ + worker: "opening-b", + messageId: "", + }); + } finally { + await Promise.all([ + disposeWithRetry(instanceA), + disposeWithRetry(instanceB), + ]); + removeDirSync(registryPath); + } + }); +}); diff --git a/packages/miniflare/test/plugins/local-explorer/index.spec.ts b/packages/miniflare/test/plugins/local-explorer/index.spec.ts index 4395cbeeba6..8cffc88acc3 100644 --- a/packages/miniflare/test/plugins/local-explorer/index.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/index.spec.ts @@ -659,6 +659,8 @@ describe("Local Explorer /api/local/workers endpoint", () => { MY_KV: { type: "kv", id: "kv-namespace-id" }, MY_DB: { type: "d1", id: "d1-database-id" }, MY_BUCKET: { type: "r2", name: "r2-bucket-name" }, + SEND_EMAIL_PRIMARY: { type: "send-email" }, + SEND_EMAIL_SECONDARY: { type: "send-email" }, MY_DO: { type: "durable-object", workerName: "worker-a1", @@ -768,6 +770,14 @@ describe("Local Explorer /api/local/workers endpoint", () => { "id": "r2-bucket-name", }, ], + "sendEmail": [ + { + "bindingName": "SEND_EMAIL_PRIMARY", + }, + { + "bindingName": "SEND_EMAIL_SECONDARY", + }, + ], "workflows": [], }, "isSelf": true, @@ -784,6 +794,7 @@ describe("Local Explorer /api/local/workers endpoint", () => { }, ], "r2": [], + "sendEmail": [], "workflows": [], }, "isSelf": true, @@ -800,6 +811,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..e441922162c 100644 --- a/packages/wrangler/e2e/createTestHarness.test.ts +++ b/packages/wrangler/e2e/createTestHarness.test.ts @@ -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( @@ -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..1f785dc464e 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"; @@ -63,6 +64,8 @@ const HYPERDRIVE_DATABASES = [ * when multiple PRs have jobs running at the same time (or the same PR has the tests run across multiple OSes). */ const workerName = generateResourceName(); +const GENERATED_MESSAGE_ID_HEADER = + /^Message-ID: <[A-Za-z0-9]{36}@example\.com>$/m; describe.each([ { cmd: "wrangler dev --port=0 --inspector-port=0" }, @@ -2444,12 +2447,22 @@ This is a random email body. { interval: 100, timeout: 5000 } ); - expect(await readFile(maybeReplyPath, "utf-8")).toMatchInlineSnapshot(` + const reply = await readFile(maybeReplyPath, "utf-8"); + expect(reply).toMatch(GENERATED_MESSAGE_ID_HEADER); + expect(reply).not.toContain( + "Message-ID: " + ); + expect( + reply.replace( + GENERATED_MESSAGE_ID_HEADER, + "Message-ID: " + ) + ).toMatchInlineSnapshot(` "References: From: someone else To: someone In-Reply-To: - Message-ID: + Message-ID: MIME-Version: 1.0 Content-Type: text/plain @@ -2620,10 +2633,20 @@ This is a random email body. { interval: 100, timeout: 5000 } ); - expect(await readFile(maybeReplyPath, "utf-8")).toMatchInlineSnapshot(` + const capturedEmail = await readFile(maybeReplyPath, "utf-8"); + expect(capturedEmail).toMatch(GENERATED_MESSAGE_ID_HEADER); + expect(capturedEmail).not.toContain( + "Message-ID: " + ); + expect( + capturedEmail.replace( + GENERATED_MESSAGE_ID_HEADER, + "Message-ID: " + ) + ).toMatchInlineSnapshot(` "From: someone To: someone else - Message-ID: + Message-ID: MIME-Version: 1.0 Content-Type: text/plain @@ -2631,6 +2654,229 @@ 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" }) + ); + } + }, + }; + `, + }); + + const worker = helper.runLongLived("wrangler dev"); + const { url } = await worker.waitForReady(); + const apiUrl = `${url}/cdn-cgi/local/explorer/api`; + const sentText = "x".repeat(2 * 1024 * 1024); + + 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: sentText, + }), + }); + 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 sentList = await fetchJson<{ + result: Array<{ + worker: string; + messageId: string; + subject: string; + text?: string; + }>; + result_info: { + count: number; + per_page: number; + has_more: boolean; + cursor?: string; + }; + }>(`${apiUrl}/local/email/sending?worker=${workerName}`); + expect(sentList.result).toEqual([ + expect.objectContaining({ + worker: workerName, + messageId: sentResult.messageId, + subject: "Explorer sent email", + }), + ]); + expect(sentList.result[0]).not.toHaveProperty("text"); + expect(sentList.result_info).toMatchObject({ + count: 1, + per_page: 25, + has_more: false, + }); + expect(sentList.result_info).not.toHaveProperty("cursor"); + + const sentDetail = await fetchJson<{ + result: { + worker: string; + messageId: string; + subject: string; + text?: string; + }; + messages: Array<{ code: number; message: string }>; + }>( + `${apiUrl}/local/email/sending?email_id=${encodeURIComponent(sentResult.messageId)}` + ); + expect(sentDetail.result).toMatchObject({ + worker: workerName, + messageId: sentResult.messageId, + subject: "Explorer sent email", + }); + expect(sentDetail.result.text).not.toBe(sentText); + expect(sentDetail.messages).toEqual([ + { + code: 10604, + message: + "Displayed sent email content was truncated during local capture. The complete email is available in the local filesystem; see the development log for its path.", + }, + ]); + + const receivedRaw = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + X-Test-Mode: forward + Subject: Explorer received email + 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: [["x-forwarded-test", "ok"]], + }, + ], + events: [{ type: "received" }, { type: "forward" }], + }); + + const receivedList = await fetchJson<{ + result: Array<{ + worker: string; + messageId: string; + subject: string; + raw?: string; + forwards: Array<{ + recipient: string; + headers: Array<[string, string]>; + }>; + events: Array<{ type: string }>; + }>; + result_info: { + count: number; + per_page: number; + has_more: boolean; + cursor?: string; + }; + }>(`${apiUrl}/local/email/routing?worker=${workerName}`); + expect(receivedList.result).toEqual([ + expect.objectContaining({ + worker: workerName, + messageId: "", + subject: "Explorer received email", + forwards: [ + expect.objectContaining({ + recipient: "forwarded@example.com", + headers: [["x-forwarded-test", "ok"]], + }), + ], + events: [ + expect.objectContaining({ type: "received" }), + expect.objectContaining({ type: "forward" }), + ], + }), + ]); + expect(receivedList.result[0]).not.toHaveProperty("raw"); + expect(receivedList.result_info).toMatchObject({ + count: 1, + per_page: 25, + has_more: false, + }); + expect(receivedList.result_info).not.toHaveProperty("cursor"); + + const receivedDetail = await fetchJson<{ + result: { + worker: string; + messageId: string; + raw: string; + rawBase64?: string; + forwards: Array<{ + recipient: string; + headers: Array<[string, string]>; + }>; + events: Array<{ type: string }>; + }; + messages: Array<{ code: number; message: string }>; + }>( + `${apiUrl}/local/email/routing?email_id=${encodeURIComponent("")}` + ); + expect(receivedDetail.result).toMatchObject({ + worker: workerName, + messageId: "", + raw: receivedRaw, + rawBase64: Buffer.from(receivedRaw).toString("base64"), + forwards: [ + expect.objectContaining({ + recipient: "forwarded@example.com", + headers: [["x-forwarded-test", "ok"]], + }), + ], + events: [ + expect.objectContaining({ type: "received" }), + expect.objectContaining({ type: "forward" }), + ], + }); + expect(receivedDetail.messages).toEqual([]); + }); }); 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..32c3f758cf2 100644 --- a/packages/wrangler/e2e/multiworker-dev.test.ts +++ b/packages/wrangler/e2e/multiworker-dev.test.ts @@ -670,3 +670,82 @@ 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: expect.stringMatching(/^<[A-Za-z0-9]{36}@example\.com>$/), + worker: workerBName, + }), + ]) + ); + expect( + workerBEmails.result.some((email) => email.messageId === messageId) + ).toBe(false); + }); +}); 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.