From 4795d7e418da0671fc01776471e54d0676ad4d95 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:29:15 +0100 Subject: [PATCH 01/13] [miniflare] Define local email data contracts --- .../miniflare/src/workers/core/constants.ts | 5 + .../miniflare/src/workers/email/capture.ts | 89 ++++++++ .../miniflare/src/workers/email/constants.ts | 1 - .../src/workers/email/email.worker.ts | 2 +- .../miniflare/src/workers/email/message-id.ts | 47 ++++ .../miniflare/src/workers/email/storage.ts | 205 ++++++++++++++++++ .../miniflare/src/workers/email/validate.ts | 8 +- packages/miniflare/src/workers/index.ts | 8 + 8 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 packages/miniflare/src/workers/email/capture.ts delete mode 100644 packages/miniflare/src/workers/email/constants.ts create mode 100644 packages/miniflare/src/workers/email/message-id.ts create mode 100644 packages/miniflare/src/workers/email/storage.ts diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index 3e49866d37b..afd9b6ebbb5 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -65,6 +65,7 @@ export const CoreBindings = { SERVICE_LOOPBACK: "MINIFLARE_LOOPBACK", SERVICE_USER_ROUTE_PREFIX: "MINIFLARE_USER_ROUTE_", SERVICE_USER_FALLBACK: "MINIFLARE_USER_FALLBACK", + TEXT_FALLBACK_WORKER_NAME: "MINIFLARE_FALLBACK_WORKER_NAME", TEXT_CUSTOM_SERVICE: "MINIFLARE_CUSTOM_SERVICE", // Backs the Images binding (`env.IMAGES`) — see imagesLocalFetcher. IMAGES_BINDING_SERVICE: "MINIFLARE_IMAGES_BINDING_SERVICE", @@ -95,6 +96,10 @@ export const CoreBindings = { SERVICE_R2_PUBLIC: "MINIFLARE_R2_PUBLIC", SERVICE_R2_S3: "MINIFLARE_R2_S3", SERVICE_OBSERVABILITY_COLLECTOR: "MINIFLARE_OBSERVABILITY_COLLECTOR", + 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/email/capture.ts b/packages/miniflare/src/workers/email/capture.ts new file mode 100644 index 00000000000..fd7ba863953 --- /dev/null +++ b/packages/miniflare/src/workers/email/capture.ts @@ -0,0 +1,89 @@ +// Helpers for preparing email bytes for capture into the local email store. +// +// Capture is a dev-only inspection aid for the Local Explorer. It pushes the +// raw MIME (as base64) to the EmailStore Durable Object over workerd-internal +// RPC, whose argument size is capped near 1 MiB. Rather than fail delivery for +// larger messages, oversized bodies are truncated for capture only; delivery +// always uses the full, untruncated message. + +export const RAW_EMAIL = "EmailMessage::raw"; + +/** + * Maximum raw byte size of an email body captured into the local email store. + * + * This is a capture/inspection limit, not a delivery limit: sending, replying, + * and receiving always use the full message regardless of size. Capture pushes + * the raw MIME to the EmailStore Durable Object over workerd-internal RPC, + * whose argument size is capped near 1 MiB, so larger bodies are truncated to + * this size for the Local Explorer (see `truncateRawForCapture`). + */ +export const MAX_LOCAL_EMAIL_BYTES = 1024 * 1024; + +/** Encodes bytes without passing a large argument list to String.fromCharCode. */ +export function bytesToBase64(bytes: Uint8Array): string { + const chunkSize = 0x8000; + let binary = ""; + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + binary += String.fromCharCode( + ...bytes.subarray(offset, offset + chunkSize) + ); + } + return btoa(binary); +} + +export function base64ToBytes(encoded: string): Uint8Array { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +export interface TruncatedRaw { + /** Raw MIME content, truncated to `MAX_LOCAL_EMAIL_BYTES` when oversized. */ + raw: string; + /** Lossless base64 of the (possibly truncated) raw content. */ + rawBase64: string; + /** Whether the content was truncated for capture. */ + truncated: boolean; +} + +/** + * Prepares a raw email body for capture in the local email store. + * + * Capture pushes the raw MIME (as base64) to the EmailStore Durable Object over + * workerd-internal RPC, whose argument size is capped near 1 MiB. Rather than + * fail delivery for larger messages, we capture only the first + * `MAX_LOCAL_EMAIL_BYTES` of the raw body so the Local Explorer still shows a + * (truncated) message. Delivery itself always uses the full, untruncated body — + * this only affects what the inspector stores. + */ +export function truncateRawForCapture(raw: Uint8Array): TruncatedRaw { + const truncated = raw.byteLength > MAX_LOCAL_EMAIL_BYTES; + const captured = truncated ? raw.subarray(0, MAX_LOCAL_EMAIL_BYTES) : raw; + return { + raw: new TextDecoder().decode(captured), + rawBase64: bytesToBase64(captured), + truncated, + }; +} + +/** + * Truncates a UTF-8 string to at most `maxBytes` bytes, splitting on a byte + * boundary (any trailing partial multi-byte sequence is dropped by the decoder). + * Returns the original string when it already fits. + */ +export function truncateStringForCapture( + value: string, + maxBytes: number = MAX_LOCAL_EMAIL_BYTES +): { value: string; truncated: boolean } { + const bytes = new TextEncoder().encode(value); + if (bytes.byteLength <= maxBytes) { + return { value, truncated: false }; + } + return { + value: new TextDecoder().decode(bytes.subarray(0, maxBytes)), + truncated: true, + }; +} diff --git a/packages/miniflare/src/workers/email/constants.ts b/packages/miniflare/src/workers/email/constants.ts deleted file mode 100644 index 9f9dacfbe3b..00000000000 --- a/packages/miniflare/src/workers/email/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const RAW_EMAIL = "EmailMessage::raw"; diff --git a/packages/miniflare/src/workers/email/email.worker.ts b/packages/miniflare/src/workers/email/email.worker.ts index b8917d1f97b..5402b451b7c 100644 --- a/packages/miniflare/src/workers/email/email.worker.ts +++ b/packages/miniflare/src/workers/email/email.worker.ts @@ -1,4 +1,4 @@ -import { RAW_EMAIL } from "./constants"; +import { RAW_EMAIL } from "./capture"; import type { EmailMessage as EmailMessageType } from "@cloudflare/workers-types/experimental"; // This type is the _actual_ type of an EmailMessage when running locally, which is different to production diff --git a/packages/miniflare/src/workers/email/message-id.ts b/packages/miniflare/src/workers/email/message-id.ts new file mode 100644 index 00000000000..b1c20098a8b --- /dev/null +++ b/packages/miniflare/src/workers/email/message-id.ts @@ -0,0 +1,47 @@ +// Message-ID handling shared by the paths that capture emails: the `send_email` +// binding and the local explorer's "send test email" endpoint. Both must agree +// on the format, because the id derived from a Message-ID keys the explorer's +// record. + +/** + * Builds a Message-ID in the shape the `mimetext` library generates for emails + * created via `createMimeMessage()`: `<{base36 random}@{sender domain}>`. Used + * as a fallback when no Message-ID is otherwise available, so a synthesized id + * matches the format callers see everywhere else. + */ +export function synthesizeMessageId(senderEmail: string): string { + const id = Math.random().toString(36).slice(2); + const domain = senderEmail.slice(senderEmail.lastIndexOf("@") + 1); + return `<${id}@${domain}>`; +} + +/** + * Derives the id an email is indexed under from its Message-ID by stripping the + * enclosing angle brackets. + * + * This id keys the local explorer record, so a message listed in the explorer + * can be looked up by it. + */ +export function messageIdToStorageId(messageId: string): string { + return messageId.replace(/^<|>$/g, ""); +} + +/** + * Case-insensitive lookup of a header value in a `Record` of headers, so a + * caller-supplied Message-ID is honoured whatever casing it uses. + */ +export function getHeader( + headers: Record | undefined, + name: string +): string | undefined { + if (headers === undefined) { + return undefined; + } + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === target) { + return value; + } + } + return undefined; +} diff --git a/packages/miniflare/src/workers/email/storage.ts b/packages/miniflare/src/workers/email/storage.ts new file mode 100644 index 00000000000..c84f0188f8b --- /dev/null +++ b/packages/miniflare/src/workers/email/storage.ts @@ -0,0 +1,205 @@ +// 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. + +export type EmailHandlerEventType = + | "received" + | "forward" + | "reply" + | "reject" + | "unhandled"; + +export type EmailHandlerEvent = + | { + /** A message the handler forwarded or replied to. */ + type: "forward" | "reply"; + timestamp: string; + /** Correlates with the matching `forwards`/`replies` entry. */ + messageId: string; + } + | { + /** + * A lifecycle event with no associated message: `received` (always + * first), `reject` (the handler called `setReject()`), or `unhandled` + * (the Worker exports no `email()` handler). + */ + type: "received" | "reject" | "unhandled"; + timestamp: string; + }; + +export interface EmailHandlerForward { + messageId: string; + /** Envelope recipient the message was forwarded to. */ + recipient: string; + headers: [string, string][]; +} + +export interface EmailHandlerReply { + messageId: string; + /** Address the reply was sent from. */ + sender: string; + /** Raw MIME content of the reply. */ + raw: string; + /** Lossless base64 representation of the reply MIME. */ + rawBase64?: string; +} + +export interface EmailHandlerResult { + outcome: "ok" | "exception"; + /** Reason passed to `setReject()`, if the handler rejected the message. */ + rejectReason?: string; + forwards: EmailHandlerForward[]; + replies: EmailHandlerReply[]; + /** Ordered lifecycle of everything the handler did to the message. */ + events: EmailHandlerEvent[]; +} + +export interface StoredRoutingEmail extends EmailHandlerResult { + /** Worker whose `email()` handler processed the message, if known. */ + worker?: string; + /** Envelope MAIL FROM address. */ + from: string; + /** Envelope RCPT TO address. */ + to: string; + subject: string; + /** + * RFC `Message-ID` header value (``). Indexes the record in the + * store; a message listed in the explorer is looked up by it. + */ + messageId: string; + receivedAt: string; + rawSize: number; + /** Raw MIME content (capped at 1MiB by the email handler). */ + raw: string; + /** Lossless base64 representation of the raw MIME content. */ + rawBase64?: string; + /** Attachments parsed out of `raw`. Metadata only.*/ + attachments: StoredEmailAttachment[]; +} + +export type StoredRoutingEmailMetadata = Omit< + StoredRoutingEmail, + "raw" | "rawBase64" | "replies" +> & { + // Reply raw bodies are streamed separately (see the received chunk + // transport), so the metadata prelude carries only reply envelope fields. + replies: Array< + Omit + >; +}; + +export type StoredRoutingEmailRecord = Omit< + StoredRoutingEmail, + "raw" | "rawBase64" | "replies" +> & { + rawBase64: string; + // Reply raw bodies are stored base64-only; the decoded `raw` is + // materialised on read. + replies: Array< + Omit & { + raw?: string; + rawBase64?: string; + } + >; +}; + +export type StoredRoutingEmailSummary = Omit< + StoredRoutingEmail, + "raw" | "rawBase64" | "replies" +> & { + replies: Array< + Omit + >; +}; + +export interface StoredEmailAttachment { + filename: string; + contentType: string; + disposition: "inline" | "attachment"; + size: number; +} + +export interface EmailArtifact { + recordId: string; + prefix: string; + id: string; + extension: string; +} + +export interface StoredSendingEmail { + /** Worker that owns the `send_email` binding the message was sent through, if known. */ + worker?: string; + from: string; + to: string[]; + cc?: string[]; + bcc?: string[]; + replyTo?: string; + subject: string; + sentAt: string; + /** + * RFC `Message-ID` header value (``). Indexes the record in the + * store. + */ + messageId: string; + text?: string; + html?: string; + headers?: Record; + attachments: StoredEmailAttachment[]; + /** Raw MIME content, present when sent via the `EmailMessage` API. */ + raw?: string; + /** Lossless base64 representation of the raw MIME content. */ + rawBase64?: string; +} + +export type StoredSendingEmailSummary = Omit< + StoredSendingEmail, + "text" | "html" | "raw" | "rawBase64" +>; + +/** + * A sent email without its raw MIME body, used as the metadata prelude when + * streaming a large raw email's body to the store in chunks (mirrors + * `StoredRoutingEmailMetadata`). + */ +export type StoredSendingEmailMetadata = Omit< + StoredSendingEmail, + "raw" | "rawBase64" +>; + +/** + * RPC surface of the email store host worker (see email-store.worker.ts). Used + * to type the `SERVICE_EMAIL_STORE` service binding in the workers that + * capture (send_email, the receiving `email()` path) and read (local explorer) + * emails. + */ +export interface EmailStoreService { + storeReceived(email: StoredRoutingEmailRecord): Promise; + beginReceived(email: StoredRoutingEmailMetadata): Promise; + appendReceivedRaw(id: string, chunk: string): Promise; + appendReplyRaw(id: string, replyIndex: number, chunk: string): Promise; + finishReceived(id: string): Promise; + discardReceived(id: string): Promise; + /** Looks up a received email by its local storage ID. */ + findReceived(id: string): Promise; + listReceived(): Promise; + storeSent(email: StoredSendingEmail): Promise; + beginSent(email: StoredSendingEmailMetadata): Promise; + appendSentRaw(id: string, chunk: string): Promise; + finishSent(id: string): Promise; + discardSent(id: string): Promise; + /** Looks up a sent email by its local storage ID. */ + findSent(id: string): Promise; + listSent(): Promise; + clear(): Promise; +} diff --git a/packages/miniflare/src/workers/email/validate.ts b/packages/miniflare/src/workers/email/validate.ts index b9e0883cd82..bf87268cfa5 100644 --- a/packages/miniflare/src/workers/email/validate.ts +++ b/packages/miniflare/src/workers/email/validate.ts @@ -1,6 +1,6 @@ import { red } from "kleur/colors"; import PostalMime from "postal-mime"; -import { RAW_EMAIL } from "./constants"; +import { RAW_EMAIL } from "./capture"; import { type MiniflareEmailMessage as EmailMessage } from "./email.worker"; import type { Email } from "postal-mime"; @@ -64,7 +64,7 @@ export async function isEmailReplyable( export async function validateReply( incomingMessage: Email, replyMessage: EmailMessage -): Promise { +): Promise<{ raw: Uint8Array; messageId: string }> { const rawEmail: ReadableStream = replyMessage[RAW_EMAIL]; const rawEmailBuffer = new Uint8Array( @@ -130,8 +130,8 @@ export async function validateReply( // prepend References to be in the headers instead of the end of the body finalReplyEmail.set(encodedReferences, 0); finalReplyEmail.set(rawEmailBuffer, encodedReferences.byteLength); - return finalReplyEmail; + return { raw: finalReplyEmail, messageId: parsedReply.messageId }; } - return rawEmailBuffer; + return { raw: rawEmailBuffer, messageId: parsedReply.messageId }; } diff --git a/packages/miniflare/src/workers/index.ts b/packages/miniflare/src/workers/index.ts index 3cfe67ac4ba..1fce6315fea 100644 --- a/packages/miniflare/src/workers/index.ts +++ b/packages/miniflare/src/workers/index.ts @@ -1,5 +1,13 @@ export * from "./cache"; export * from "./core"; +export type { + EmailArtifact, + EmailHandlerEvent, + EmailHandlerEventType, + EmailHandlerForward, + EmailHandlerReply, + EmailHandlerResult, +} from "./email/storage"; export * from "./kv"; export * from "./queues"; export * from "./shared"; From 6a15062b6fb9417a081b35a5effcf9260fbaa474 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:29:29 +0100 Subject: [PATCH 02/13] [miniflare] Add local email API schemas --- packages/miniflare/openapi-ts.config.ts | 2 +- .../scripts/openapi-filter-config.ts | 621 +++++++++++++++ .../workers/local-explorer/generated/index.ts | 34 + .../local-explorer/generated/types.gen.ts | 395 ++++++++++ .../local-explorer/generated/zod.gen.ts | 208 +++++ .../workers/local-explorer/openapi.local.json | 728 ++++++++++++++++++ .../src/workers/local-explorer/route-names.ts | 5 + 7 files changed, 1992 insertions(+), 1 deletion(-) diff --git a/packages/miniflare/openapi-ts.config.ts b/packages/miniflare/openapi-ts.config.ts index a063e53af9b..f153d165838 100644 --- a/packages/miniflare/openapi-ts.config.ts +++ b/packages/miniflare/openapi-ts.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ // Keep these paths in sync with the prettier inputs in package.json (generate:types script) input: "src/workers/local-explorer/openapi.local.json", output: "src/workers/local-explorer/generated", - plugins: ["@hey-api/typescript", "zod"], + plugins: ["@hey-api/typescript", { name: "zod", compatibilityVersion: 4 }], parser: { patch: { schemas: { diff --git a/packages/miniflare/scripts/openapi-filter-config.ts b/packages/miniflare/scripts/openapi-filter-config.ts index d0968087c33..4aeaa7e34e0 100644 --- a/packages/miniflare/scripts/openapi-filter-config.ts +++ b/packages/miniflare/scripts/openapi-filter-config.ts @@ -628,6 +628,273 @@ const config = { }, }, + // Email endpoints (local-only, not pulling from upstream API) + "/email/routing": { + get: { + description: + "Lists emails received by the worker's email() handler during this dev session.", + operationId: "email-list-routing", + parameters: [], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + items: { + $ref: "#/components/schemas/email_routing-item", + }, + type: "array", + }, + }, + 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"], + }, + }, + "/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 other to/cc/bcc addresses appear only in the composed MIME headers.", + operationId: "email-send-routing", + 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"], + }, + }, + "/email/routing/{email_id}": { + get: { + description: "Returns the details of a received email.", + operationId: "email-get-routing", + parameters: [ + { + in: "path", + name: "email_id", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + $ref: "#/components/schemas/email_routing-detail", + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "Get received email response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Get received email failure.", + }, + }, + summary: "Get Received Email", + tags: ["Email"], + }, + }, + "/email/sending": { + get: { + description: + "Lists emails sent through send_email bindings during this dev session.", + operationId: "email-list-sending", + parameters: [], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + items: { + $ref: "#/components/schemas/email_sending-item", + }, + type: "array", + }, + }, + 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"], + }, + }, + "/email/sending/{email_id}": { + get: { + description: "Returns the details of a sent email.", + operationId: "email-get-sending", + parameters: [ + { + in: "path", + name: "email_id", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + $ref: "#/components/schemas/email_sending-detail", + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "Get sent email response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Get sent email failure.", + }, + }, + summary: "Get Sent Email", + tags: ["Email"], + }, + }, + // Workflows endpoints (local-only, not pulling from upstream API) "/workflows": { get: { @@ -1663,6 +1930,13 @@ const config = { }, description: "Workflow bindings", }, + sendEmail: { + type: "array", + items: { + $ref: "#/components/schemas/local-explorer_resource-binding", + }, + description: "Send Email bindings", + }, }, }, "local-explorer_resource-binding": { @@ -1867,6 +2141,353 @@ const config = { }, required: ["columns", "rows"], }, + + "email_handler-event": { + type: "object", + description: + "One entry in the ordered lifecycle of what the handler did to the message. `forward`/`reply` events carry a `messageId` correlating with the matching `forwards`/`replies` entry.", + properties: { + type: { + type: "string", + enum: ["received", "forward", "reply", "reject", "unhandled"], + description: "The kind of event.", + }, + timestamp: { + type: "string", + description: "ISO 8601 timestamp of when the event occurred.", + }, + messageId: { + type: "string", + description: + "Present on `forward`/`reply` events; correlates with the matching `forwards`/`replies` entry.", + }, + }, + required: ["type", "timestamp"], + }, + "email_handler-forward": { + type: "object", + properties: { + messageId: { type: "string" }, + recipient: { + type: "string", + description: "Envelope recipient the message was forwarded to.", + }, + headers: { + type: "array", + description: + "Headers added to the forwarded message, as [key, value] pairs.", + items: { + type: "array", + items: { type: "string" }, + }, + }, + }, + required: ["messageId", "recipient", "headers"], + }, + "email_handler-reply": { + type: "object", + properties: { + messageId: { type: "string" }, + sender: { + type: "string", + description: "Address the reply was sent from.", + }, + raw: { + type: "string", + description: + "Raw MIME content of the reply. Omitted from the routing list; present on the detail response.", + }, + rawBase64: { + type: "string", + description: "Lossless base64 representation of the reply MIME.", + }, + }, + required: ["messageId", "sender"], + }, + "email_routing-item": { + type: "object", + properties: { + worker: { + type: "string", + description: + "Worker whose email() handler processed the message, if known.", + }, + from: { type: "string", description: "Envelope MAIL FROM address." }, + to: { type: "string", description: "Envelope RCPT TO address." }, + subject: { type: "string" }, + messageId: { + type: "string", + description: + "RFC Message-ID header value. Identifies the email in the store.", + }, + receivedAt: { type: "string" }, + rawSize: { type: "number" }, + outcome: { + type: "string", + enum: ["ok", "exception"], + description: "Whether the handler ran to completion or threw.", + }, + rejectReason: { + type: "string", + description: + "Reason passed to setReject(), if the handler rejected the message.", + }, + forwards: { + type: "array", + items: { $ref: "#/components/schemas/email_handler-forward" }, + }, + replies: { + type: "array", + items: { $ref: "#/components/schemas/email_handler-reply" }, + }, + events: { + type: "array", + items: { $ref: "#/components/schemas/email_handler-event" }, + }, + attachments: { + type: "array", + items: { $ref: "#/components/schemas/email_attachment" }, + }, + }, + required: [ + "messageId", + "from", + "to", + "subject", + "receivedAt", + "rawSize", + "outcome", + "forwards", + "replies", + "events", + "attachments", + ], + }, + "email_routing-detail": { + type: "object", + properties: { + worker: { type: "string" }, + from: { type: "string" }, + to: { type: "string" }, + subject: { type: "string" }, + messageId: { + type: "string", + description: + "RFC Message-ID header value. Identifies the email in the store.", + }, + receivedAt: { type: "string" }, + rawSize: { type: "number" }, + raw: { + type: "string", + description: "Raw MIME content of the received email.", + }, + rawBase64: { + type: "string", + description: "Lossless base64 representation of the received MIME.", + }, + attachments: { + type: "array", + items: { + $ref: "#/components/schemas/email_attachment", + }, + description: + "Metadata for attachments parsed out of the received message. The content itself is only available in `raw`.", + }, + outcome: { + type: "string", + enum: ["ok", "exception"], + description: "Whether the handler ran to completion or threw.", + }, + rejectReason: { + type: "string", + description: + "Reason passed to setReject(), if the handler rejected the message.", + }, + forwards: { + type: "array", + items: { $ref: "#/components/schemas/email_handler-forward" }, + }, + replies: { + type: "array", + items: { $ref: "#/components/schemas/email_handler-reply" }, + }, + events: { + type: "array", + items: { $ref: "#/components/schemas/email_handler-event" }, + }, + }, + required: [ + "messageId", + "from", + "to", + "subject", + "receivedAt", + "rawSize", + "raw", + "attachments", + "outcome", + "forwards", + "replies", + "events", + ], + }, + "email_send-request": { + type: "object", + description: + "Fields for composing a test email, mirroring MessageBuilder.", + properties: { + from: { type: "string", description: "Sender address." }, + to: { + type: "array", + items: { type: "string" }, + minItems: 1, + description: "Recipient addresses.", + }, + cc: { type: "array", items: { type: "string" } }, + bcc: { type: "array", items: { type: "string" } }, + replyTo: { type: "string" }, + subject: { type: "string" }, + text: { type: "string", description: "Plain text body." }, + html: { type: "string", description: "HTML body." }, + headers: { + type: "object", + additionalProperties: { type: "string" }, + description: "Custom headers to include on the message.", + }, + attachments: { + type: "array", + description: + "Attachments to include on the message, mirroring the MessageBuilder `attachments` entries accepted by a send_email binding. Adding any attachment composes the message as multipart/mixed.", + items: { + type: "object", + properties: { + filename: { + type: "string", + description: "Name the attachment is presented under.", + }, + type: { + type: "string", + description: + "MIME type of the attachment, e.g. 'application/pdf'.", + }, + content: { + type: "string", + description: + "Attachment content, base64-encoded. MessageBuilder takes raw bytes here, but this endpoint accepts JSON so the bytes must be base64-encoded.", + }, + contentId: { + type: "string", + description: "Content-ID for an inline attachment.", + }, + disposition: { + type: "string", + enum: ["inline", "attachment"], + description: + "How the attachment is presented. Defaults to 'attachment'.", + }, + }, + required: ["filename", "type", "content"], + }, + }, + }, + required: ["from", "to", "subject"], + }, + email_attachment: { + type: "object", + description: + "Metadata describing an attachment on a captured email, without its content.", + properties: { + filename: { type: "string" }, + contentType: { type: "string" }, + disposition: { + type: "string", + enum: ["inline", "attachment"], + }, + size: { type: "number" }, + }, + required: ["filename", "contentType", "disposition", "size"], + }, + "email_sending-item": { + type: "object", + properties: { + from: { type: "string" }, + to: { type: "array", items: { type: "string" } }, + cc: { type: "array", items: { type: "string" } }, + bcc: { type: "array", items: { type: "string" } }, + replyTo: { type: "string" }, + subject: { type: "string" }, + messageId: { + type: "string", + description: + "RFC Message-ID header value. Identifies the email in the store.", + }, + sentAt: { type: "string" }, + headers: { + type: "object", + additionalProperties: { type: "string" }, + }, + attachments: { + type: "array", + items: { + $ref: "#/components/schemas/email_attachment", + }, + }, + }, + required: [ + "messageId", + "from", + "to", + "subject", + "sentAt", + "attachments", + ], + }, + "email_sending-detail": { + type: "object", + properties: { + from: { type: "string" }, + to: { type: "array", items: { type: "string" } }, + cc: { type: "array", items: { type: "string" } }, + bcc: { type: "array", items: { type: "string" } }, + replyTo: { type: "string" }, + subject: { type: "string" }, + messageId: { + type: "string", + description: + "RFC Message-ID header value. Identifies the email in the store.", + }, + sentAt: { type: "string" }, + text: { type: "string" }, + html: { type: "string" }, + headers: { + type: "object", + additionalProperties: { type: "string" }, + }, + attachments: { + type: "array", + items: { + $ref: "#/components/schemas/email_attachment", + }, + }, + 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: [ + "messageId", + "from", + "to", + "subject", + "sentAt", + "attachments", + ], + }, }, }, } satisfies FilterConfig; diff --git a/packages/miniflare/src/workers/local-explorer/generated/index.ts b/packages/miniflare/src/workers/local-explorer/generated/index.ts index 4533d808860..2831ad1d59f 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/index.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/index.ts @@ -46,6 +46,40 @@ export type { DurableObjectsNamespaceQuerySqliteErrors, DurableObjectsNamespaceQuerySqliteResponse, DurableObjectsNamespaceQuerySqliteResponses, + EmailAttachment, + EmailGetRoutingData, + EmailGetRoutingError, + EmailGetRoutingErrors, + EmailGetRoutingResponse, + EmailGetRoutingResponses, + EmailGetSendingData, + EmailGetSendingError, + EmailGetSendingErrors, + EmailGetSendingResponse, + EmailGetSendingResponses, + EmailHandlerEvent, + EmailHandlerForward, + EmailHandlerReply, + EmailListRoutingData, + EmailListRoutingError, + EmailListRoutingErrors, + EmailListRoutingResponse, + EmailListRoutingResponses, + EmailListSendingData, + EmailListSendingError, + EmailListSendingErrors, + EmailListSendingResponse, + EmailListSendingResponses, + EmailRoutingDetail, + EmailRoutingItem, + EmailSendingDetail, + EmailSendingItem, + EmailSendRequest, + EmailSendRoutingData, + EmailSendRoutingError, + EmailSendRoutingErrors, + EmailSendRoutingResponse, + EmailSendRoutingResponses, LocalExplorerDoBinding, LocalExplorerListWorkersData, LocalExplorerListWorkersError, diff --git a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts index 3bf15682d9f..c04544084d5 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts @@ -607,6 +607,10 @@ export type LocalExplorerWorkerBindings = { * Workflow bindings */ workflows?: Array; + /** + * Send Email bindings + */ + sendEmail?: Array; }; export type LocalExplorerResourceBinding = { @@ -778,6 +782,235 @@ export type ObservabilityQueryResult = { rows: Array>; }; +/** + * One entry in the ordered lifecycle of what the handler did to the message. `forward`/`reply` events carry a `messageId` correlating with the matching `forwards`/`replies` entry. + */ +export type EmailHandlerEvent = { + /** + * The kind of event. + */ + type: "received" | "forward" | "reply" | "reject" | "unhandled"; + /** + * ISO 8601 timestamp of when the event occurred. + */ + timestamp: string; + /** + * Present on `forward`/`reply` events; 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, as [key, value] pairs. + */ + headers: Array>; +}; + +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 EmailRoutingItem = { + /** + * Worker whose email() handler processed the message, if known. + */ + worker?: string; + /** + * Envelope MAIL FROM address. + */ + from: string; + /** + * Envelope RCPT TO address. + */ + to: string; + subject: string; + /** + * RFC Message-ID header value. Identifies the email in the store. + */ + messageId: string; + receivedAt: string; + rawSize: number; + /** + * Whether the handler ran to completion or threw. + */ + outcome: "ok" | "exception"; + /** + * Reason passed to setReject(), if the handler rejected the message. + */ + rejectReason?: string; + forwards: Array; + replies: Array; + events: Array; + attachments: Array; +}; + +export type EmailRoutingDetail = { + worker?: string; + from: string; + to: string; + subject: string; + /** + * RFC Message-ID header value. Identifies the email in the store. + */ + messageId: string; + receivedAt: string; + rawSize: number; + /** + * Raw MIME content of the received email. + */ + raw: string; + /** + * Lossless base64 representation of the received MIME. + */ + rawBase64?: string; + /** + * Metadata for attachments parsed out of the received message. The content itself is only available in `raw`. + */ + attachments: Array; + /** + * Whether the handler ran to completion or threw. + */ + outcome: "ok" | "exception"; + /** + * Reason passed to setReject(), if the handler rejected the message. + */ + rejectReason?: string; + forwards: Array; + replies: Array; + events: Array; +}; + +/** + * 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 = { + from: string; + to: Array; + cc?: Array; + bcc?: Array; + replyTo?: string; + subject: string; + /** + * RFC Message-ID header value. Identifies the email in the store. + */ + messageId: string; + sentAt: string; + headers?: { + [key: string]: string; + }; + attachments: Array; +}; + +export type EmailSendingDetail = { + from: string; + to: Array; + cc?: Array; + bcc?: Array; + replyTo?: string; + subject: string; + /** + * RFC Message-ID header value. Identifies the email in the store. + */ + messageId: string; + sentAt: string; + text?: string; + html?: string; + headers?: { + [key: string]: string; + }; + attachments: Array; + /** + * 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 +1699,168 @@ export type LocalExplorerListWorkersResponses = { export type LocalExplorerListWorkersResponse = LocalExplorerListWorkersResponses[keyof LocalExplorerListWorkersResponses]; +export type EmailListRoutingData = { + body?: never; + path?: never; + query?: never; + url: "/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; + }; +}; + +export type EmailListRoutingResponse = + EmailListRoutingResponses[keyof EmailListRoutingResponses]; + +export type EmailSendRoutingData = { + body: EmailSendRequest; + path?: never; + query?: never; + url: "/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 EmailGetRoutingData = { + body?: never; + path: { + email_id: string; + }; + query?: never; + url: "/email/routing/{email_id}"; +}; + +export type EmailGetRoutingErrors = { + /** + * Get received email failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type EmailGetRoutingError = + EmailGetRoutingErrors[keyof EmailGetRoutingErrors]; + +export type EmailGetRoutingResponses = { + /** + * Get received email response. + */ + 200: WorkersApiResponseCommon & { + result?: EmailRoutingDetail; + }; +}; + +export type EmailGetRoutingResponse = + EmailGetRoutingResponses[keyof EmailGetRoutingResponses]; + +export type EmailListSendingData = { + body?: never; + path?: never; + query?: never; + url: "/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; + }; +}; + +export type EmailListSendingResponse = + EmailListSendingResponses[keyof EmailListSendingResponses]; + +export type EmailGetSendingData = { + body?: never; + path: { + email_id: string; + }; + query?: never; + url: "/email/sending/{email_id}"; +}; + +export type EmailGetSendingErrors = { + /** + * Get sent email failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type EmailGetSendingError = + EmailGetSendingErrors[keyof EmailGetSendingErrors]; + +export type EmailGetSendingResponses = { + /** + * Get sent email response. + */ + 200: WorkersApiResponseCommon & { + result?: EmailSendingDetail; + }; +}; + +export type EmailGetSendingResponse = + EmailGetSendingResponses[keyof EmailGetSendingResponses]; + export type WorkflowsListWorkflowsData = { body?: never; path?: never; diff --git a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts index 998064d0c3d..6484ea5cd46 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts @@ -440,6 +440,7 @@ export const zLocalExplorerWorkerBindings = z.object({ r2: z.array(zLocalExplorerResourceBinding).optional(), do: z.array(zLocalExplorerDoBinding).optional(), workflows: z.array(zLocalExplorerWorkflowBinding).optional(), + sendEmail: z.array(zLocalExplorerResourceBinding).optional(), }); export const zLocalExplorerWorker = z.object({ @@ -528,6 +529,128 @@ 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. `forward`/`reply` events carry a `messageId` correlating with the matching `forwards`/`replies` entry. + */ +export const zEmailHandlerEvent = z.object({ + type: z.enum(["received", "forward", "reply", "reject", "unhandled"]), + timestamp: z.string(), + messageId: z.string().optional(), +}); + +export const zEmailHandlerForward = z.object({ + messageId: z.string(), + recipient: z.string(), + headers: z.array(z.array(z.string())), +}); + +export const zEmailHandlerReply = z.object({ + messageId: z.string(), + sender: z.string(), + raw: z.string().optional(), + rawBase64: z.string().optional(), +}); + +/** + * Fields for composing a test email, mirroring MessageBuilder. + */ +export const zEmailSendRequest = z.object({ + from: z.string(), + to: z.array(z.string()).min(1), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + replyTo: z.string().optional(), + subject: z.string(), + text: z.string().optional(), + html: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + attachments: z + .array( + z.object({ + filename: z.string(), + type: z.string(), + content: z.string(), + contentId: z.string().optional(), + disposition: z.enum(["inline", "attachment"]).optional(), + }) + ) + .optional(), +}); + +/** + * Metadata describing an attachment on a captured email, without its content. + */ +export const zEmailAttachment = z.object({ + filename: z.string(), + contentType: z.string(), + disposition: z.enum(["inline", "attachment"]), + size: z.number(), +}); + +export const zEmailRoutingItem = z.object({ + worker: z.string().optional(), + from: z.string(), + to: z.string(), + subject: z.string(), + messageId: z.string(), + receivedAt: z.string(), + rawSize: z.number(), + outcome: z.enum(["ok", "exception"]), + rejectReason: z.string().optional(), + forwards: z.array(zEmailHandlerForward), + replies: z.array(zEmailHandlerReply), + events: z.array(zEmailHandlerEvent), + attachments: z.array(zEmailAttachment), +}); + +export const zEmailRoutingDetail = z.object({ + worker: z.string().optional(), + from: z.string(), + to: z.string(), + subject: z.string(), + messageId: z.string(), + receivedAt: z.string(), + rawSize: z.number(), + raw: z.string(), + rawBase64: z.string().optional(), + attachments: z.array(zEmailAttachment), + outcome: z.enum(["ok", "exception"]), + rejectReason: z.string().optional(), + forwards: z.array(zEmailHandlerForward), + replies: z.array(zEmailHandlerReply), + events: z.array(zEmailHandlerEvent), +}); + +export const zEmailSendingItem = z.object({ + from: z.string(), + to: z.array(z.string()), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + replyTo: z.string().optional(), + subject: z.string(), + messageId: z.string(), + sentAt: z.string(), + headers: z.record(z.string()).optional(), + attachments: z.array(zEmailAttachment), +}); + +export const zEmailSendingDetail = z.object({ + from: z.string(), + to: z.array(z.string()), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), + replyTo: z.string().optional(), + subject: z.string(), + messageId: z.string(), + sentAt: z.string(), + text: z.string().optional(), + html: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + attachments: z.array(zEmailAttachment), + raw: z.string().optional(), + rawBase64: z.string().optional(), +}); + export const zR2ResultInfoWritable = z.record(z.string(), z.unknown()); export const zWorkersNamespaceWritable = z.object({ @@ -928,6 +1051,91 @@ export const zLocalExplorerListWorkersResponse = zWorkersApiResponseCommon.and( }) ); +export const zEmailListRoutingData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional(), +}); + +/** + * List received emails response. + */ +export const zEmailListRoutingResponse = zWorkersApiResponseCommon.and( + z.object({ + result: z.array(zEmailRoutingItem).optional(), + }) +); + +export const zEmailSendRoutingData = z.object({ + body: zEmailSendRequest, + path: z.never().optional(), + query: z.never().optional(), +}); + +/** + * 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 zEmailGetRoutingData = z.object({ + body: z.never().optional(), + path: z.object({ + email_id: z.string(), + }), + query: z.never().optional(), +}); + +/** + * Get received email response. + */ +export const zEmailGetRoutingResponse = zWorkersApiResponseCommon.and( + z.object({ + result: zEmailRoutingDetail.optional(), + }) +); + +export const zEmailListSendingData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional(), +}); + +/** + * List sent emails response. + */ +export const zEmailListSendingResponse = zWorkersApiResponseCommon.and( + z.object({ + result: z.array(zEmailSendingItem).optional(), + }) +); + +export const zEmailGetSendingData = z.object({ + body: z.never().optional(), + path: z.object({ + email_id: z.string(), + }), + query: z.never().optional(), +}); + +/** + * Get sent email response. + */ +export const zEmailGetSendingResponse = zWorkersApiResponseCommon.and( + z.object({ + result: zEmailSendingDetail.optional(), + }) +); + export const zWorkflowsListWorkflowsData = z.object({ body: z.never().optional(), path: z.never().optional(), diff --git a/packages/miniflare/src/workers/local-explorer/openapi.local.json b/packages/miniflare/src/workers/local-explorer/openapi.local.json index bdff2fa1e49..6b7315d6675 100644 --- a/packages/miniflare/src/workers/local-explorer/openapi.local.json +++ b/packages/miniflare/src/workers/local-explorer/openapi.local.json @@ -1282,6 +1282,269 @@ "tags": ["Local Explorer"] } }, + "/email/routing": { + "get": { + "description": "Lists emails received by the worker's email() handler during this dev session.", + "operationId": "email-list-routing", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "items": { + "$ref": "#/components/schemas/email_routing-item" + }, + "type": "array" + } + }, + "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"] + } + }, + "/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 other to/cc/bcc addresses appear only in the composed MIME headers.", + "operationId": "email-send-routing", + "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"] + } + }, + "/email/routing/{email_id}": { + "get": { + "description": "Returns the details of a received email.", + "operationId": "email-get-routing", + "parameters": [ + { + "in": "path", + "name": "email_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "$ref": "#/components/schemas/email_routing-detail" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Get received email response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Get received email failure." + } + }, + "summary": "Get Received Email", + "tags": ["Email"] + } + }, + "/email/sending": { + "get": { + "description": "Lists emails sent through send_email bindings during this dev session.", + "operationId": "email-list-sending", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "items": { + "$ref": "#/components/schemas/email_sending-item" + }, + "type": "array" + } + }, + "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"] + } + }, + "/email/sending/{email_id}": { + "get": { + "description": "Returns the details of a sent email.", + "operationId": "email-get-sending", + "parameters": [ + { + "in": "path", + "name": "email_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "$ref": "#/components/schemas/email_sending-detail" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Get sent email response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Get sent email failure." + } + }, + "summary": "Get Sent Email", + "tags": ["Email"] + } + }, "/workflows": { "get": { "description": "Returns the workflows configured for local development.", @@ -3165,6 +3428,13 @@ "$ref": "#/components/schemas/local-explorer_workflow-binding" }, "description": "Workflow bindings" + }, + "sendEmail": { + "type": "array", + "items": { + "$ref": "#/components/schemas/local-explorer_resource-binding" + }, + "description": "Send Email bindings" } } }, @@ -3400,6 +3670,464 @@ } }, "required": ["columns", "rows"] + }, + "email_handler-event": { + "type": "object", + "description": "One entry in the ordered lifecycle of what the handler did to the message. `forward`/`reply` events carry a `messageId` correlating with the matching `forwards`/`replies` entry.", + "properties": { + "type": { + "type": "string", + "enum": ["received", "forward", "reply", "reject", "unhandled"], + "description": "The kind of event." + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp of when the event occurred." + }, + "messageId": { + "type": "string", + "description": "Present on `forward`/`reply` events; correlates with the matching `forwards`/`replies` entry." + } + }, + "required": ["type", "timestamp"] + }, + "email_handler-forward": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "recipient": { + "type": "string", + "description": "Envelope recipient the message was forwarded to." + }, + "headers": { + "type": "array", + "description": "Headers added to the forwarded message, as [key, value] pairs.", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": ["messageId", "recipient", "headers"] + }, + "email_handler-reply": { + "type": "object", + "properties": { + "messageId": { + "type": "string" + }, + "sender": { + "type": "string", + "description": "Address the reply was sent from." + }, + "raw": { + "type": "string", + "description": "Raw MIME content of the reply. Omitted from the routing list; present on the detail response." + }, + "rawBase64": { + "type": "string", + "description": "Lossless base64 representation of the reply MIME." + } + }, + "required": ["messageId", "sender"] + }, + "email_routing-item": { + "type": "object", + "properties": { + "worker": { + "type": "string", + "description": "Worker whose email() handler processed the message, if known." + }, + "from": { + "type": "string", + "description": "Envelope MAIL FROM address." + }, + "to": { + "type": "string", + "description": "Envelope RCPT TO address." + }, + "subject": { + "type": "string" + }, + "messageId": { + "type": "string", + "description": "RFC Message-ID header value. Identifies the email in the store." + }, + "receivedAt": { + "type": "string" + }, + "rawSize": { + "type": "number" + }, + "outcome": { + "type": "string", + "enum": ["ok", "exception"], + "description": "Whether the handler ran to completion or threw." + }, + "rejectReason": { + "type": "string", + "description": "Reason passed to setReject(), if the handler rejected the message." + }, + "forwards": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email_handler-forward" + } + }, + "replies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email_handler-reply" + } + }, + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email_handler-event" + } + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email_attachment" + } + } + }, + "required": [ + "messageId", + "from", + "to", + "subject", + "receivedAt", + "rawSize", + "outcome", + "forwards", + "replies", + "events", + "attachments" + ] + }, + "email_routing-detail": { + "type": "object", + "properties": { + "worker": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "messageId": { + "type": "string", + "description": "RFC Message-ID header value. Identifies the email in the store." + }, + "receivedAt": { + "type": "string" + }, + "rawSize": { + "type": "number" + }, + "raw": { + "type": "string", + "description": "Raw MIME content of the received email." + }, + "rawBase64": { + "type": "string", + "description": "Lossless base64 representation of the received MIME." + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email_attachment" + }, + "description": "Metadata for attachments parsed out of the received message. The content itself is only available in `raw`." + }, + "outcome": { + "type": "string", + "enum": ["ok", "exception"], + "description": "Whether the handler ran to completion or threw." + }, + "rejectReason": { + "type": "string", + "description": "Reason passed to setReject(), if the handler rejected the message." + }, + "forwards": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email_handler-forward" + } + }, + "replies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email_handler-reply" + } + }, + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email_handler-event" + } + } + }, + "required": [ + "messageId", + "from", + "to", + "subject", + "receivedAt", + "rawSize", + "raw", + "attachments", + "outcome", + "forwards", + "replies", + "events" + ] + }, + "email_send-request": { + "type": "object", + "description": "Fields for composing a test email, mirroring MessageBuilder.", + "properties": { + "from": { + "type": "string", + "description": "Sender address." + }, + "to": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "Recipient addresses." + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "replyTo": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "text": { + "type": "string", + "description": "Plain text body." + }, + "html": { + "type": "string", + "description": "HTML body." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Custom headers to include on the message." + }, + "attachments": { + "type": "array", + "description": "Attachments to include on the message, mirroring the MessageBuilder `attachments` entries accepted by a send_email binding. Adding any attachment composes the message as multipart/mixed.", + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "Name the attachment is presented under." + }, + "type": { + "type": "string", + "description": "MIME type of the attachment, e.g. 'application/pdf'." + }, + "content": { + "type": "string", + "description": "Attachment content, base64-encoded. MessageBuilder takes raw bytes here, but this endpoint accepts JSON so the bytes must be base64-encoded." + }, + "contentId": { + "type": "string", + "description": "Content-ID for an inline attachment." + }, + "disposition": { + "type": "string", + "enum": ["inline", "attachment"], + "description": "How the attachment is presented. Defaults to 'attachment'." + } + }, + "required": ["filename", "type", "content"] + } + } + }, + "required": ["from", "to", "subject"] + }, + "email_attachment": { + "type": "object", + "description": "Metadata describing an attachment on a captured email, without its content.", + "properties": { + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "disposition": { + "type": "string", + "enum": ["inline", "attachment"] + }, + "size": { + "type": "number" + } + }, + "required": ["filename", "contentType", "disposition", "size"] + }, + "email_sending-item": { + "type": "object", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "replyTo": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "messageId": { + "type": "string", + "description": "RFC Message-ID header value. Identifies the email in the store." + }, + "sentAt": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email_attachment" + } + } + }, + "required": [ + "messageId", + "from", + "to", + "subject", + "sentAt", + "attachments" + ] + }, + "email_sending-detail": { + "type": "object", + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "cc": { + "type": "array", + "items": { + "type": "string" + } + }, + "bcc": { + "type": "array", + "items": { + "type": "string" + } + }, + "replyTo": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "messageId": { + "type": "string", + "description": "RFC Message-ID header value. Identifies the email in the store." + }, + "sentAt": { + "type": "string" + }, + "text": { + "type": "string" + }, + "html": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/email_attachment" + } + }, + "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": [ + "messageId", + "from", + "to", + "subject", + "sentAt", + "attachments" + ] } } } diff --git a/packages/miniflare/src/workers/local-explorer/route-names.ts b/packages/miniflare/src/workers/local-explorer/route-names.ts index 1aa3c0464ff..44e508995af 100644 --- a/packages/miniflare/src/workers/local-explorer/route-names.ts +++ b/packages/miniflare/src/workers/local-explorer/route-names.ts @@ -30,6 +30,11 @@ const ROUTE_PATTERNS: [RegExp, string][] = [ [/^\/workflows$/, "workflows.list"], [/^\/local\/observability\/query$/, "observability.query"], [/^\/local\/observability\/clear$/, "observability.clear"], + [/^\/email\/routing\/send$/, "email.routing.send"], + [/^\/email\/routing\/[^/]+$/, "email.routing.details"], + [/^\/email\/routing$/, "email.routing.list"], + [/^\/email\/sending\/[^/]+$/, "email.sending.details"], + [/^\/email\/sending$/, "email.sending.list"], [/^\/local\/workers$/, "local.workers"], ]; From 4a6ec700532a9e7244ac2fe2e33f3f022986a7b5 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:29:46 +0100 Subject: [PATCH 03/13] [miniflare] Isolate email artifact and temp-file handling --- packages/miniflare/src/index.ts | 135 +++++++++++-- .../miniflare/src/plugins/core/constants.ts | 6 + .../miniflare/src/plugins/core/temp-file.ts | 43 ++++ .../miniflare/src/plugins/email/artifacts.ts | 75 +++++++ packages/miniflare/src/plugins/email/index.ts | 189 ++++++++++++------ packages/miniflare/src/plugins/index.ts | 1 + .../miniflare/src/plugins/shared/index.ts | 3 +- 7 files changed, 382 insertions(+), 70 deletions(-) create mode 100644 packages/miniflare/src/plugins/core/temp-file.ts create mode 100644 packages/miniflare/src/plugins/email/artifacts.ts diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 7a6c2f4b98b..db1bc0afba4 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"; @@ -79,6 +78,9 @@ import { } from "./plugins/core"; import { InspectorProxyController } from "./plugins/core/inspector-proxy"; import { isModuleFallbackRequest } from "./plugins/core/module-fallback"; +import { writeTempFile } from "./plugins/core/temp-file"; +import { removeEmailTempFiles, writeEmailTempFile } from "./plugins/email"; +import { EmailArtifactManager } from "./plugins/email/artifacts"; import { HyperdriveProxyController } from "./plugins/hyperdrive/hyperdrive-proxy"; import { cfImageLocalFetcher, @@ -112,6 +114,7 @@ import { decodeErrorPayload, LogLevel, Mutex, + sanitisePath, SharedHeaders, SiteBindings, } from "./workers"; @@ -171,6 +174,16 @@ import type { Abortable } from "node:events"; import type { Duplex, Transform, Writable } from "node:stream"; import type { Dispatcher, Response as UndiciResponse } from "undici"; +const emailArtifactSchema = z.object({ + recordId: z.string(), + prefix: z.string(), + id: z.string(), + extension: z.string(), +}); +const emailArtifactsRequestSchema = z.object({ + artifacts: z.array(emailArtifactSchema).optional(), +}); + const DEFAULT_HOST = "127.0.0.1"; function getURLSafeHost(host: string) { return net.isIPv6(host) ? `[${host}]` : host; @@ -979,6 +992,7 @@ export class Miniflare { // Aborted when dispose() is called readonly #disposeController: AbortController; + readonly #emailArtifactManager = new EmailArtifactManager(); #loopbackServer?: StoppableServer; #loopbackHost?: string; readonly #webSocketServer: WebSocketServer; @@ -1270,6 +1284,102 @@ 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. Email callers pass `email=true` to opt into the + * email layout instead, which groups files by session and mirrors them into the + * project directory. + * + * @param url in format: /core/store-temp-file?prefix&extension[&email&id] + */ + async #handleLoopbackStoreTempFileRequest( + request: Request, + url: URL + ): Promise { + const extension = url.searchParams.get("extension") ?? "txt"; + const prefix = url.searchParams.get("prefix"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(extension)) { + return new Response("Invalid temporary-file extension", { status: 400 }); + } + if ( + prefix !== null && + (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(prefix) || + prefix === "." || + prefix === "..") + ) { + return new Response("Invalid temporary-file prefix", { status: 400 }); + } + + if (url.searchParams.get("email") === "true") { + // `id` is derived from a Message-ID, which Worker code controls, so it + // must be sanitised before being used as a path segment. + const rawId = url.searchParams.get("id"); + const id = rawId === null ? crypto.randomUUID() : sanitisePath(rawId); + const rawRecordId = url.searchParams.get("record") ?? rawId ?? id; + const recordId = sanitisePath(rawRecordId); + const artifact = { + recordId, + prefix: prefix ?? "files", + id, + extension, + }; + const filePath = await this.#emailArtifactManager.store( + artifact, + async () => { + return await writeEmailTempFile({ + resourceTmpPath: this.#sharedOpts.core.resourceTmpPath, + tmpPath: this.#tmpPath, + prefix: prefix ?? "files", + fileName: `${id}.${extension}`, + contents: Buffer.from(await request.arrayBuffer()), + }); + } + ); + if (filePath === null) { + return new Response("Email temporary file was evicted", { + status: 410, + }); + } + return new Response(filePath, { status: 200 }); + } + + const filePath = await writeTempFile({ + tmpPath: this.#tmpPath, + prefix, + extension, + contents: await request.text(), + }); + return new Response(filePath, { status: 200 }); + } + + async #handleLoopbackDeleteEmailTempFilesRequest( + request: Request + ): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response("Invalid email artifact request", { status: 400 }); + } + const parsed = emailArtifactsRequestSchema.safeParse(body); + if (!parsed.success) { + return new Response("Invalid email artifact request", { status: 400 }); + } + await this.#emailArtifactManager.delete( + parsed.data.artifacts ?? [], + async (artifacts) => { + await removeEmailTempFiles({ + resourceTmpPath: this.#sharedOpts.core.resourceTmpPath, + tmpPath: this.#tmpPath, + artifacts, + }); + } + ); + return new Response(null, { status: 204 }); + } + /** * Gets DO object IDs by checking filenames in the DO persistence directory. * @@ -1626,16 +1736,13 @@ export class Miniflare { const sessionIds = this.#browserProcesses.keys(); response = Response.json(Array.from(sessionIds)); } else if (url.pathname === "/core/store-temp-file") { - const prefix = url.searchParams.get("prefix"); - const folder = prefix ? `files/${prefix}` : "files"; - await mkdir(path.join(this.#tmpPath, folder), { recursive: true }); - const filePath = path.join( - this.#tmpPath, - folder, - `${crypto.randomUUID()}.${url.searchParams.get("extension") ?? "txt"}` - ); - await writeFile(filePath, await request.text()); - response = new Response(filePath, { status: 200 }); + response = await this.#handleLoopbackStoreTempFileRequest(request, url); + } else if ( + url.pathname === "/core/delete-email-temp-files" && + request.method === "POST" + ) { + response = + await this.#handleLoopbackDeleteEmailTempFilesRequest(request); } else if (url.pathname.startsWith("/core/do-storage/")) { response = await this.#handleLoopbackDOStorageRequest(url); } else if (url.pathname.startsWith("/core/workflow-storage/")) { @@ -2030,7 +2137,8 @@ export class Miniflare { // @ts-expect-error dynamic plugin dispatch: external plugins return // a different type than internal plugin options this.#getWorkerOptsForPlugin(key, workerOpts), - i + i, + workerName ); if (pluginBindings !== undefined) { for (const binding of pluginBindings) { @@ -2313,6 +2421,7 @@ export class Miniflare { ) ? `${RPC_PROXY_SERVICE_NAME}:${this.#workerOpts[0].core.name}` : getUserServiceName(this.#workerOpts[0].core.name), + fallbackWorkerPublicName: this.#workerOpts[0].core.name, tmpPath: this.#tmpPath, log: this.#log, proxyBindings, @@ -3349,6 +3458,7 @@ export class Miniflare { async dispose(): Promise { this.#disposeController.abort(); + this.#emailArtifactManager.dispose(); // The `ProxyServer` "heap" will be destroyed when `workerd` shuts down, // invalidating all existing native references. Mark all proxies as invalid. // Note `dispose()`ing the `#proxyClient` implicitly poison's proxies, but @@ -3385,6 +3495,7 @@ export class Miniflare { // `noServer: true` so it doesn't own an HTTP server, but connected // WebSocket clients still hold open sockets. this.#webSocketServer.close(); + await this.#emailArtifactManager.drain(); // Best-effort cleanup: on Windows, workerd may not release file handles // immediately after disposal, causing EBUSY errors. The temp directory // lives in os.tmpdir() so the OS will clean it up eventually. 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/temp-file.ts b/packages/miniflare/src/plugins/core/temp-file.ts new file mode 100644 index 00000000000..c70091a5598 --- /dev/null +++ b/packages/miniflare/src/plugins/core/temp-file.ts @@ -0,0 +1,43 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** + * Writes text content to a randomly named file under the instance temp + * directory, optionally grouped into a `prefix` subdirectory, and returns the + * path it was written to. + * + * This backs the `/core/store-temp-file` loopback endpoint. Callers that need + * the email layout instead should use `writeEmailTempFile`. + */ +export async function writeTempFile(options: { + tmpPath: string; + prefix: string | null; + extension: string; + contents: string; +}): Promise { + if ( + (options.prefix !== null && + (options.prefix === "." || + options.prefix === ".." || + options.prefix.includes("/") || + options.prefix.includes("\\"))) || + options.extension.includes("/") || + options.extension.includes("\\") + ) { + throw new Error("Invalid temporary-file path component"); + } + const folder = options.prefix ? `files/${options.prefix}` : "files"; + const directory = path.join(options.tmpPath, folder); + await mkdir(directory, { recursive: true }); + + const filePath = path.resolve( + directory, + `${crypto.randomUUID()}.${options.extension}` + ); + const root = path.resolve(directory); + if (!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/email/artifacts.ts b/packages/miniflare/src/plugins/email/artifacts.ts new file mode 100644 index 00000000000..88303af4834 --- /dev/null +++ b/packages/miniflare/src/plugins/email/artifacts.ts @@ -0,0 +1,75 @@ +import { sanitisePath } from "../../workers"; +import type { EmailArtifact } from "../../workers/email/storage"; + +function getArtifactKey(artifact: EmailArtifact): string { + return `${sanitisePath(artifact.recordId)}\0${sanitisePath(artifact.prefix)}\0${sanitisePath(artifact.id)}.${sanitisePath(artifact.extension)}`; +} + +function normaliseArtifact(artifact: EmailArtifact): EmailArtifact { + return { + recordId: sanitisePath(artifact.recordId), + prefix: sanitisePath(artifact.prefix), + id: sanitisePath(artifact.id), + extension: sanitisePath(artifact.extension), + }; +} + +export class EmailArtifactManager { + #tombstones = new Set(); + #operations = new Map>(); + + async store( + artifact: EmailArtifact, + write: () => Promise + ): Promise { + const key = getArtifactKey(artifact); + const previous = this.#operations.get(key); + const operation = (previous ?? Promise.resolve(null)) + .then( + () => {}, + () => {} + ) + .then(async () => { + if (this.#tombstones.delete(key)) { + return null; + } + return await write(); + }); + this.#operations.set(key, operation); + try { + return await operation; + } finally { + if (this.#operations.get(key) === operation) { + this.#operations.delete(key); + } + } + } + + async delete( + artifacts: EmailArtifact[], + remove: (artifacts: EmailArtifact[]) => Promise + ): Promise { + const normalisedArtifacts = artifacts.map(normaliseArtifact); + const keys = normalisedArtifacts.map(getArtifactKey); + for (const artifact of normalisedArtifacts) { + this.#tombstones.add(getArtifactKey(artifact)); + } + try { + await Promise.allSettled(keys.map((key) => this.#operations.get(key))); + await remove(normalisedArtifacts); + } finally { + for (const key of keys) { + this.#tombstones.delete(key); + } + } + } + + dispose(): void { + this.#tombstones.clear(); + } + + async drain(): Promise { + await Promise.allSettled(this.#operations.values()); + this.#operations.clear(); + } +} diff --git a/packages/miniflare/src/plugins/email/index.ts b/packages/miniflare/src/plugins/email/index.ts index dfaedfaf3c3..8163d9fded5 100644 --- a/packages/miniflare/src/plugins/email/index.ts +++ b/packages/miniflare/src/plugins/email/index.ts @@ -1,15 +1,19 @@ -import { mkdir } from "node:fs/promises"; +import { mkdir, unlink, writeFile } 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 { z } from "zod"; +import { CoreBindings, sanitisePath } from "../../workers"; +import { EMAIL_STORE_SERVICE_NAME } from "../core/constants"; import { buildRemoteProxyProps, getUserBindingServiceName, remoteProxyClientWorker, ProxyNodeBinding, + WORKER_BINDING_SERVICE_LOOPBACK, } from "../shared"; import type { Service, Worker_Binding } from "../../runtime"; +import type { EmailArtifact } from "../../workers/email/storage"; import type { Plugin, RemoteProxyConnectionString } from "../shared"; // Define the mutually exclusive schema @@ -42,12 +46,16 @@ export const EmailOptionsSchema = z.object({ .optional(), }); +export const EmailSharedOptionsSchema = z.object({ + // Mirrors the core shared option. When the local explorer is enabled, the + // email store service exists, so the send_email worker binds to it to capture + // sent emails. + unsafeLocalExplorer: z.boolean().optional(), +}); + 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[] { return Object.entries(bindings).map(([name, value]) => ({ @@ -84,6 +92,108 @@ function getEmailProjectSessionDirectory( return path.join(parentDir, path.basename(tmpPath)); } +function resolveContainedPath(directory: string, fileName: string): string { + const root = path.resolve(directory); + const resolved = path.resolve(root, fileName); + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new Error("Invalid email temporary-file path"); + } + return resolved; +} + +/** + * Resolves the directories email files are written to for a given `prefix` + * (e.g. `"email"`). + */ +export function getEmailFileDirectories( + resourceTmpPath: string | undefined, + tmpPath: string, + prefix: string +): { system: string; project: string | undefined } { + const projectSessionDir = getEmailProjectSessionDirectory( + resourceTmpPath, + tmpPath + ); + return { + system: path.join(tmpPath, EMAIL_PLUGIN_NAME, prefix), + project: + projectSessionDir !== undefined + ? path.join(projectSessionDir, prefix) + : undefined, + }; +} + +/** + * Writes email content to the directories resolved by + * {@link getEmailFileDirectories}. + * + * The file is always written to the instance temp directory, and mirrored into + * the project directory when one is configured so that captured messages + * outlive the dev session. Returns the path callers should surface, preferring + * the project copy since that is the one a user can navigate to. + */ +export async function writeEmailTempFile(options: { + defaultProjectTmpPath: string | undefined; + tmpPath: string; + prefix: string; + fileName: string; + contents: Buffer; +}): Promise { + if ( + options.prefix.length === 0 || + options.prefix === "." || + options.prefix === ".." || + options.prefix.includes("/") || + options.prefix.includes("\\") + ) { + throw new Error("Invalid email temporary-file prefix"); + } + const { system, project } = getEmailFileDirectories( + options.defaultProjectTmpPath, + options.tmpPath, + options.prefix + ); + + await mkdir(system, { recursive: true }); + const systemPath = resolveContainedPath(system, options.fileName); + await writeFile(systemPath, options.contents); + + if (project === undefined) { + return systemPath; + } + + await mkdir(project, { recursive: true }); + const projectPath = resolveContainedPath(project, options.fileName); + await writeFile(projectPath, options.contents); + return projectPath; +} + +export async function removeEmailTempFiles(options: { + defaultProjectTmpPath: string | undefined; + tmpPath: string; + artifacts: EmailArtifact[]; +}): Promise { + await Promise.all( + options.artifacts.map(async (artifact) => { + const { system, project } = getEmailFileDirectories( + options.defaultProjectTmpPath, + options.tmpPath, + artifact.prefix + ); + const fileName = `${sanitisePath(artifact.id)}.${artifact.extension}`; + const paths = [ + resolveContainedPath(system, fileName), + ...(project === undefined + ? [] + : [resolveContainedPath(project, fileName)]), + ]; + await Promise.all( + paths.map((filePath) => unlink(filePath).catch(() => {})) + ); + }) + ); +} + export function getEmailPathsToClean( resourceTmpPath: string | undefined, tmpPath: string @@ -99,8 +209,12 @@ export function getEmailPathsToClean( return { sessionDir, parentDir }; } -export const EMAIL_PLUGIN: Plugin = { +export const EMAIL_PLUGIN: Plugin< + typeof EmailOptionsSchema, + typeof EmailSharedOptionsSchema +> = { options: EmailOptionsSchema, + sharedOptions: EmailSharedOptionsSchema, bindingTypeDescription: "Email", getBindings(options): Worker_Binding[] { if (!options.email?.send_email) { @@ -139,51 +253,18 @@ 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.resourceTmpPath) { - const emailProjectSessionDirectory = getEmailProjectSessionDirectory( - args.resourceTmpPath, - args.tmpPath - ); - if (emailProjectSessionDirectory !== undefined) { - await mkdir(emailProjectSessionDirectory, { recursive: true }); - diskServices.push({ - location: "project", - bindingName: `${EMAIL_DISK_BINDING_NAME}_PROJECT`, - serviceName: `${EMAIL_DISK_SERVICE_NAME}:project`, - path: emailProjectSessionDirectory, - }); - } - } - - const services: Service[] = diskServices.map(({ serviceName, path }) => ({ - name: serviceName, - disk: { - path, - writable: true, - }, - })); + // The email store service only exists when the local explorer is enabled. + const emailStoreBinding: Worker_Binding[] = args.sharedOptions + .unsafeLocalExplorer + ? [ + { + name: CoreBindings.SERVICE_EMAIL_STORE, + service: { name: EMAIL_STORE_SERVICE_NAME }, + }, + ] + : []; + const services: Service[] = []; let hasRemote = false; for (const { name, remoteProxyConnectionString, ...config } of args.options .email?.send_email ?? []) { @@ -203,14 +284,8 @@ 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, ], }, }); diff --git a/packages/miniflare/src/plugins/index.ts b/packages/miniflare/src/plugins/index.ts index 3dca3daa67b..cc1a2295192 100644 --- a/packages/miniflare/src/plugins/index.ts +++ b/packages/miniflare/src/plugins/index.ts @@ -163,6 +163,7 @@ export type SharedOptions = z.input & z.input & z.input & z.input & + z.input & z.input & z.input & z.input & diff --git a/packages/miniflare/src/plugins/shared/index.ts b/packages/miniflare/src/plugins/shared/index.ts index a23ef3af187..0a687810684 100644 --- a/packages/miniflare/src/plugins/shared/index.ts +++ b/packages/miniflare/src/plugins/shared/index.ts @@ -93,7 +93,8 @@ export interface PluginBase< bindingTypeDescription?: string; getBindings( options: z.infer, - workerIndex: number + workerIndex: number, + workerName?: string ): Awaitable; getNodeBindings( options: z.infer From 05021caf0aecaec8439c54f22e897e94c00cd754 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:30:48 +0100 Subject: [PATCH 04/13] [miniflare] Add the local email store service --- .../miniflare/src/plugins/core/explorer.ts | 15 + packages/miniflare/src/plugins/core/index.ts | 15 + packages/miniflare/src/plugins/core/types.ts | 5 + packages/miniflare/src/plugins/email/store.ts | 55 ++ .../src/workers/email/email-store.ts | 514 ++++++++++++++++++ .../src/workers/email/email-store.worker.ts | 111 ++++ 6 files changed, 715 insertions(+) create mode 100644 packages/miniflare/src/plugins/email/store.ts create mode 100644 packages/miniflare/src/workers/email/email-store.ts create mode 100644 packages/miniflare/src/workers/email/email-store.worker.ts diff --git a/packages/miniflare/src/plugins/core/explorer.ts b/packages/miniflare/src/plugins/core/explorer.ts index 9ec9d2cc126..89e5f2de1ac 100644 --- a/packages/miniflare/src/plugins/core/explorer.ts +++ b/packages/miniflare/src/plugins/core/explorer.ts @@ -16,6 +16,7 @@ import { SERVICE_DEV_REGISTRY_PROXY, } from "../shared"; import { + EMAIL_STORE_SERVICE_NAME, getUserServiceName, LOCAL_EXPLORER_DISK, OBSERVABILITY_COLLECTOR_SERVICE_NAME, @@ -96,6 +97,12 @@ 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 }, + }, ]; // Only bind the observability collector when observability is enabled — @@ -337,6 +344,7 @@ export function constructExplorerWorkerOpts( r2: [], do: [], workflows: [], + sendEmail: [], }; for (const [bindingName, ns] of namespaceEntries( @@ -389,6 +397,13 @@ export function constructExplorerWorkerOpts( }); } + for (const sendEmail of workerOpts.email.email?.send_email ?? []) { + bindings.sendEmail.push({ + id: sendEmail.name, + bindingName: sendEmail.name, + }); + } + result[workerName] = bindings; } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 6f5907d1fab..641cbc9685e 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -29,6 +29,7 @@ import { getDurableObjectUniqueKey, normaliseDurableObject, } from "../do"; +import { getEmailStoreServices } from "../email/store"; import { IMAGES_PLUGIN_NAME } from "../images"; import { getR2PublicService, @@ -49,6 +50,7 @@ import { STREAM_PLUGIN_NAME } from "../stream"; import { CUSTOM_SERVICE_KNOWN_OUTBOUND, CustomServiceKind, + EMAIL_STORE_SERVICE_NAME, getBuiltinServiceName, getCustomFetchServiceName, getCustomNodeServiceName, @@ -947,6 +949,7 @@ export interface GlobalServicesOptions { sharedOptions: z.infer; allWorkerRoutes: Map; fallbackWorkerName: string | undefined; + fallbackWorkerPublicName: string | undefined; tmpPath: string; log: Log; /** All user workerd-native bindings, used for Miniflare's magic proxy and the local explorer worker */ @@ -962,6 +965,7 @@ export function getGlobalServices({ sharedOptions, allWorkerRoutes, fallbackWorkerName, + fallbackWorkerPublicName, tmpPath, log, proxyBindings, @@ -991,6 +995,10 @@ export function getGlobalServices({ name: CoreBindings.SERVICE_USER_FALLBACK, service: { name: fallbackWorkerName }, }, + { + name: CoreBindings.TEXT_FALLBACK_WORKER_NAME, + json: JSON.stringify(fallbackWorkerPublicName ?? ""), + }, ...workerNames.map((name) => ({ name: CoreBindings.SERVICE_USER_ROUTE_PREFIX + name, service: { name: getUserServiceName(name) }, @@ -1026,6 +1034,12 @@ export function getGlobalServices({ name: SERVICE_LOCAL_EXPLORER, }, }); + // The entry worker runs the receiving `email()` path (see handleEmail), + // which captures received emails into the store over RPC. + serviceEntryBindings.push({ + name: CoreBindings.SERVICE_EMAIL_STORE, + service: { name: EMAIL_STORE_SERVICE_NAME }, + }); } const streamServiceEnabled = allWorkerOpts?.some( (worker) => @@ -1173,6 +1187,7 @@ export function getGlobalServices({ observabilityEnabled: sharedOptions.unsafeObservability === true, }) ); + services.push(...getEmailStoreServices(tmpPath)); } // Register the trace collector service. It's attached to each user worker's diff --git a/packages/miniflare/src/plugins/core/types.ts b/packages/miniflare/src/plugins/core/types.ts index f85c0d5fdeb..e6c91c29937 100644 --- a/packages/miniflare/src/plugins/core/types.ts +++ b/packages/miniflare/src/plugins/core/types.ts @@ -51,6 +51,11 @@ export type WorkerResourceBindings = { className: string; scriptName: string; }[]; + sendEmail: { + /** id = binding name */ + id: string; + bindingName: string; + }[]; }; export type ExplorerWorkerOpts = Record; diff --git a/packages/miniflare/src/plugins/email/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/email/email-store.ts b/packages/miniflare/src/workers/email/email-store.ts new file mode 100644 index 00000000000..08b4c9adc43 --- /dev/null +++ b/packages/miniflare/src/workers/email/email-store.ts @@ -0,0 +1,514 @@ +/** + * The local email store: a SQLite-backed Durable Object holding the emails + * captured during a dev session. The `send_email` binding and the `email()` + * receiving path write to it, and the Local Explorer's Email API reads from it, + * all over workerd-internal RPC. Because every hop stays inside workerd, capture + * never depends on the Node host loopback server — so it works even when a + * binding method is invoked through the synchronous platform proxy + * (`getPlatformProxy()` / `getBindings()`), which blocks the Node main thread. + * + * Records are stored as full JSON blobs alongside compact summaries keyed by an + * autoincrement `seq` for newest-first ordering. Lists read summaries while + * detail lookups read the full record. 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 { + zEmailHandlerForward, + zEmailHandlerReply, + zEmailRoutingDetail, + zEmailSendingDetail, +} from "../local-explorer/generated/zod.gen"; +import { base64ToBytes, bytesToBase64 } from "./capture"; +import { messageIdToStorageId } from "./message-id"; +import type { + EmailArtifact, + StoredRoutingEmailMetadata, + StoredRoutingEmailRecord, + StoredRoutingEmail, + StoredRoutingEmailSummary, + StoredSendingEmail, + StoredSendingEmailMetadata, + StoredSendingEmailSummary, +} from "./storage"; + +export type { StoredRoutingEmail, StoredSendingEmail }; + +function materialiseReceivedEmail( + email: StoredRoutingEmailRecord +): StoredRoutingEmail { + return { + ...email, + raw: new TextDecoder().decode(base64ToBytes(email.rawBase64)), + replies: email.replies.map((reply) => ({ + ...reply, + raw: + reply.raw ?? + (reply.rawBase64 === undefined + ? "" + : new TextDecoder().decode(base64ToBytes(reply.rawBase64))), + })), + }; +} + +/** + * Decodes a sent record's `raw` from its `rawBase64` when it was stored + * base64-only (the chunked send path stores no decoded `raw`). Records that + * already carry `raw`, or have no raw body at all, are returned unchanged. + */ +function materialiseSentEmail(email: StoredSendingEmail): StoredSendingEmail { + if (email.raw !== undefined || email.rawBase64 === undefined) { + return email; + } + return { + ...email, + raw: new TextDecoder().decode(base64ToBytes(email.rawBase64)), + }; +} + +/** + * Upper bound on the number of received/sent emails retained per dev session. + * Adjust if the explorer needs a deeper history. Could be attached to a binding + * in the future. + */ +const MAX_STORED_EMAILS = 200; + +const SCHEMA = [ + `CREATE TABLE IF NOT EXISTS received ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL, + data TEXT NOT NULL, + summary TEXT + )`, + `CREATE INDEX IF NOT EXISTS received_by_id ON received (id)`, + `CREATE TABLE IF NOT EXISTS sent ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL, + data TEXT NOT NULL, + summary TEXT + )`, + `CREATE INDEX IF NOT EXISTS sent_by_id ON sent (id)`, +]; + +const STATEMENTS = { + received: { + insert: `INSERT INTO received (id, data, summary) VALUES (?, ?, ?)`, + evict: `DELETE FROM received WHERE seq NOT IN (SELECT seq FROM received ORDER BY seq DESC LIMIT ?)`, + list: `SELECT summary FROM received ORDER BY seq DESC`, + find: `SELECT data FROM received WHERE id = ? ORDER BY seq DESC LIMIT 1`, + evicted: `SELECT data FROM received WHERE seq NOT IN (SELECT seq FROM received ORDER BY seq DESC LIMIT ?)`, + hasId: `SELECT 1 FROM received WHERE id = ? LIMIT 1`, + clear: `DELETE FROM received`, + }, + sent: { + insert: `INSERT INTO sent (id, data, summary) VALUES (?, ?, ?)`, + evict: `DELETE FROM sent WHERE seq NOT IN (SELECT seq FROM sent ORDER BY seq DESC LIMIT ?)`, + list: `SELECT summary FROM sent ORDER BY seq DESC`, + find: `SELECT data FROM sent WHERE id = ? ORDER BY seq DESC LIMIT 1`, + evicted: `SELECT data FROM sent WHERE seq NOT IN (SELECT seq FROM sent ORDER BY seq DESC LIMIT ?)`, + hasId: `SELECT 1 FROM sent WHERE id = ? LIMIT 1`, + clear: `DELETE FROM sent`, + }, +} as const; + +const MIGRATION_STATEMENTS = { + received: { + tableInfo: "PRAGMA table_info(received)", + addSummary: "ALTER TABLE received ADD COLUMN summary TEXT", + rows: "SELECT seq, data, summary FROM received WHERE summary IS NULL", + update: "UPDATE received SET summary = ? WHERE seq = ?", + }, + sent: { + tableInfo: "PRAGMA table_info(sent)", + addSummary: "ALTER TABLE sent ADD COLUMN summary TEXT", + rows: "SELECT seq, data, summary FROM sent WHERE summary IS NULL", + update: "UPDATE sent SET summary = ? WHERE seq = ?", + }, +} as const; + +const zStoredEmailForward = zEmailHandlerForward.extend({ + headers: z.array(z.tuple([z.string(), z.string()])), +}); +const zStoredEmailReply = zEmailHandlerReply.extend({ raw: z.string() }); +const zStoredEmailEvent = z.discriminatedUnion("type", [ + z.object({ + type: z.enum(["forward", "reply"]), + timestamp: z.string(), + messageId: z.string(), + }), + z.object({ + type: z.enum(["received", "reject", "unhandled"]), + timestamp: z.string(), + }), +]); +export const zStoredRoutingEmail = zEmailRoutingDetail.extend({ + forwards: z.array(zStoredEmailForward), + replies: z.array(zStoredEmailReply), + events: z.array(zStoredEmailEvent), +}); +const zStoredRoutingEmailRecord = zStoredRoutingEmail + .omit({ raw: true, replies: true }) + .extend({ + rawBase64: z.string(), + // Reply raw bodies are stored base64-only (streamed in chunks); the + // decoded `raw` is materialised on read. + replies: z.array(zEmailHandlerReply), + }); +export const zStoredRoutingEmailSummary = zStoredRoutingEmail + .omit({ raw: true, rawBase64: true, replies: true }) + .extend({ + replies: z.array(zStoredEmailReply.omit({ raw: true, rawBase64: true })), + }); + +type EmailTable = keyof typeof STATEMENTS; + +function normaliseReceivedRecord( + email: StoredRoutingEmail | StoredRoutingEmailRecord +): StoredRoutingEmailRecord { + if ("raw" in email) { + const { raw: _raw, ...record } = email; + return { + ...record, + rawBase64: + email.rawBase64 ?? bytesToBase64(new TextEncoder().encode(email.raw)), + }; + } + return email; +} + +function parseReceivedRecord(data: unknown): StoredRoutingEmailRecord { + const record = zStoredRoutingEmailRecord.safeParse(data); + if (record.success) { + return record.data; + } + return normaliseReceivedRecord(zStoredRoutingEmail.parse(data)); +} + +function getReceivedSummary( + email: StoredRoutingEmailRecord +): StoredRoutingEmailSummary { + const { rawBase64: _rawBase64, replies, ...rest } = email; + return { + ...rest, + replies: replies.map( + ({ raw: _replyRaw, rawBase64: _replyRawBase64, ...reply }) => reply + ), + }; +} + +function getSentSummary(email: StoredSendingEmail): StoredSendingEmailSummary { + const { + text: _text, + html: _html, + raw: _raw, + rawBase64: _rawBase64, + ...summary + } = email; + return summary; +} + +function getAttachmentExtension(filename: string): string { + const extension = filename.match(/\.([^.]+)$/u)?.[1]; + return extension !== undefined && + /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(extension) + ? extension + : "bin"; +} + +function getArtifacts( + table: EmailTable, + email: StoredRoutingEmailRecord | StoredSendingEmail +): EmailArtifact[] { + const recordId = messageIdToStorageId(email.messageId); + if (table === "received") { + if (!("receivedAt" in email)) { + throw new TypeError("Received email record does not match its table"); + } + return email.replies.map((reply) => ({ + recordId, + prefix: "reply", + id: messageIdToStorageId(reply.messageId), + extension: "eml", + })); + } + + if (!("sentAt" in email)) { + throw new TypeError("Sent email record does not match its table"); + } + const sentEmail = email; + const artifacts: EmailArtifact[] = []; + if (sentEmail.raw !== undefined) { + artifacts.push({ + recordId, + prefix: "email", + id: recordId, + extension: "eml", + }); + } else { + if (sentEmail.text !== undefined) { + artifacts.push({ + recordId, + prefix: "email-text", + id: recordId, + extension: "txt", + }); + } + if (sentEmail.html !== undefined) { + artifacts.push({ + recordId, + prefix: "email-html", + id: recordId, + extension: "html", + }); + } + for (const [index, attachment] of sentEmail.attachments.entries()) { + artifacts.push({ + recordId, + prefix: "email-attachment", + id: `${recordId}-${index + 1}`, + extension: getAttachmentExtension(attachment.filename), + }); + } + } + return artifacts; +} + +export class EmailStore extends DurableObject { + private sql = this.ctx.storage.sql; + #pendingReceived = new Map< + string, + { + email: StoredRoutingEmailMetadata; + chunks: string[]; + replyChunks: Map; + } + >(); + #pendingSent = new Map< + string, + { email: StoredSendingEmailMetadata; chunks: string[] } + >(); + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env as never); + this.ctx.blockConcurrencyWhile(async () => { + for (const stmt of SCHEMA) { + this.sql.exec(stmt); + } + for (const table of ["received", "sent"] as const) { + const hasSummaryColumn = this.sql + .exec<{ name: string }>(MIGRATION_STATEMENTS[table].tableInfo) + .toArray() + .some(({ name }) => name === "summary"); + if (!hasSummaryColumn) { + this.sql.exec(MIGRATION_STATEMENTS[table].addSummary); + } + } + for (const table of ["received", "sent"] as const) { + const rows = this.sql + .exec<{ + seq: number; + data: string; + summary: string | null; + }>(MIGRATION_STATEMENTS[table].rows) + .toArray(); + for (const row of rows) { + const summary = + table === "received" + ? getReceivedSummary(parseReceivedRecord(JSON.parse(row.data))) + : getSentSummary(zEmailSendingDetail.parse(JSON.parse(row.data))); + this.sql.exec( + MIGRATION_STATEMENTS[table].update, + JSON.stringify(summary), + row.seq + ); + } + } + }); + } + + /** Inserts a record and evicts the oldest rows beyond `MAX_STORED_EMAILS`. */ + #insert( + table: EmailTable, + id: string, + data: unknown, + summary: unknown + ): EmailArtifact[] { + this.sql.exec( + STATEMENTS[table].insert, + id, + JSON.stringify(data), + JSON.stringify(summary) + ); + const evicted = this.sql + .exec<{ data: string }>(STATEMENTS[table].evicted, MAX_STORED_EMAILS) + .toArray() + .flatMap(({ data: evictedData }) => + getArtifacts( + table, + table === "received" + ? parseReceivedRecord(JSON.parse(evictedData)) + : zEmailSendingDetail.parse(JSON.parse(evictedData)) + ) + ); + this.sql.exec(STATEMENTS[table].evict, MAX_STORED_EMAILS); + const retainedRecordIds = new Set(); + for (const recordId of new Set( + evicted.map((artifact) => artifact.recordId) + )) { + if ( + this.sql.exec(STATEMENTS[table].hasId, recordId).toArray().length > 0 + ) { + retainedRecordIds.add(recordId); + } + } + return evicted.filter( + (artifact) => !retainedRecordIds.has(artifact.recordId) + ); + } + + /** Newest-first list of summary records from a table. */ + #list(table: EmailTable): T[] { + return this.sql + .exec<{ summary: string | null }>(STATEMENTS[table].list) + .toArray() + .filter((row): row is { summary: string } => row.summary !== null) + .map((row) => JSON.parse(row.summary) as T); + } + + /** Most recently stored full record with the given message ID. */ + #find(table: EmailTable, id: string): T | undefined { + const row = this.sql + .exec<{ data: string }>(STATEMENTS[table].find, id) + .toArray()[0]; + return row === undefined ? undefined : (JSON.parse(row.data) as T); + } + + storeReceived(email: StoredRoutingEmailRecord): EmailArtifact[] { + return this.#insert( + "received", + messageIdToStorageId(email.messageId), + email, + getReceivedSummary(email) + ); + } + + beginReceived(email: StoredRoutingEmailMetadata): void { + this.#pendingReceived.set(messageIdToStorageId(email.messageId), { + email, + chunks: [], + replyChunks: new Map(), + }); + } + + appendReceivedRaw(id: string, chunk: string): void { + const pending = this.#pendingReceived.get(id); + if (pending === undefined) { + throw new Error(`No pending received email for ${id}`); + } + pending.chunks.push(chunk); + } + + appendReplyRaw(id: string, replyIndex: number, chunk: string): void { + const pending = this.#pendingReceived.get(id); + if (pending === undefined) { + throw new Error(`No pending received email for ${id}`); + } + let chunks = pending.replyChunks.get(replyIndex); + if (chunks === undefined) { + chunks = []; + pending.replyChunks.set(replyIndex, chunks); + } + chunks.push(chunk); + } + + async finishReceived(id: string): Promise { + const pending = this.#pendingReceived.get(id); + if (pending === undefined) { + throw new Error(`No pending received email for ${id}`); + } + this.#pendingReceived.delete(id); + return this.storeReceived({ + ...pending.email, + rawBase64: pending.chunks.join(""), + replies: pending.email.replies.map((reply, index) => { + const chunks = pending.replyChunks.get(index); + return chunks === undefined + ? reply + : { ...reply, rawBase64: chunks.join("") }; + }), + }); + } + + discardReceived(id: string): void { + this.#pendingReceived.delete(id); + } + + findReceived(id: string): StoredRoutingEmail | undefined { + const row = this.sql + .exec<{ data: string }>(STATEMENTS.received.find, id) + .toArray()[0]; + return row === undefined + ? undefined + : materialiseReceivedEmail(parseReceivedRecord(JSON.parse(row.data))); + } + + listReceived(): StoredRoutingEmailSummary[] { + return this.#list("received"); + } + + storeSent(email: StoredSendingEmail): EmailArtifact[] { + return this.#insert( + "sent", + messageIdToStorageId(email.messageId), + email, + getSentSummary(email) + ); + } + + beginSent(email: StoredSendingEmailMetadata): void { + this.#pendingSent.set(messageIdToStorageId(email.messageId), { + email, + chunks: [], + }); + } + + appendSentRaw(id: string, chunk: string): void { + const pending = this.#pendingSent.get(id); + if (pending === undefined) { + throw new Error(`No pending sent email for ${id}`); + } + pending.chunks.push(chunk); + } + + async finishSent(id: string): Promise { + const pending = this.#pendingSent.get(id); + if (pending === undefined) { + throw new Error(`No pending sent email for ${id}`); + } + this.#pendingSent.delete(id); + return this.storeSent({ + ...pending.email, + rawBase64: pending.chunks.join(""), + }); + } + + discardSent(id: string): void { + this.#pendingSent.delete(id); + } + + findSent(id: string): StoredSendingEmail | undefined { + const email = this.#find("sent", id); + return email === undefined ? undefined : materialiseSentEmail(email); + } + + listSent(): StoredSendingEmailSummary[] { + return this.#list("sent"); + } + + clear(): void { + this.sql.exec(STATEMENTS.received.clear); + this.sql.exec(STATEMENTS.sent.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..175e9cdf916 --- /dev/null +++ b/packages/miniflare/src/workers/email/email-store.worker.ts @@ -0,0 +1,111 @@ +/** + * 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 { + EmailArtifact, + StoredRoutingEmail, + StoredRoutingEmailMetadata, + StoredRoutingEmailRecord, + StoredRoutingEmailSummary, + StoredSendingEmail, + StoredSendingEmailMetadata, + StoredSendingEmailSummary, +} from "./storage"; + +// Re-export so the embedded worker registers the DO class under its namespace. +export { EmailStore }; + +interface Env { + EMAIL_STORE_DO: DurableObjectNamespace; +} + +export default class EmailStoreHost extends WorkerEntrypoint { + #store() { + return this.env.EMAIL_STORE_DO.get( + this.env.EMAIL_STORE_DO.idFromName("singleton") + ); + } + + async storeReceived( + email: StoredRoutingEmailRecord + ): Promise { + return await this.#store().storeReceived(email); + } + + async beginReceived(email: StoredRoutingEmailMetadata): Promise { + await this.#store().beginReceived(email); + } + + async appendReceivedRaw(id: string, chunk: string): Promise { + await this.#store().appendReceivedRaw(id, chunk); + } + + async appendReplyRaw( + id: string, + replyIndex: number, + chunk: string + ): Promise { + await this.#store().appendReplyRaw(id, replyIndex, chunk); + } + + async finishReceived(id: string): Promise { + return await this.#store().finishReceived(id); + } + + async discardReceived(id: string): Promise { + await this.#store().discardReceived(id); + } + + async findReceived(id: string): Promise { + const email = await this.#store().findReceived(id); + return email === undefined ? undefined : zStoredRoutingEmail.parse(email); + } + + async listReceived(): Promise { + return zStoredRoutingEmailSummary + .array() + .parse(await this.#store().listReceived()); + } + + async storeSent(email: StoredSendingEmail): Promise { + return await this.#store().storeSent(email); + } + + async beginSent(email: StoredSendingEmailMetadata): Promise { + await this.#store().beginSent(email); + } + + async appendSentRaw(id: string, chunk: string): Promise { + await this.#store().appendSentRaw(id, chunk); + } + + async finishSent(id: string): Promise { + return await this.#store().finishSent(id); + } + + async discardSent(id: string): Promise { + await this.#store().discardSent(id); + } + + async findSent(id: string): Promise { + return await this.#store().findSent(id); + } + + async listSent(): Promise { + return await this.#store().listSent(); + } + + async clear(): Promise { + await this.#store().clear(); + } +} From f21943b4f3806b5a5beb8efecb2a538b1365dc9a Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:31:02 +0100 Subject: [PATCH 05/13] [miniflare] Capture received email handler results --- packages/miniflare/src/workers/core/email.ts | 598 ++++++++++++------ .../src/workers/core/entry.worker.ts | 14 +- 2 files changed, 419 insertions(+), 193 deletions(-) diff --git a/packages/miniflare/src/workers/core/email.ts b/packages/miniflare/src/workers/core/email.ts index e6d7c9a31de..752fd5a1418 100644 --- a/packages/miniflare/src/workers/core/email.ts +++ b/packages/miniflare/src/workers/core/email.ts @@ -2,9 +2,21 @@ import assert from "node:assert"; import { $, blue, red, reset, yellow } from "kleur/colors"; import { LogLevel, SharedHeaders } from "miniflare:shared"; import PostalMime from "postal-mime"; +import { MAX_LOCAL_EMAIL_BYTES, truncateRawForCapture } from "../email/capture"; +import { messageIdToStorageId, synthesizeMessageId } from "../email/message-id"; import { isEmailReplyable, validateReply } from "../email/validate"; import { CoreBindings } from "./constants"; import type { MiniflareEmailMessage } from "../email/email.worker"; +import type { + EmailArtifact, + EmailHandlerEvent, + EmailHandlerForward, + EmailHandlerReply, + EmailStoreService, + StoredRoutingEmail, + StoredRoutingEmailMetadata, + StoredRoutingEmailRecord, +} from "../email/storage"; import type { ForwardableEmailMessage } from "@cloudflare/workers-types/experimental"; import type { Email } from "postal-mime"; @@ -14,42 +26,59 @@ $.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"') + ); +} + +async function removeEmailArtifacts( + loopback: Fetcher, + artifacts: EmailArtifact[] +): Promise { + if (artifacts.length === 0) return; + const response = await loopback.fetch( + "http://localhost/core/delete-email-temp-files", + { + method: "POST", + body: JSON.stringify({ artifacts }), + } + ); + if (!response.ok) { + throw new Error( + `could not delete email temporary files: ${await response.text()}` + ); + } +} + export async function handleEmail( params: URLSearchParams, request: Request, service: Fetcher, + workerName: string | undefined, env: Env, ctx: ExecutionContext ): Promise { - const events: Array< - | { - type: "forward" | "reply"; - timestamp: string; - messageId: string; - } - | { - type: "reject"; - timestamp: string; - } - > = []; - const forwards: Array<{ - messageId: string; - recipient: string; - headers: [string, string][]; - }> = []; - const replies: Array<{ - messageId: string; - sender: string; - raw: string; - }> = []; + const events: EmailHandlerEvent[] = []; + const forwards: EmailHandlerForward[] = []; + const replies: EmailHandlerReply[] = []; // Turn an HTTP request into an EmailMessage, using: // - `from` and `to` from the URL @@ -76,24 +105,10 @@ export async function handleEmail( const incomingEmailRaw = new Uint8Array(await request.arrayBuffer()); - // Email Routing does not support messages bigger than 25Mib: https://developers.cloudflare.com/email-routing/limits/#message-size - // In practice, local dev only supports 1MB, since it uses a JSRPC transport. - if (incomingEmailRaw.byteLength > 25 * 1024 * 1024) { - return new Response( - "Email message size is bigger than the production size limit of 25MiB. Local development has a lower limit of 1Mib.", - { - status: 400, - } - ); - } - if (incomingEmailRaw.byteLength > 1024 * 1024) { - 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, - } - ); - } + // Delivery to the user Worker uses the full message regardless of size — the + // capture feature must never change what `email()` receives. The captured + // copy is truncated to `MAX_LOCAL_EMAIL_BYTES` (see `storeReceivedEmail`) so + // the workerd-internal RPC to the store stays under its ~1 MiB argument cap. let parsedIncomingEmail: Email; try { @@ -121,7 +136,7 @@ export async function handleEmail( { method: "POST", headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString() }, - body: `${yellow("Provided MAIL FROM address doesn't match the email message's \"From\" header")}:\n MAIL FROM: ${from}\n "From" header: ${parsedIncomingEmail.from.address}`, + body: `${yellow("Provided MAIL FROM address doesn't match the email message's \"From\" header")}:\n MAIL FROM: ${escapeLogValue(from)}\n "From" header: ${escapeLogValue(parsedIncomingEmail.from.address ?? "")}`, } ); } @@ -132,7 +147,7 @@ export async function handleEmail( { method: "POST", headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString() }, - body: `${yellow('Provided RCPT TO address doesn\'t match any "To" header in the email message')}:\n RCPT TO: ${to}\n "To" header: ${parsedIncomingEmail.to?.map((addr) => addr.address).join(", ")}`, + body: `${yellow('Provided RCPT TO address doesn\'t match any "To" header in the email message')}:\n RCPT TO: ${escapeLogValue(to)}\n "To" header: ${escapeLogValue(parsedIncomingEmail.to?.map((addr) => addr.address).join(", ") ?? "")}`, } ); } @@ -141,177 +156,380 @@ export async function handleEmail( parsedIncomingEmail.headers.map((header) => [header.key, header.value]) ); + let outcome: "ok" | "exception" = "ok"; // Propogate `.setReject()` reasons to the caller let rejectReason: string | undefined = undefined; + events.push({ type: "received", timestamp: new Date().toISOString() }); - // @ts-expect-error .email is not in the `Fetcher` but it's a valid RPC call. - const emailEvent = service.email( - // Construct a ForwardableEmailMessage-like object. We need - // - ForwardableEmailMessage to be able to be passed across JSRPC (to support e.g. userWorker.email(ForwardableEmailMessage)) - // - ForwardableEmailMessage properties to be synchronously available (to match production). This rules out a class extending `RpcStub` - // However, unlike EmailMessage (see email.worker.ts) it doesn't need to be user-constructable, and so we can just use an object with `satisfies` - { - from, - to, - raw: clonedRequest.body, - rawSize: incomingEmailRaw.byteLength, - headers: incomingEmailHeaders, - setReject: (reason: string): void => { - ctx.waitUntil( - env[CoreBindings.SERVICE_LOOPBACK].fetch( + // Capture this email for the local explorer "Routing" interface. Only the + // first `MAX_LOCAL_EMAIL_BYTES` are captured (the full message is still + // delivered to the user Worker); larger bodies are truncated so the store + // RPC stays under its argument cap. `rawSize` keeps the original size. + const capturedRaw = truncateRawForCapture(incomingEmailRaw); + const rawBase64 = capturedRaw.rawBase64; + if (capturedRaw.truncated) { + ctx.waitUntil( + env[CoreBindings.SERVICE_LOOPBACK] + .fetch("http://localhost/core/log", { + method: "POST", + headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString() }, + body: `Received email exceeds the ${MAX_LOCAL_EMAIL_BYTES}-byte local capture limit; the email was delivered, but only the first ${MAX_LOCAL_EMAIL_BYTES} bytes are shown in the Local Explorer.`, + }) + .catch(() => undefined) + ); + } + const storedEmail: StoredRoutingEmail = { + worker: workerName, + from, + to, + subject: parsedIncomingEmail.subject ?? "(no subject)", + messageId: parsedIncomingEmail.messageId, + receivedAt: new Date().toISOString(), + rawSize: incomingEmailRaw.byteLength, + raw: capturedRaw.raw, + rawBase64, + attachments: (parsedIncomingEmail.attachments ?? []).map((attachment) => ({ + filename: attachment.filename ?? "attachment", + contentType: attachment.mimeType ?? "application/octet-stream", + disposition: + attachment.disposition === "inline" ? "inline" : "attachment", + size: + typeof attachment.content === "string" + ? new TextEncoder().encode(attachment.content).byteLength + : attachment.content.byteLength, + })), + outcome, + forwards, + replies, + events, + }; + // Store exactly once per request, no matter which exit path runs. The result + // fields are refreshed from the (possibly mutated) locals on each attempt. + let stored = false; + async function storeReceivedEmail(): Promise { + if (stored) { + return; + } + stored = true; + storedEmail.outcome = outcome; + storedEmail.rejectReason = rejectReason; + try { + const { + raw: _raw, + rawBase64: _rawBase64, + ...emailMetadata + } = storedEmail; + const store = env[CoreBindings.SERVICE_EMAIL_STORE]; + let artifacts: EmailArtifact[] | undefined; + if (store !== undefined) { + const recordId = messageIdToStorageId(storedEmail.messageId); + // Stream when either the received body or any reply body would + // exceed workerd's RPC argument limit if sent in a single call. + const needsStreaming = + rawBase64.length > 64 * 1024 || + emailMetadata.replies.some( + (reply) => (reply.rawBase64?.length ?? 0) > 64 * 1024 + ); + if (needsStreaming) { + // Reply bodies are streamed separately, so drop them from the + // prelude to keep it under the RPC argument limit. + const metadata: StoredRoutingEmailMetadata = { + ...emailMetadata, + replies: emailMetadata.replies.map( + ({ raw: _replyRaw, rawBase64: _replyRawBase64, ...reply }) => + reply + ), + }; + await store.beginReceived(metadata); + try { + for ( + let offset = 0; + offset < rawBase64.length; + offset += 64 * 1024 + ) { + await store.appendReceivedRaw( + recordId, + rawBase64.slice(offset, offset + 64 * 1024) + ); + } + for (const [replyIndex, reply] of emailMetadata.replies.entries()) { + const replyRawBase64 = reply.rawBase64; + if (replyRawBase64 === undefined) { + continue; + } + for ( + let offset = 0; + offset < replyRawBase64.length; + offset += 64 * 1024 + ) { + await store.appendReplyRaw( + recordId, + replyIndex, + replyRawBase64.slice(offset, offset + 64 * 1024) + ); + } + } + artifacts = await store.finishReceived(recordId); + } catch (error) { + await store.discardReceived(recordId).catch(() => undefined); + throw error; + } + } else { + const record: StoredRoutingEmailRecord = { + ...emailMetadata, + rawBase64, + }; + artifacts = await store.storeReceived(record); + } + } + if (artifacts !== undefined) { + try { + await removeEmailArtifacts( + env[CoreBindings.SERVICE_LOOPBACK], + artifacts + ); + } catch { + ctx.waitUntil( + env[CoreBindings.SERVICE_LOOPBACK] + .fetch("http://localhost/core/log", { + method: "POST", + headers: { + [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString(), + }, + body: "Failed to clean up evicted email artifacts.", + }) + .catch(() => undefined) + ); + } + } + } catch { + // Ignore storage failures - they must not affect email handling. + stored = false; + } + } + + try { + // @ts-expect-error .email is not in the `Fetcher` but it's a valid RPC call. + const emailEvent = service.email( + // Construct a ForwardableEmailMessage-like object. We need + // - ForwardableEmailMessage to be able to be passed across JSRPC (to support e.g. userWorker.email(ForwardableEmailMessage)) + // - ForwardableEmailMessage properties to be synchronously available (to match production). This rules out a class extending `RpcStub` + // However, unlike EmailMessage (see email.worker.ts) it doesn't need to be user-constructable, and so we can just use an object with `satisfies` + { + from, + to, + raw: clonedRequest.body, + rawSize: incomingEmailRaw.byteLength, + headers: incomingEmailHeaders, + setReject: (reason: string): void => { + ctx.waitUntil( + env[CoreBindings.SERVICE_LOOPBACK].fetch( + "http://localhost/core/log", + { + method: "POST", + headers: { + [SharedHeaders.LOG_LEVEL]: LogLevel.ERROR.toString(), + }, + body: `${red("Email handler rejected message")}${reset(` with the following reason: "${escapeLogValue(reason)}"`)}`, + } + ) + ); + + events.push({ + type: "reject", + timestamp: new Date().toISOString(), + }); + rejectReason = reason; + }, + forward: async ( + rcptTo: string, + headers?: Headers + ): Promise => { + await env[CoreBindings.SERVICE_LOOPBACK].fetch( "http://localhost/core/log", { method: "POST", - headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.ERROR.toString() }, - body: `${red("Email handler rejected message")}${reset(` with the following reason: "${reason}"`)}`, + headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.INFO.toString() }, + body: `${blue("Email handler forwarded message")}${reset(` with\n rcptTo: ${escapeLogValue(rcptTo)}${renderEmailHeaders(headers)}`)}`, } - ) - ); + ); + // Production returns a message id identifying the forwarded message. + // Locally we have no such id, so synthesize one in the production + // shape, using the recipient's domain. + const result = { messageId: synthesizeMessageId(rcptTo) }; - events.push({ - type: "reject", - timestamp: new Date().toISOString(), - }); - rejectReason = reason; - }, - forward: async ( - rcptTo: string, - headers?: Headers - ): Promise => { - await env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/log", - { - method: "POST", - headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.INFO.toString() }, - body: `${blue("Email handler forwarded message")}${reset(` with\n rcptTo: ${rcptTo}${renderEmailHeaders(headers)}`)}`, - } - ); - /** - * The message ID in production is a 36 character random string that identifies the message for e.g. linking up threads. - * In production it uses the sender domain rather than example.com. Locally, we have access to none of that information - * so instead we make a dummy message ID that matches the production format (36 characters followed by a domain) - */ - const uuid = crypto.randomUUID().replaceAll("-", ""); - const result = { messageId: `${uuid}@example.com` }; - - events.push({ - type: "forward", - timestamp: new Date().toISOString(), - messageId: result.messageId, - }); - forwards.push({ - recipient: rcptTo, - headers: headers ? [...headers.entries()] : [], - messageId: result.messageId, - }); + events.push({ + type: "forward", + timestamp: new Date().toISOString(), + messageId: result.messageId, + }); + forwards.push({ + recipient: rcptTo, + headers: headers ? [...headers.entries()] : [], + messageId: result.messageId, + }); - return result; - }, - reply: async (replyMessage): Promise => { - assert( - "from" in replyMessage && "to" in replyMessage, - "EmailReplyMessageBuilder is not currently supported" - ); + return result; + }, + reply: async (replyMessage): Promise => { + assert( + "from" in replyMessage && "to" in replyMessage, + "EmailReplyMessageBuilder is not currently supported" + ); - if ( - !(await isEmailReplyable( + if ( + !(await isEmailReplyable( + parsedIncomingEmail, + incomingEmailHeaders, + async (msg) => + void (await env[CoreBindings.SERVICE_LOOPBACK].fetch( + "http://localhost/core/log", + { + method: "POST", + headers: { + [SharedHeaders.LOG_LEVEL]: LogLevel.ERROR.toString(), + }, + body: msg, + } + )) + )) + ) { + throw new Error("Original email is not replyable"); + } + const validatedReply = await validateReply( parsedIncomingEmail, - incomingEmailHeaders, - async (msg) => - void (await env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/log", - { + replyMessage as MiniflareEmailMessage + ); + const finalReply = validatedReply.raw; + const replyId = messageIdToStorageId(validatedReply.messageId); + const parentRecordId = messageIdToStorageId( + parsedIncomingEmail.messageId + ); + + // Store the reply under `email//reply/.eml` + const resp = await env[CoreBindings.SERVICE_LOOPBACK].fetch( + `http://localhost/core/store-temp-file?email=true&extension=eml&prefix=reply&id=${encodeURIComponent(replyId)}&record=${encodeURIComponent(parentRecordId)}`, + { + method: "POST", + body: finalReply, + } + ); + if (!resp.ok) { + throw new Error( + `could not store reply temporary file: ${await resp.text()}` + ); + } + const file = await resp.text(); + + await env[CoreBindings.SERVICE_LOOPBACK].fetch( + "http://localhost/core/log", + { + method: "POST", + headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.INFO.toString() }, + body: `${blue("Email handler replied to sender")}${reset(` with the following message:\n ${escapeLogValue(file)}`)}`, + } + ); + + // The reply MIME already has a message id + const result = { messageId: validatedReply.messageId }; + events.push({ + type: "reply", + timestamp: new Date().toISOString(), + messageId: result.messageId, + }); + // The full reply is written to disk above; only capture up to the + // local limit in the store record so it stays under the RPC cap. + const capturedReply = truncateRawForCapture(finalReply); + if (capturedReply.truncated) { + ctx.waitUntil( + env[CoreBindings.SERVICE_LOOPBACK] + .fetch("http://localhost/core/log", { method: "POST", headers: { - [SharedHeaders.LOG_LEVEL]: LogLevel.ERROR.toString(), + [SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString(), }, - body: msg, - } - )) - )) - ) { - throw new Error("Original email is not replyable"); - } - const finalReply = await validateReply( - parsedIncomingEmail, - replyMessage as MiniflareEmailMessage - ); - - const resp = await env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/store-temp-file?extension=eml&prefix=email", - { - method: "POST", - body: finalReply, - } - ); - const file = await resp.text(); - - await env[CoreBindings.SERVICE_LOOPBACK].fetch( - "http://localhost/core/log", - { - method: "POST", - headers: { [SharedHeaders.LOG_LEVEL]: LogLevel.INFO.toString() }, - body: `${blue("Email handler replied to sender")}${reset(` with the following message:\n ${file}`)}`, + body: `Reply email exceeds the ${MAX_LOCAL_EMAIL_BYTES}-byte local capture limit; the reply was sent, but only the first ${MAX_LOCAL_EMAIL_BYTES} bytes are shown in the Local Explorer.`, + }) + .catch(() => undefined) + ); } + replies.push({ + messageId: result.messageId, + sender: replyMessage.from, + raw: capturedReply.raw, + rawBase64: capturedReply.rawBase64, + }); + 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)) { + // The Worker has no `email()` handler, so the message could not be + // delivered. Record it as `unhandled`` + events.splice(0, events.length, { + type: "unhandled", timestamp: new Date().toISOString(), - messageId: result.messageId, - }); - replies.push({ - messageId: result.messageId, - sender: replyMessage.from, - raw: new TextDecoder().decode(finalReply), }); - return result; - }, - } satisfies ForwardableEmailMessage - ); + } + } - if (params.get("format") !== "json") { - await emailEvent; + // Give an un-awaited `setReject()` call time to cross JSRPC. + await scheduler.wait(0); + await storeReceivedEmail(); - if (rejectReason !== undefined) { + return Response.json( + { + outcome, + rejectReason, + forwards, + replies: replies.map(({ rawBase64: _rawBase64, ...reply }) => reply), + events, + }, + { status: outcome === "ok" ? 200 : 500 } + ); + } catch (e) { + outcome = "exception"; + if (isMissingEmailHandlerError(e)) { + // The Worker has no `email()` handler, so the message could not be + // delivered. Record it as `unhandled`` + events.splice(0, events.length, { + type: "unhandled", + timestamp: new Date().toISOString(), + }); + await storeReceivedEmail(); return new Response( - `Worker rejected email with the following reason: ${rejectReason}`, - { status: 400 } + "Worker does not export an email() handler; message stored without delivery.", + { status: 500 } ); } - - return new Response("Worker successfully processed email", { - status: 200, - }); - } - - let outcome: "ok" | "exception"; - - try { - await emailEvent; - outcome = "ok"; - } catch { - outcome = "exception"; + await storeReceivedEmail(); + throw e; } - - // 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 2ec9adfd2c3..6707e2a27df 100644 --- a/packages/miniflare/src/workers/core/entry.worker.ts +++ b/packages/miniflare/src/workers/core/entry.worker.ts @@ -17,6 +17,7 @@ import type { Colorize } from "kleur/colors"; type Env = { [CoreBindings.SERVICE_LOOPBACK]: Fetcher; [CoreBindings.SERVICE_USER_FALLBACK]: Fetcher; + [CoreBindings.TEXT_FALLBACK_WORKER_NAME]: string; [CoreBindings.SERVICE_LOCAL_EXPLORER]: Fetcher; [CoreBindings.SERVICE_STREAM]?: Fetcher; [CoreBindings.SERVICE_IMAGES_DELIVERY]?: Fetcher; @@ -140,8 +141,13 @@ function getUserRequest( return request; } -function getTargetService(request: Request, url: URL, env: Env) { +function getTargetService( + request: Request, + url: URL, + env: Env +): { service: Fetcher | undefined; workerName: string | undefined } { let service: Fetcher | undefined = env[CoreBindings.SERVICE_USER_FALLBACK]; + let workerName = env[CoreBindings.TEXT_FALLBACK_WORKER_NAME] || undefined; const override = request.headers.get(CoreHeaders.ROUTE_OVERRIDE); request.headers.delete(CoreHeaders.ROUTE_OVERRIDE); @@ -149,8 +155,9 @@ function getTargetService(request: Request, url: URL, env: Env) { const route = override ?? matchRoutes(env[CoreBindings.JSON_ROUTES], url); if (route !== null) { service = env[`${CoreBindings.SERVICE_USER_ROUTE_PREFIX}${route}`]; + workerName = route; } - return service; + return { service, workerName }; } const LOCALHOST_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"]; @@ -510,7 +517,7 @@ export default >{ throw e; } const url = new URL(request.url); - const service = getTargetService(request, url, env); + const { service, workerName } = getTargetService(request, url, env); if (service === undefined) { return new Response("No entrypoint worker found", { status: 404 }); } @@ -542,6 +549,7 @@ export default >{ url.searchParams, request, service, + workerName, env, ctx ); From 067047f8cd6eadebe9726c9dc8d486119dd08aa5 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:31:09 +0100 Subject: [PATCH 06/13] [miniflare] Capture sent email messages --- .../src/workers/email/send_email.worker.ts | 491 ++++++++++++------ 1 file changed, 339 insertions(+), 152 deletions(-) diff --git a/packages/miniflare/src/workers/email/send_email.worker.ts b/packages/miniflare/src/workers/email/send_email.worker.ts index b56b78e36ab..82fcb3c401d 100644 --- a/packages/miniflare/src/workers/email/send_email.worker.ts +++ b/packages/miniflare/src/workers/email/send_email.worker.ts @@ -1,24 +1,47 @@ import { WorkerEntrypoint } from "cloudflare:workers"; -import { blue } from "kleur/colors"; +import { $, blue } from "kleur/colors"; +import { LogLevel, SharedHeaders } from "miniflare:shared"; import PostalMime from "postal-mime"; -import { RAW_EMAIL } from "./constants"; +import { CoreBindings } from "../core/constants"; +import { + MAX_LOCAL_EMAIL_BYTES, + RAW_EMAIL, + truncateRawForCapture, + truncateStringForCapture, +} from "./capture"; import { type MiniflareEmailMessage as EmailMessage } from "./email.worker"; +import { messageIdToStorageId, synthesizeMessageId } from "./message-id"; +import type { + EmailArtifact, + EmailStoreService, + StoredEmailAttachment, + StoredSendingEmail, +} from "./storage"; import type { EmailAddress, MessageBuilder } from "./types"; import type { Email } from "postal-mime"; +// Force-enable colours. +$.enabled = true; + /** - * Build a Message-ID in the shape the production `send_email` binding returns: - * `<{36 alphanumeric chars}@{sender domain}>`, brackets included. The body is - * random — production synthesizes its own id rather than echoing any header - * present in the submitted email. + * Byte length of email content, so attachment sizes are accurate for + * multi-byte payloads (string `.length` counts UTF-16 code units, not bytes). */ -function synthesizeMessageId(senderEmail: string): string { - const alphabet = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - const bytes = crypto.getRandomValues(new Uint8Array(36)); - const id = Array.from(bytes, (b) => alphabet[b % alphabet.length]).join(""); - const domain = senderEmail.slice(senderEmail.lastIndexOf("@") + 1); - return `<${id}@${domain}>`; +function contentByteLength( + content: string | ArrayBuffer | ArrayBufferView +): number { + if (typeof content === "string") { + return new TextEncoder().encode(content).byteLength; + } + return content.byteLength; +} + +function getAttachmentExtension(filename: string): string { + const extension = filename.match(/\.([^.]+)$/u)?.[1]; + return extension !== undefined && + /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(extension) + ? extension + : "bin"; } /** @@ -44,6 +67,16 @@ function formatEmailAddress(addr: string | EmailAddress): string { return `"${addr.name}" <${addr.email}>`; } +function formatParsedAddress(addr: { + address?: string; + name?: string; +}): string { + const email = addr.address ?? ""; + return addr.name === undefined || addr.name === "" + ? email + : `"${addr.name}" <${email}>`; +} + /** * Formats a MessageBuilder for logging */ @@ -70,64 +103,131 @@ 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[]; destination_address: string | undefined; allowed_destination_addresses: string[] | undefined; allowed_sender_addresses: string[] | undefined; - MINIFLARE_EMAIL_DISK_SYSTEM: Fetcher; - MINIFLARE_EMAIL_DISK_PROJECT?: Fetcher; + MINIFLARE_LOOPBACK: Fetcher; + [CoreBindings.SERVICE_EMAIL_STORE]?: EmailStoreService; + /** Worker that owns this send_email binding, set when the local explorer is enabled. */ + SEND_EMAIL_OWNER_WORKER?: string; } export class SendEmailBinding extends WorkerEntrypoint { /** - * Gets a disk service binding by name + * Logs a message via the loopback `/core/log` endpoint. */ - private getServiceBinding(bindingName: string): Fetcher { - const binding = - this.env[ - bindingName as - | "MINIFLARE_EMAIL_DISK_SYSTEM" - | "MINIFLARE_EMAIL_DISK_PROJECT" - ]; - if (!binding) { - throw new Error(`Disk service binding not found: ${bindingName}`); + private async log( + message: string, + level: LogLevel = LogLevel.INFO + ): Promise { + await this.env.MINIFLARE_LOOPBACK.fetch("http://localhost/core/log", { + method: "POST", + headers: { [SharedHeaders.LOG_LEVEL]: level.toString() }, + body: message, + }); + } + + /** + * Warns (via the loopback log) that an oversized message was truncated for + * capture. Delivery is unaffected — only the Local Explorer copy is trimmed. + */ + private async warnTruncated(): Promise { + try { + await this.log( + `Email exceeds the ${MAX_LOCAL_EMAIL_BYTES}-byte local capture limit; the email was sent, but only the first ${MAX_LOCAL_EMAIL_BYTES} bytes are shown in the Local Explorer.`, + LogLevel.WARN + ); + } catch { + // Logging failures must not affect sending. } - return binding; } /** - * Logs a message via the runtime console. + * Captures a sent email into the local email store for the explorer. + * + * Capture is a dev-only inspection aid: any failure here (store unbound, + * RPC error) is swallowed so it never affects the result of `send()`. When + * the store is unbound (local explorer disabled) this is a no-op. Large raw + * bodies are streamed in 64 KB base64 slices to stay under workerd's RPC + * argument cap, mirroring the received path. */ - private log(message: string): void { - console.log(message); + private async reportSentEmail( + email: StoredSendingEmail + ): Promise { + const store = this.env[CoreBindings.SERVICE_EMAIL_STORE]; + if (store === undefined) { + return []; + } + try { + const rawBase64 = email.rawBase64; + if (rawBase64 !== undefined && rawBase64.length > 64 * 1024) { + const { raw: _raw, rawBase64: _rawBase64, ...metadata } = email; + const id = messageIdToStorageId(email.messageId); + await store.beginSent(metadata); + try { + for (let offset = 0; offset < rawBase64.length; offset += 64 * 1024) { + await store.appendSentRaw( + id, + rawBase64.slice(offset, offset + 64 * 1024) + ); + } + return await store.finishSent(id); + } catch (error) { + await store.discardSent(id).catch(() => undefined); + throw error; + } + } + return await store.storeSent(email); + } catch { + try { + await this.log( + "Failed to capture sent email for the Local Explorer; the email was still sent.", + LogLevel.WARN + ); + } catch { + // Capture failures must not affect sending. + } + return []; + } + } + + private async removeSentArtifacts(artifacts: EmailArtifact[]): Promise { + if (artifacts.length === 0) { + return; + } + const response = await this.env.MINIFLARE_LOOPBACK.fetch( + "http://localhost/core/delete-email-temp-files", + { + method: "POST", + body: JSON.stringify({ artifacts } satisfies { + artifacts: EmailArtifact[]; + }), + } + ); + if (!response.ok) { + throw new Error( + `could not delete email temporary files: ${await response.text()}` + ); + } } /** - * 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. + * + * Always requests the endpoint's email mode so the file lands in the email + * directories and is mirrored into the project directory. + * + * `id` names the file, and is always derived from the message's id so every + * file belonging to a message can be found from the id the local explorer + * shows. */ private async storeTempFile( content: string | ArrayBuffer | ArrayBufferView, extension: string, prefix: string, - location: "system" | "project" = "system", - messageUUID?: string + id: string, + recordId = id ): Promise { let body: string | Uint8Array; if (typeof content === "string") { @@ -143,27 +243,29 @@ export class SendEmailBinding extends WorkerEntrypoint { ); } - const fileName = messageUUID - ? `${messageUUID}.${extension}` - : `${crypto.randomUUID()}.${extension}`; - const url = new URL(`${prefix}/${fileName}`, "http://placeholder/"); + const params = new URLSearchParams({ + prefix, + extension, + email: "true", + id, + record: recordId, + }); - // Find the disk service config for the requested location. - const diskConfig = this.env.email_disk_services.find( - (config) => config.location === location + const resp = await this.env.MINIFLARE_LOOPBACK.fetch( + `http://localhost/core/store-temp-file?${params.toString()}`, + { + method: "POST", + body, + } ); - if (!diskConfig) { - throw new Error(`Disk service for ${location} not found`); + const text = await resp.text(); + if (!resp.ok) { + // A non-2xx body is an error message, not a path; surface it so the + // caller doesn't log an error string as if it were a file path. + throw new Error(`could not store email temporary file: ${text}`); } - - const service = this.getServiceBinding(diskConfig.bindingName); - await service.fetch(url, { - method: "PUT", - body, - }); - - return joinPath(diskConfig.path, prefix, fileName); + return text; } private checkDestinationAllowed(to: string) { @@ -230,7 +332,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 +374,81 @@ export class SendEmailBinding extends WorkerEntrypoint { throw new Error("invalid headers set"); } - const locations = this.env.email_disk_services.map( - (service) => service.location - ); - const filePaths = await Promise.all( - locations.map((location) => - this.storeTempFile( + // Always synthesise new ID for user sent emails. + const messageId = synthesizeMessageId(emailMessage.from); + const id = messageIdToStorageId(messageId); + + // Capture only up to the local limit; delivery uses the full body. The + // captured copy (store record and on-disk .eml) is trimmed to keep the + // workerd-internal RPC argument under its ~1 MiB cap. + const capturedRaw = truncateRawForCapture(rawEmailBuffer); + if (capturedRaw.truncated) { + await this.warnTruncated(); + } + + // Complete the workerd-side capture before resolving send(). File writes + // remain deferred because they cross the Node loopback service. + const evictedArtifacts = await this.reportSentEmail({ + worker: this.env.SEND_EMAIL_OWNER_WORKER, + from: emailMessage.from, + to: [emailMessage.to], + cc: parsedEmail.cc?.map(formatParsedAddress), + bcc: parsedEmail.bcc?.map(formatParsedAddress), + replyTo: parsedEmail.replyTo + ? parsedEmail.replyTo.map(formatParsedAddress).join(", ") + : undefined, + subject: parsedEmail.subject ?? "(no subject)", + sentAt: new Date().toISOString(), + messageId, + headers: Object.fromEntries( + parsedEmail.headers.map(({ key, value }) => [key, value]) + ), + // `text`/`html` are derived views of the raw body (the full copy is + // preserved via `rawBase64`), and travel in the metadata prelude + // that precedes the streamed raw body. Cap them well under + // workerd's RPC argument limit so the prelude always fits. + text: + parsedEmail.text === undefined + ? undefined + : truncateStringForCapture(parsedEmail.text, 64 * 1024).value, + html: + parsedEmail.html === undefined + ? undefined + : truncateStringForCapture(parsedEmail.html, 64 * 1024).value, + attachments: (parsedEmail.attachments ?? []).map((attachment) => ({ + filename: attachment.filename ?? "attachment", + contentType: attachment.mimeType ?? "application/octet-stream", + disposition: + attachment.disposition === "inline" ? "inline" : "attachment", + size: contentByteLength(attachment.content), + })), + raw: capturedRaw.raw, + rawBase64: capturedRaw.rawBase64, + }); + + this.ctx.waitUntil( + (async () => { + const filePath = await this.storeTempFile( rawEmailBuffer, "eml", "email", - location, - messageUUID - ) - ) - ); - - // Log only project location if it exists, otherwise system location - const projectIndex = locations.indexOf("project"); - const logIndex = projectIndex !== -1 ? projectIndex : 0; - const fileInfo = `Email: ${filePaths[logIndex]}`; - this.log( - `${blue("send_email binding called with the following message:")}\n${fileInfo}` + id, + id + ); + await this.removeSentArtifacts(evictedArtifacts); + 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 +456,117 @@ 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); + + const toDisplay = ( + addr: string | EmailAddress | (string | EmailAddress)[] + ): string[] => + (Array.isArray(addr) ? addr : [addr]).map(formatEmailAddress); + + const sentAttachments: StoredEmailAttachment[] = ( + builder.attachments ?? [] + ).map((attachment) => ({ + filename: attachment.filename, + contentType: attachment.type, + disposition: attachment.disposition, + size: contentByteLength(attachment.content), + })); + + // A MessageBuilder carries its `text`/`html` inline in the capture + // record (there is no raw body). When either exceeds the local capture + // limit, truncate it for the store; delivery uses the full content. + const capturedText = + builder.text !== undefined + ? truncateStringForCapture(builder.text) + : undefined; + const capturedHtml = + builder.html !== undefined + ? truncateStringForCapture(builder.html) + : undefined; + if (capturedText?.truncated || capturedHtml?.truncated) { + await this.warnTruncated(); } - if (builder.html) { - const html = builder.html; - const htmlResults = await Promise.all( - locations.map((location) => - this.storeTempFile( - html, + // Complete the workerd-side capture before resolving send() + const evictedArtifacts = await this.reportSentEmail({ + worker: this.env.SEND_EMAIL_OWNER_WORKER, + from: formatEmailAddress(builder.from), + to: toDisplay(builder.to), + cc: builder.cc ? toDisplay(builder.cc) : undefined, + bcc: builder.bcc ? toDisplay(builder.bcc) : undefined, + replyTo: builder.replyTo + ? formatEmailAddress(builder.replyTo) + : undefined, + subject: builder.subject, + sentAt: new Date().toISOString(), + messageId, + text: capturedText?.value, + html: capturedHtml?.value, + headers: builder.headers, + attachments: sentAttachments, + }); + + this.ctx.waitUntil( + (async () => { + const files: string[] = []; + + if (builder.text) { + const textPath = await this.storeTempFile( + builder.text, + "txt", + "email-text", + id, + 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, + 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 - ) - ) - ); - // Log only project location if it exists, otherwise system location - const projectIndex = locations.indexOf("project"); - const logIndex = projectIndex !== -1 ? projectIndex : 0; - files.push( - `Attachment (${attachment.disposition}): ${attachment.filename} -> ${attachmentResults[logIndex]}` + `${id}-${index + 1}`, + id + ); + files.push( + `Attachment (${attachment.disposition}): ${attachment.filename} -> ${attachmentPath}` + ); + } + } + + await this.removeSentArtifacts(evictedArtifacts); + const formatted = formatMessageBuilder(builder); + const fileInfo = files.length > 0 ? `\n\n${files.join("\n")}` : ""; + await this.log( + `${blue("send_email binding called with MessageBuilder:")}\n${formatted}${fileInfo}` ); - } - } - - // Format and log the message details with file paths - const formatted = formatMessageBuilder(builder); - const fileInfo = files.length > 0 ? `\n\n${files.join("\n")}` : ""; - this.log( - `${blue("send_email binding called with MessageBuilder:")}\n${formatted}${fileInfo}` + })().catch(async (error: unknown) => { + try { + await this.log(`Failed to persist sent email: ${String(error)}`); + } catch { + // Logging failures must not create another unhandled rejection. + } + }) ); - return { - messageId: synthesizeMessageId(extractEmailAddress(builder.from)), - }; + return { messageId }; } } } From 8229a3eaf60945211dd7cb44d99ead1e11ca34a0 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:31:15 +0100 Subject: [PATCH 07/13] [wrangler] Reuse the canonical email handler result --- packages/wrangler/src/api/test-harness.ts | 27 ++--------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/packages/wrangler/src/api/test-harness.ts b/packages/wrangler/src/api/test-harness.ts index c26a4d0440f..b3dc882f147 100644 --- a/packages/wrangler/src/api/test-harness.ts +++ b/packages/wrangler/src/api/test-harness.ts @@ -54,6 +54,7 @@ import type { DurableObjectStorageHandle, DurableObjectStorageOptions, DispatchFetch, + EmailHandlerResult, Json, Miniflare, RequestInfo, @@ -104,31 +105,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. From 21cb578e619b6160f058e3fea2b6de73fc265db8 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:31:30 +0100 Subject: [PATCH 08/13] [miniflare] Add local email explorer endpoints --- .../workers/local-explorer/explorer.worker.ts | 34 ++ .../workers/local-explorer/resources/email.ts | 355 ++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 packages/miniflare/src/workers/local-explorer/resources/email.ts diff --git a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts index e9b4b127867..dd13ec1c16b 100644 --- a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts +++ b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts @@ -12,6 +12,7 @@ import { zD1RawDatabaseQueryData, zDurableObjectsNamespaceListObjectsData, zDurableObjectsNamespaceQuerySqliteData, + zEmailSendRoutingData, zR2BucketDeleteObjectsData, zR2BucketListObjectsData, zWorkersKvNamespaceGetMultipleKeyValuePairsData, @@ -24,6 +25,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, @@ -59,6 +67,7 @@ import type { import type { WorkerRegistry } from "../../shared/dev-registry-types"; import type { CoreBindings } from "../core"; import type { WorkerdDebugPortConnector } from "../core/dev-registry-proxy-shared.worker"; +import type { EmailStoreService } from "../email/storage"; import type { LocalExplorerWorker } from "./generated"; export type Env = { @@ -78,6 +87,9 @@ export type Env = { // Internal observability collector's read API — only bound when local // observability is enabled (see getExplorerServices). [CoreBindings.SERVICE_OBSERVABILITY_COLLECTOR]?: Fetcher; + // Email store RPC. Bound whenever the local explorer is enabled (see + // getExplorerServices). Backs the Email tab's routing/sending views. + [CoreBindings.SERVICE_EMAIL_STORE]?: EmailStoreService; }; export type AppBindings = { Bindings: Env }; @@ -366,6 +378,28 @@ app.post( app.post("/api/local/observability/clear", (c) => clearTraces(c)); +// ============================================================================ +// Email Endpoints +// ============================================================================ + +app.get("/api/email/routing", (c) => listReceivedEmails(c)); + +app.post( + "/api/email/routing/send", + validateRequestBody(zEmailSendRoutingData.shape.body), + (c) => sendTestEmail(c, c.req.valid("json")) +); + +app.get("/api/email/routing/:email_id", (c) => + getReceivedEmail(c, c.req.param("email_id")) +); + +app.get("/api/email/sending", (c) => listSentEmails(c)); + +app.get("/api/email/sending/:email_id", (c) => + getSentEmail(c, c.req.param("email_id")) +); + // ============================================================================ // Local Workers / Dev Registry Endpoint // ============================================================================ 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..02896a54932 --- /dev/null +++ b/packages/miniflare/src/workers/local-explorer/resources/email.ts @@ -0,0 +1,355 @@ +import { getPublicUrl } from "miniflare:shared"; +import { decodeWords } from "postal-mime"; +import { z } from "zod"; +import { CoreBindings, CorePaths } from "../../core"; +import { MAX_LOCAL_EMAIL_BYTES } from "../../email/capture"; +import { + getHeader, + messageIdToStorageId, + synthesizeMessageId, +} from "../../email/message-id"; +import { errorResponse, wrapResponse } from "../common"; +import { + zEmailHandlerEvent, + zEmailHandlerForward, + zEmailHandlerReply, + zEmailRoutingDetail, + zEmailRoutingItem, + zEmailSendingDetail, + zEmailSendingItem, +} from "../generated/zod.gen"; +import type { EmailStoreService } from "../../email/storage"; +import type { AppContext } from "../common"; +import type { EmailSendRequest } from "../generated"; + +const EMAIL_ERROR_NOT_FOUND = 10601; +const EMAIL_ERROR_SEND_FAILED = 10602; +/** Occurs when the email store binding is missing (should not happen when the explorer is + * enabled, since the store is registered alongside it). */ +const EMAIL_ERROR_STORE_UNAVAILABLE = 10603; + +const zEmailHandlerResult = z.object({ + outcome: z.enum(["ok", "exception"]), + rejectReason: z.string().optional(), + forwards: z.array(zEmailHandlerForward), + replies: z.array(zEmailHandlerReply.extend({ raw: z.string() })), + events: z.array(zEmailHandlerEvent), +}); + +function getEmailStore(c: AppContext): EmailStoreService | undefined { + return c.env[CoreBindings.SERVICE_EMAIL_STORE]; +} + +function extractAddress(value: string): string { + const match = value.match(/<([^>]+)>/); + return (match ? match[1] : value).trim(); +} + +function hasUnsafeHeaderCharacters(value: string): boolean { + return /[\u0000-\u001f\u007f]/u.test(value); +} + +function isHeaderName(value: string): boolean { + return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(value); +} + +function isMimeType(value: string): boolean { + return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test( + value + ); +} + +function isBase64(value: string): boolean { + const normalized = value.replace(/\s/gu, ""); + if ( + normalized.length % 4 !== 0 || + !/^[A-Za-z0-9+/]*={0,2}$/u.test(normalized) + ) { + return false; + } + try { + atob(normalized); + return true; + } catch { + return false; + } +} + +function validateEmailRequest(body: EmailSendRequest): string | undefined { + const headerValues = [ + body.from, + ...body.to, + ...(body.cc ?? []), + ...(body.bcc ?? []), + body.replyTo, + body.subject, + ].filter((value): value is string => value !== undefined); + if (headerValues.some(hasUnsafeHeaderCharacters)) { + return "Email fields must not contain control characters."; + } + + for (const [name, value] of Object.entries(body.headers ?? {})) { + if (!isHeaderName(name) || hasUnsafeHeaderCharacters(value)) { + return "Custom headers must use valid names and values."; + } + } + + for (const attachment of body.attachments ?? []) { + if ( + hasUnsafeHeaderCharacters(attachment.filename) || + (attachment.contentId !== undefined && + hasUnsafeHeaderCharacters(attachment.contentId)) || + !isMimeType(attachment.type) || + !isBase64(attachment.content) + ) { + return "Attachments must have valid filenames, MIME types, and base64 content."; + } + } + + return undefined; +} + +function buildMimeMessage(body: EmailSendRequest, messageId: string): string { + const headers: string[] = [`From: ${body.from}`, `To: ${body.to.join(", ")}`]; + if (body.cc?.length) { + headers.push(`Cc: ${body.cc.join(", ")}`); + } + if (body.bcc?.length) { + headers.push(`Bcc: ${body.bcc.join(", ")}`); + } + if (body.replyTo) { + headers.push(`Reply-To: ${body.replyTo}`); + } + headers.push(`Subject: ${body.subject}`); + headers.push(`Message-ID: ${messageId}`); + headers.push(`Date: ${new Date().toUTCString()}`); + headers.push("MIME-Version: 1.0"); + + // Custom headers last so they can override defaults if intentionally set. A + // caller-supplied Message-ID is skipped because it is already emitted above, + // as `messageId`. + for (const [key, value] of Object.entries(body.headers ?? {})) { + if (key.toLowerCase() === "message-id") { + continue; + } + headers.push(`${key}: ${value}`); + } + + const text = body.text ?? ""; + const html = body.html; + + let contentHeaders: string[]; + let content: string; + + if (html && body.text) { + const boundary = `----=_Part_${crypto.randomUUID()}`; + contentHeaders = [ + `Content-Type: multipart/alternative; boundary="${boundary}"`, + ]; + content = [ + `--${boundary}`, + "Content-Type: text/plain; charset=utf-8", + "", + text, + `--${boundary}`, + "Content-Type: text/html; charset=utf-8", + "", + html, + `--${boundary}--`, + "", + ].join("\r\n"); + } else if (html) { + contentHeaders = ["Content-Type: text/html; charset=utf-8"]; + content = html; + } else { + contentHeaders = ["Content-Type: text/plain; charset=utf-8"]; + content = text; + } + + const attachments = body.attachments ?? []; + if (attachments.length === 0) { + headers.push(...contentHeaders); + return `${headers.join("\r\n")}\r\n\r\n${content}`; + } + + const boundary = `----=_Mixed_${crypto.randomUUID()}`; + headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); + + const parts: string[] = [`--${boundary}`, ...contentHeaders, "", content]; + for (const attachment of attachments) { + const filename = attachment.filename + .replace(/[\r\n]/g, " ") + .replace(/(["\\])/g, "\\$1"); + parts.push( + `--${boundary}`, + `Content-Type: ${attachment.type}; name="${filename}"`, + `Content-Disposition: ${attachment.disposition ?? "attachment"}; filename="${filename}"`, + "Content-Transfer-Encoding: base64", + ...(attachment.disposition === "inline" && attachment.contentId + ? [ + `Content-ID: ${attachment.contentId.startsWith("<") ? attachment.contentId : `<${attachment.contentId}>`}`, + ] + : []), + "", + // RFC 2045 caps base64 body lines at 76 characters. + attachment.content + .replace(/\s/g, "") + .replace(/(.{76})/g, "$1\r\n") + .trimEnd() + ); + } + parts.push(`--${boundary}--`, ""); + + return `${headers.join("\r\n")}\r\n\r\n${parts.join("\r\n")}`; +} + +export async function listReceivedEmails(c: AppContext): Promise { + const store = getEmailStore(c); + if (!store) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Email store is not available for this dev session." + ); + } + const emails = z.array(zEmailRoutingItem).parse(await store.listReceived()); + return c.json(wrapResponse(emails)); +} + +export async function getReceivedEmail( + c: AppContext, + emailId: string +): Promise { + const store = getEmailStore(c); + if (!store) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Email store is not available for this dev session." + ); + } + const email = await store.findReceived(emailId); + if (!email) { + return errorResponse( + 404, + EMAIL_ERROR_NOT_FOUND, + `Email '${emailId}' not found.` + ); + } + // Decode MIME "encoded-word" headers (e.g. `=?utf-8?B?...?=`) in each reply's + // display text so the explorer shows readable subjects. The lossless bytes + // remain available through rawBase64. + const decoded = { + ...email, + replies: email.replies.map((reply) => ({ + ...reply, + raw: decodeWords(reply.raw), + })), + }; + return c.json(wrapResponse(zEmailRoutingDetail.parse(decoded))); +} + +/** + * Sends a test email to trigger the worker's email() handler. + */ +export async function sendTestEmail( + c: AppContext, + body: EmailSendRequest +): Promise { + const invalidRequest = validateEmailRequest(body); + if (invalidRequest !== undefined) { + return errorResponse(400, 10000, invalidRequest); + } + const from = extractAddress(body.from); + const to = extractAddress(body.to[0] ?? ""); + + if (!to) { + return errorResponse(400, 10000, "At least one recipient is required."); + } + + // Derive the Message-ID exactly as the `send_email` binding does, so a + // received and a sent email agree on it. Honour one the caller set + // explicitly, since the send dialog allows custom headers. + const messageId = + getHeader(body.headers, "Message-ID") ?? synthesizeMessageId(from); + // TODO(miniflare v5): switch on-disk file naming to a mimetext-style id + // to unify the file name with the Message-ID seen in local explorer. + const id = messageIdToStorageId(messageId); + const mime = buildMimeMessage(body, messageId); + if (new TextEncoder().encode(mime).byteLength > MAX_LOCAL_EMAIL_BYTES) { + return errorResponse( + 400, + EMAIL_ERROR_SEND_FAILED, + "Email message exceeds the 1 MiB local development limit." + ); + } + + const entryUrl = await getPublicUrl(c.env.MINIFLARE_LOOPBACK); + const deliverUrl = new URL(CorePaths.EMAIL, entryUrl); + 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 response = await fetch(deliverUrl, { method: "POST", body: mime }); + + // A 4xx means the message itself was invalid (bad envelope, unparseable, or + // too large) and never reached the handler — that's a send failure. Anything + // else (including a handler that rejected or threw) counts as delivered. + if (response.status >= 400 && response.status < 500) { + const message = await response.text(); + return errorResponse( + 400, + EMAIL_ERROR_SEND_FAILED, + message || "Failed to deliver test email." + ); + } + + const result = zEmailHandlerResult.parse(await response.json()); + return c.json( + wrapResponse({ + messageId, + outcome: result.outcome, + ...(result.rejectReason !== undefined + ? { rejectReason: result.rejectReason } + : {}), + }) + ); +} + +export async function listSentEmails(c: AppContext): Promise { + const store = getEmailStore(c); + if (!store) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Email store is not available for this dev session." + ); + } + const emails = z.array(zEmailSendingItem).parse(await store.listSent()); + return c.json(wrapResponse(emails)); +} + +export async function getSentEmail( + c: AppContext, + emailId: string +): Promise { + const store = getEmailStore(c); + if (!store) { + return errorResponse( + 500, + EMAIL_ERROR_STORE_UNAVAILABLE, + "Email store is not available for this dev session." + ); + } + const email = await store.findSent(emailId); + if (!email) { + return errorResponse( + 404, + EMAIL_ERROR_NOT_FOUND, + `Email '${emailId}' not found.` + ); + } + return c.json(wrapResponse(zEmailSendingDetail.parse(email))); +} From 18670327b42c6369df212db786d81ed65107faa1 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:31:46 +0100 Subject: [PATCH 09/13] [miniflare] Scope email services to workers --- .../miniflare/src/plugins/core/explorer.ts | 6 +++ packages/miniflare/src/plugins/email/index.ts | 54 ++++++++++++++----- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/miniflare/src/plugins/core/explorer.ts b/packages/miniflare/src/plugins/core/explorer.ts index 89e5f2de1ac..bb6f1953f30 100644 --- a/packages/miniflare/src/plugins/core/explorer.ts +++ b/packages/miniflare/src/plugins/core/explorer.ts @@ -103,6 +103,12 @@ export function getExplorerServices( 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 — diff --git a/packages/miniflare/src/plugins/email/index.ts b/packages/miniflare/src/plugins/email/index.ts index 8163d9fded5..5cc65e9c326 100644 --- a/packages/miniflare/src/plugins/email/index.ts +++ b/packages/miniflare/src/plugins/email/index.ts @@ -3,6 +3,7 @@ import path from "node:path"; import EMAIL_MESSAGE from "worker:email/email"; import SEND_EMAIL_BINDING from "worker:email/send_email"; import { z } from "zod"; +import { isFileNotFoundError } from "../../shared"; import { CoreBindings, sanitisePath } from "../../workers"; import { EMAIL_STORE_SERVICE_NAME } from "../core/constants"; import { @@ -57,7 +58,20 @@ export const EMAIL_PLUGIN_NAME = "email"; const SERVICE_SEND_EMAIL_WORKER_PREFIX = `SEND-EMAIL-WORKER`; const EMAIL_REMOTE_SERVICE_NAME = `${EMAIL_PLUGIN_NAME}:remote`; -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), @@ -133,7 +147,7 @@ export function getEmailFileDirectories( * the project copy since that is the one a user can navigate to. */ export async function writeEmailTempFile(options: { - defaultProjectTmpPath: string | undefined; + resourceTmpPath: string | undefined; tmpPath: string; prefix: string; fileName: string; @@ -149,7 +163,7 @@ export async function writeEmailTempFile(options: { throw new Error("Invalid email temporary-file prefix"); } const { system, project } = getEmailFileDirectories( - options.defaultProjectTmpPath, + options.resourceTmpPath, options.tmpPath, options.prefix ); @@ -169,14 +183,14 @@ export async function writeEmailTempFile(options: { } export async function removeEmailTempFiles(options: { - defaultProjectTmpPath: string | undefined; + resourceTmpPath: string | undefined; tmpPath: string; artifacts: EmailArtifact[]; }): Promise { await Promise.all( options.artifacts.map(async (artifact) => { const { system, project } = getEmailFileDirectories( - options.defaultProjectTmpPath, + options.resourceTmpPath, options.tmpPath, artifact.prefix ); @@ -188,7 +202,15 @@ export async function removeEmailTempFiles(options: { : [resolveContainedPath(project, fileName)]), ]; await Promise.all( - paths.map((filePath) => unlink(filePath).catch(() => {})) + paths.map(async (filePath) => { + try { + await unlink(filePath); + } catch (error) { + if (!isFileNotFoundError(error)) { + throw error; + } + } + }) ); }) ); @@ -216,7 +238,7 @@ export const EMAIL_PLUGIN: Plugin< options: EmailOptionsSchema, sharedOptions: EmailSharedOptionsSchema, bindingTypeDescription: "Email", - getBindings(options): Worker_Binding[] { + getBindings(options, _workerIndex, workerName): Worker_Binding[] { if (!options.email?.send_email) { return []; } @@ -232,10 +254,7 @@ export const EMAIL_PLUGIN: Plugin< } : { entrypoint: "SendEmailBinding", - name: getUserBindingServiceName( - SERVICE_SEND_EMAIL_WORKER_PREFIX, - name - ), + name: getSendEmailServiceName(workerName, name), }, })); }, @@ -264,6 +283,16 @@ export const EMAIL_PLUGIN: Plugin< ] : []; + // The worker that owns these send_email bindings. `getServices` is called + // once per worker, so this identifies which worker sent a message and lets + // the local explorer filter the "Sending" inbox by the selected worker. + const ownerWorkerBinding: Worker_Binding[] = args.sharedOptions + .unsafeLocalExplorer + ? buildJsonBindings({ + SEND_EMAIL_OWNER_WORKER: args.workerNames[args.workerIndex], + }) + : []; + const services: Service[] = []; let hasRemote = false; for (const { name, remoteProxyConnectionString, ...config } of args.options @@ -273,7 +302,7 @@ export const EMAIL_PLUGIN: Plugin< continue; } services.push({ - name: getUserBindingServiceName(SERVICE_SEND_EMAIL_WORKER_PREFIX, name), + name: getSendEmailServiceName(args.workerNames[args.workerIndex], name), worker: { compatibilityDate: "2025-03-17", modules: [ @@ -286,6 +315,7 @@ export const EMAIL_PLUGIN: Plugin< ...buildJsonBindings(config), WORKER_BINDING_SERVICE_LOOPBACK, ...emailStoreBinding, + ...ownerWorkerBinding, ], }, }); From bbd3b26d6a266480c5bee45f626a52aa923b8613 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:32:01 +0100 Subject: [PATCH 10/13] [miniflare] Add worker filters to the email API --- .../scripts/openapi-filter-config.ts | 53 +++++++++++++++++- .../workers/local-explorer/explorer.worker.ts | 32 ++++++++--- .../local-explorer/generated/types.gen.ts | 43 ++++++++++++-- .../local-explorer/generated/zod.gen.ts | 34 +++++++++-- .../workers/local-explorer/openapi.local.json | 56 ++++++++++++++++++- 5 files changed, 196 insertions(+), 22 deletions(-) diff --git a/packages/miniflare/scripts/openapi-filter-config.ts b/packages/miniflare/scripts/openapi-filter-config.ts index 4aeaa7e34e0..ea2d7173d73 100644 --- a/packages/miniflare/scripts/openapi-filter-config.ts +++ b/packages/miniflare/scripts/openapi-filter-config.ts @@ -634,7 +634,15 @@ const config = { description: "Lists emails received by the worker's email() handler during this dev session.", operationId: "email-list-routing", - parameters: [], + parameters: [ + { + in: "query", + name: "worker", + schema: { type: "string" }, + description: + "Only return emails received by this worker's email() handler.", + }, + ], responses: { "200": { content: { @@ -681,6 +689,15 @@ const config = { description: "Sends a test email to trigger the worker's email() handler. Only the first `to` address is used as the envelope recipient; any other to/cc/bcc addresses appear only in the composed MIME headers.", operationId: "email-send-routing", + parameters: [ + { + in: "query", + name: "worker", + schema: { type: "string" }, + description: + "Deliver the test email to this worker's email() handler, regardless of address-based routing.", + }, + ], requestBody: { required: true, content: { @@ -758,6 +775,13 @@ const config = { required: true, schema: { type: "string" }, }, + { + in: "query", + name: "worker", + schema: { type: "string" }, + description: + "Only return the email if it was received by this worker's email() handler.", + }, ], responses: { "200": { @@ -802,7 +826,15 @@ const config = { description: "Lists emails sent through send_email bindings during this dev session.", operationId: "email-list-sending", - parameters: [], + parameters: [ + { + in: "query", + name: "worker", + schema: { type: "string" }, + description: + "Only return emails sent through this worker's send_email bindings.", + }, + ], responses: { "200": { content: { @@ -855,6 +887,13 @@ const config = { required: true, schema: { type: "string" }, }, + { + in: "query", + name: "worker", + schema: { type: "string" }, + description: + "Only return the email if it was sent through this worker's send_email bindings.", + }, ], responses: { "200": { @@ -2410,6 +2449,11 @@ const config = { "email_sending-item": { type: "object", properties: { + worker: { + type: "string", + description: + "Worker that owns the send_email binding the message was sent through, if known.", + }, from: { type: "string" }, to: { type: "array", items: { type: "string" } }, cc: { type: "array", items: { type: "string" } }, @@ -2445,6 +2489,11 @@ const config = { "email_sending-detail": { type: "object", properties: { + worker: { + type: "string", + description: + "Worker that owns the send_email binding the message was sent through, if known.", + }, from: { type: "string" }, to: { type: "array", items: { type: "string" } }, cc: { type: "array", items: { type: "string" } }, diff --git a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts index dd13ec1c16b..504eebebd73 100644 --- a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts +++ b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts @@ -12,6 +12,10 @@ import { zD1RawDatabaseQueryData, zDurableObjectsNamespaceListObjectsData, zDurableObjectsNamespaceQuerySqliteData, + zEmailGetRoutingData, + zEmailGetSendingData, + zEmailListRoutingData, + zEmailListSendingData, zEmailSendRoutingData, zR2BucketDeleteObjectsData, zR2BucketListObjectsData, @@ -382,22 +386,36 @@ app.post("/api/local/observability/clear", (c) => clearTraces(c)); // Email Endpoints // ============================================================================ -app.get("/api/email/routing", (c) => listReceivedEmails(c)); +app.get( + "/api/email/routing", + validateQuery(zEmailListRoutingData.shape.query.unwrap()), + (c) => listReceivedEmails(c, c.req.valid("query").worker) +); app.post( "/api/email/routing/send", + validateQuery(zEmailSendRoutingData.shape.query.unwrap()), validateRequestBody(zEmailSendRoutingData.shape.body), - (c) => sendTestEmail(c, c.req.valid("json")) + (c) => sendTestEmail(c, c.req.valid("json"), c.req.valid("query").worker) ); -app.get("/api/email/routing/:email_id", (c) => - getReceivedEmail(c, c.req.param("email_id")) +app.get( + "/api/email/routing/:email_id", + validateQuery(zEmailGetRoutingData.shape.query.unwrap()), + (c) => + getReceivedEmail(c, c.req.param("email_id"), c.req.valid("query").worker) ); -app.get("/api/email/sending", (c) => listSentEmails(c)); +app.get( + "/api/email/sending", + validateQuery(zEmailListSendingData.shape.query.unwrap()), + (c) => listSentEmails(c, c.req.valid("query").worker) +); -app.get("/api/email/sending/:email_id", (c) => - getSentEmail(c, c.req.param("email_id")) +app.get( + "/api/email/sending/:email_id", + validateQuery(zEmailGetSendingData.shape.query.unwrap()), + (c) => getSentEmail(c, c.req.param("email_id"), c.req.valid("query").worker) ); // ============================================================================ 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 c04544084d5..72e3dcd0bd1 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts @@ -966,6 +966,10 @@ export type EmailAttachment = { }; export type EmailSendingItem = { + /** + * Worker that owns the send_email binding the message was sent through, if known. + */ + worker?: string; from: string; to: Array; cc?: Array; @@ -984,6 +988,10 @@ export type EmailSendingItem = { }; export type EmailSendingDetail = { + /** + * Worker that owns the send_email binding the message was sent through, if known. + */ + worker?: string; from: string; to: Array; cc?: Array; @@ -1702,7 +1710,12 @@ export type LocalExplorerListWorkersResponse = export type EmailListRoutingData = { body?: never; path?: never; - query?: never; + query?: { + /** + * Only return emails received by this worker's email() handler. + */ + worker?: string; + }; url: "/email/routing"; }; @@ -1731,7 +1744,12 @@ export type EmailListRoutingResponse = export type EmailSendRoutingData = { body: EmailSendRequest; path?: never; - query?: never; + query?: { + /** + * Deliver the test email to this worker's email() handler, regardless of address-based routing. + */ + worker?: string; + }; url: "/email/routing/send"; }; @@ -1775,7 +1793,12 @@ export type EmailGetRoutingData = { path: { email_id: string; }; - query?: never; + query?: { + /** + * Only return the email if it was received by this worker's email() handler. + */ + worker?: string; + }; url: "/email/routing/{email_id}"; }; @@ -1804,7 +1827,12 @@ export type EmailGetRoutingResponse = export type EmailListSendingData = { body?: never; path?: never; - query?: never; + query?: { + /** + * Only return emails sent through this worker's send_email bindings. + */ + worker?: string; + }; url: "/email/sending"; }; @@ -1835,7 +1863,12 @@ export type EmailGetSendingData = { path: { email_id: string; }; - query?: never; + query?: { + /** + * Only return the email if it was sent through this worker's send_email bindings. + */ + worker?: string; + }; url: "/email/sending/{email_id}"; }; 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 6484ea5cd46..438506986ed 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts @@ -622,6 +622,7 @@ export const zEmailRoutingDetail = z.object({ }); export const zEmailSendingItem = z.object({ + worker: z.string().optional(), from: z.string(), to: z.array(z.string()), cc: z.array(z.string()).optional(), @@ -630,11 +631,12 @@ export const zEmailSendingItem = z.object({ subject: z.string(), messageId: z.string(), sentAt: z.string(), - headers: z.record(z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), attachments: z.array(zEmailAttachment), }); export const zEmailSendingDetail = z.object({ + worker: z.string().optional(), from: z.string(), to: z.array(z.string()), cc: z.array(z.string()).optional(), @@ -1054,7 +1056,11 @@ export const zLocalExplorerListWorkersResponse = zWorkersApiResponseCommon.and( export const zEmailListRoutingData = z.object({ body: z.never().optional(), path: z.never().optional(), - query: z.never().optional(), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), }); /** @@ -1069,7 +1075,11 @@ export const zEmailListRoutingResponse = zWorkersApiResponseCommon.and( export const zEmailSendRoutingData = z.object({ body: zEmailSendRequest, path: z.never().optional(), - query: z.never().optional(), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), }); /** @@ -1092,7 +1102,11 @@ export const zEmailGetRoutingData = z.object({ path: z.object({ email_id: z.string(), }), - query: z.never().optional(), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), }); /** @@ -1107,7 +1121,11 @@ export const zEmailGetRoutingResponse = zWorkersApiResponseCommon.and( export const zEmailListSendingData = z.object({ body: z.never().optional(), path: z.never().optional(), - query: z.never().optional(), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), }); /** @@ -1124,7 +1142,11 @@ export const zEmailGetSendingData = z.object({ path: z.object({ email_id: z.string(), }), - query: z.never().optional(), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), }); /** diff --git a/packages/miniflare/src/workers/local-explorer/openapi.local.json b/packages/miniflare/src/workers/local-explorer/openapi.local.json index 6b7315d6675..9521ab9df18 100644 --- a/packages/miniflare/src/workers/local-explorer/openapi.local.json +++ b/packages/miniflare/src/workers/local-explorer/openapi.local.json @@ -1286,7 +1286,16 @@ "get": { "description": "Lists emails received by the worker's email() handler during this dev session.", "operationId": "email-list-routing", - "parameters": [], + "parameters": [ + { + "in": "query", + "name": "worker", + "schema": { + "type": "string" + }, + "description": "Only return emails received by this worker's email() handler." + } + ], "responses": { "200": { "content": { @@ -1332,6 +1341,16 @@ "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 other to/cc/bcc addresses appear only in the composed MIME headers.", "operationId": "email-send-routing", + "parameters": [ + { + "in": "query", + "name": "worker", + "schema": { + "type": "string" + }, + "description": "Deliver the test email to this worker's email() handler, regardless of address-based routing." + } + ], "requestBody": { "required": true, "content": { @@ -1407,6 +1426,14 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "worker", + "schema": { + "type": "string" + }, + "description": "Only return the email if it was received by this worker's email() handler." } ], "responses": { @@ -1451,7 +1478,16 @@ "get": { "description": "Lists emails sent through send_email bindings during this dev session.", "operationId": "email-list-sending", - "parameters": [], + "parameters": [ + { + "in": "query", + "name": "worker", + "schema": { + "type": "string" + }, + "description": "Only return emails sent through this worker's send_email bindings." + } + ], "responses": { "200": { "content": { @@ -1505,6 +1541,14 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "worker", + "schema": { + "type": "string" + }, + "description": "Only return the email if it was sent through this worker's send_email bindings." } ], "responses": { @@ -4000,6 +4044,10 @@ "email_sending-item": { "type": "object", "properties": { + "worker": { + "type": "string", + "description": "Worker that owns the send_email binding the message was sent through, if known." + }, "from": { "type": "string" }, @@ -4059,6 +4107,10 @@ "email_sending-detail": { "type": "object", "properties": { + "worker": { + "type": "string", + "description": "Worker that owns the send_email binding the message was sent through, if known." + }, "from": { "type": "string" }, From 6be0341246727bd7a3be6aea2391adf7c5c07045 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 15:32:18 +0100 Subject: [PATCH 11/13] [miniflare] Support multi-worker email exploration --- .../workers/local-explorer/resources/email.ts | 348 +++++++++++++++--- 1 file changed, 305 insertions(+), 43 deletions(-) diff --git a/packages/miniflare/src/workers/local-explorer/resources/email.ts b/packages/miniflare/src/workers/local-explorer/resources/email.ts index 02896a54932..54bedc6c258 100644 --- a/packages/miniflare/src/workers/local-explorer/resources/email.ts +++ b/packages/miniflare/src/workers/local-explorer/resources/email.ts @@ -2,12 +2,18 @@ import { getPublicUrl } from "miniflare:shared"; import { decodeWords } from "postal-mime"; import { z } from "zod"; import { CoreBindings, CorePaths } from "../../core"; +import { handleEmail } from "../../core/email"; import { MAX_LOCAL_EMAIL_BYTES } from "../../email/capture"; import { getHeader, messageIdToStorageId, synthesizeMessageId, } from "../../email/message-id"; +import { + aggregateListResults, + fetchFromPeer, + getPeerUrlsIfAggregating, +} from "../aggregation"; import { errorResponse, wrapResponse } from "../common"; import { zEmailHandlerEvent, @@ -20,7 +26,7 @@ import { } from "../generated/zod.gen"; import type { EmailStoreService } from "../../email/storage"; import type { AppContext } from "../common"; -import type { EmailSendRequest } from "../generated"; +import type { EmailSendRequest, LocalExplorerWorker } from "../generated"; const EMAIL_ERROR_NOT_FOUND = 10601; const EMAIL_ERROR_SEND_FAILED = 10602; @@ -40,6 +46,77 @@ function getEmailStore(c: AppContext): EmailStoreService | undefined { return c.env[CoreBindings.SERVICE_EMAIL_STORE]; } +function isFetcher(value: unknown): value is Fetcher { + return ( + typeof value === "object" && + value !== null && + "fetch" in value && + typeof value.fetch === "function" + ); +} + +/** Whether the given worker is served by this Miniflare instance. */ +function isLocalWorker(c: AppContext, worker: string): boolean { + return c.env[CoreBindings.JSON_LOCAL_EXPLORER_WORKER_NAMES].includes(worker); +} + +/** + * Resolves a direct service binding to a user worker in this instance, used to + * invoke that worker's `email()` handler for "Send Test Email". These bindings + * are registered per worker by `getExplorerServices` (see the + * `SERVICE_EXPLORER_USER_WORKER_PREFIX` bindings). + */ +function getUserWorkerService( + c: AppContext, + worker: string +): Fetcher | undefined { + const service = + c.env[`${CoreBindings.SERVICE_EXPLORER_USER_WORKER_PREFIX}${worker}`]; + return isFetcher(service) ? service : undefined; +} + +/** + * Keeps only the emails belonging to `worker`. Returns all with no 'worker' + */ +function filterByWorker( + emails: T[], + worker: string | undefined +): T[] { + if (worker === undefined) { + return emails; + } + return emails.filter((email) => email.worker === worker); +} + +/** + * Finds the peer instance that serves `worker` by asking each peer which workers + * it hosts. Returns the peer's debug port address, or null when no peer owns it. + */ +async function findWorkerOwner( + c: AppContext, + peerUrls: string[], + worker: string +): Promise { + const responses = await Promise.all( + peerUrls.map(async (url) => { + const response = await fetchFromPeer(url, "/local/workers"); + if (!response?.ok) { + return null; + } + try { + const data = (await response.json()) as { + result?: LocalExplorerWorker[]; + }; + const owns = data.result?.some((w) => w.name === worker) ?? false; + return owns ? url : null; + } catch { + return null; + } + }) + ); + return responses.find((url) => url !== null) ?? null; +} + function extractAddress(value: string): string { const match = value.match(/<([^>]+)>/); return (match ? match[1] : value).trim(); @@ -49,6 +126,14 @@ function hasUnsafeHeaderCharacters(value: string): boolean { return /[\u0000-\u001f\u007f]/u.test(value); } +function decodeEmailHeaders(raw: string): string { + const separator = /\r?\n\r?\n/u.exec(raw); + if (separator?.index === undefined) { + return decodeWords(raw); + } + return `${decodeWords(raw.slice(0, separator.index))}${raw.slice(separator.index)}`; +} + function isHeaderName(value: string): boolean { return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(value); } @@ -114,9 +199,6 @@ function buildMimeMessage(body: EmailSendRequest, messageId: string): string { if (body.cc?.length) { headers.push(`Cc: ${body.cc.join(", ")}`); } - if (body.bcc?.length) { - headers.push(`Bcc: ${body.bcc.join(", ")}`); - } if (body.replyTo) { headers.push(`Reply-To: ${body.replyTo}`); } @@ -203,7 +285,10 @@ function buildMimeMessage(body: EmailSendRequest, messageId: string): string { return `${headers.join("\r\n")}\r\n\r\n${parts.join("\r\n")}`; } -export async function listReceivedEmails(c: AppContext): Promise { +export async function listReceivedEmails( + c: AppContext, + worker?: string +): Promise { const store = getEmailStore(c); if (!store) { return errorResponse( @@ -212,13 +297,17 @@ export async function listReceivedEmails(c: AppContext): Promise { "Email store is not available for this dev session." ); } - const emails = z.array(zEmailRoutingItem).parse(await store.listReceived()); - return c.json(wrapResponse(emails)); + const local = z.array(zEmailRoutingItem).parse(await store.listReceived()); + // Merge in emails captured by workers running in other Miniflare instances so + // the inbox reflects the whole dev session, then narrow to the selected worker. + const emails = await aggregateListResults(c, local, "/email/routing"); + return c.json(wrapResponse(filterByWorker(emails, worker))); } export async function getReceivedEmail( c: AppContext, - emailId: string + emailId: string, + worker?: string ): Promise { const store = getEmailStore(c); if (!store) { @@ -228,38 +317,199 @@ export async function getReceivedEmail( "Email store is not available for this dev session." ); } - const email = await store.findReceived(emailId); + const email = await store.findReceived(messageIdToStorageId(emailId)); if (!email) { - return errorResponse( - 404, - EMAIL_ERROR_NOT_FOUND, - `Email '${emailId}' not found.` - ); + // 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. The lossless bytes - // remain available through rawBase64. + // display text so the explorer shows readable subjects. const decoded = { ...email, replies: email.replies.map((reply) => ({ ...reply, - raw: decodeWords(reply.raw), + raw: decodeEmailHeaders(reply.raw), })), }; return c.json(wrapResponse(zEmailRoutingDetail.parse(decoded))); } +/** + * Looks up an email by id on peer instances. When a `worker` is selected we ask + * the peer that owns it; otherwise (the unfiltered view) we broadcast the lookup + * to every peer and return the first hit, so a peer-owned email can still be + * opened when no worker is selected. + * + * @param basePath - The peer API path for the detail endpoint, e.g. + * `/email/routing` or `/email/sending`. + */ +async function findEmailOnPeers( + c: AppContext, + basePath: string, + emailId: string, + worker: string | undefined +): Promise { + const encodedId = encodeURIComponent(emailId); + + if (worker !== undefined) { + // A specific worker is selected: only the owning peer can hold it. + if (!isLocalWorker(c, worker)) { + const owner = await findWorkerOwner( + c, + await getPeerUrlsIfAggregating(c), + worker + ); + if (owner) { + const response = await fetchFromPeer( + owner, + `${basePath}/${encodedId}?worker=${encodeURIComponent(worker)}` + ); + if (response?.ok) { + return response; + } + } + } + } else { + // Unfiltered view: the email could live on any peer, so ask them all and + // return the first that has it. + const peerUrls = await getPeerUrlsIfAggregating(c); + const responses = await Promise.all( + peerUrls.map((url) => fetchFromPeer(url, `${basePath}/${encodedId}`)) + ); + const found = responses.find((response) => response?.ok); + if (found) { + return found; + } + } + + return errorResponse( + 404, + EMAIL_ERROR_NOT_FOUND, + `Email '${emailId}' not found.` + ); +} + +/** + * Proxies a received-email lookup to a peer. Used when the email is not held by + * this instance's store. + */ +async function getReceivedEmailFromPeers( + c: AppContext, + emailId: string, + worker: string | undefined +): Promise { + return findEmailOnPeers(c, "/email/routing", emailId, worker); +} + +/** + * Delivers a built test email to the selected worker's `email()` handler. + * + * When a `worker` is selected resolve a direct service binding to it and + * invoke `handleEmail`. This avoids routing the delivery back + * through the entry worker. + * + * When no worker is selected deliver via the entry worker's public URL. + * + * 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 | undefined; + } +): 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"); + + if (worker === undefined) { + // No specific worker: let the entry worker route by address. + const entryUrl = await getPublicUrl(c.env.MINIFLARE_LOOPBACK); + const publicDeliverUrl = new URL(deliverUrl.pathname, entryUrl); + publicDeliverUrl.search = deliverUrl.search; + return fetch(publicDeliverUrl, { method: "POST", body: mime }); + } + + 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 + body: EmailSendRequest, + worker?: string ): Promise { const invalidRequest = validateEmailRequest(body); if (invalidRequest !== undefined) { return errorResponse(400, 10000, invalidRequest); } + + // When the selected worker lives in another Miniflare instance, forward the + // whole send to the instance that owns it. + if (worker !== undefined && !isLocalWorker(c, worker)) { + const owner = await findWorkerOwner( + c, + await getPeerUrlsIfAggregating(c), + worker + ); + if (owner) { + const response = await fetchFromPeer( + owner, + `/email/routing/send?worker=${encodeURIComponent(worker)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); + if (response) { + return response; + } + } + return errorResponse( + 400, + EMAIL_ERROR_SEND_FAILED, + `Worker '${worker}' is not available in this dev session.` + ); + } + const from = extractAddress(body.from); const to = extractAddress(body.to[0] ?? ""); @@ -267,13 +517,9 @@ export async function sendTestEmail( return errorResponse(400, 10000, "At least one recipient is required."); } - // Derive the Message-ID exactly as the `send_email` binding does, so a - // received and a sent email agree on it. Honour one the caller set - // explicitly, since the send dialog allows custom headers. + // Derive the Message-ID exactly as the `send_email` binding does. const messageId = getHeader(body.headers, "Message-ID") ?? synthesizeMessageId(from); - // TODO(miniflare v5): switch on-disk file naming to a mimetext-style id - // to unify the file name with the Message-ID seen in local explorer. const id = messageIdToStorageId(messageId); const mime = buildMimeMessage(body, messageId); if (new TextEncoder().encode(mime).byteLength > MAX_LOCAL_EMAIL_BYTES) { @@ -284,15 +530,14 @@ export async function sendTestEmail( ); } - const entryUrl = await getPublicUrl(c.env.MINIFLARE_LOOPBACK); - const deliverUrl = new URL(CorePaths.EMAIL, entryUrl); - 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 response = await fetch(deliverUrl, { method: "POST", body: mime }); + 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 @@ -318,7 +563,10 @@ export async function sendTestEmail( ); } -export async function listSentEmails(c: AppContext): Promise { +export async function listSentEmails( + c: AppContext, + worker?: string +): Promise { const store = getEmailStore(c); if (!store) { return errorResponse( @@ -327,13 +575,17 @@ export async function listSentEmails(c: AppContext): Promise { "Email store is not available for this dev session." ); } - const emails = z.array(zEmailSendingItem).parse(await store.listSent()); - return c.json(wrapResponse(emails)); + const local = z.array(zEmailSendingItem).parse(await store.listSent()); + // Merge in emails sent by workers running in other Miniflare instances so the + // list reflects the whole dev session, then narrow to the selected worker. + const emails = await aggregateListResults(c, local, "/email/sending"); + return c.json(wrapResponse(filterByWorker(emails, worker))); } export async function getSentEmail( c: AppContext, - emailId: string + emailId: string, + worker?: string ): Promise { const store = getEmailStore(c); if (!store) { @@ -343,13 +595,23 @@ export async function getSentEmail( "Email store is not available for this dev session." ); } - const email = await store.findSent(emailId); - if (!email) { - return errorResponse( - 404, - EMAIL_ERROR_NOT_FOUND, - `Email '${emailId}' not found.` - ); + const email = await store.findSent(messageIdToStorageId(emailId)); + if (!email || (worker !== undefined && email.worker !== worker)) { + // The email may have been sent by a worker in another Miniflare instance; + // look it up there before giving up. + return getSentEmailFromPeers(c, emailId, worker); } return c.json(wrapResponse(zEmailSendingDetail.parse(email))); } + +/** + * Proxies a sent-email lookup to a peer. Used when the email is not held by this + * instance's store. + */ +async function getSentEmailFromPeers( + c: AppContext, + emailId: string, + worker: string | undefined +): Promise { + return findEmailOnPeers(c, "/email/sending", emailId, worker); +} From f7ce2461a5ee5201b846348d3866922e5483d108 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 16:34:08 +0100 Subject: [PATCH 12/13] [miniflare][wrangler] Added/Updated tests (inc. E2E) to cover email storage and api exposure. --- packages/miniflare/test/index.spec.ts | 49 +- .../test/plugins/email/artifacts.spec.ts | 141 ++++ .../test/plugins/email/index.spec.ts | 297 ++++--- .../test/plugins/local-explorer/email.spec.ts | 776 ++++++++++++++++++ .../test/plugins/local-explorer/index.spec.ts | 3 + .../wrangler/e2e/createTestHarness.test.ts | 13 +- packages/wrangler/e2e/dev.test.ts | 144 ++++ .../wrangler/e2e/get-platform-proxy.test.ts | 2 +- packages/wrangler/e2e/multiworker-dev.test.ts | 73 ++ 9 files changed, 1343 insertions(+), 155 deletions(-) create mode 100644 packages/miniflare/test/plugins/email/artifacts.spec.ts create mode 100644 packages/miniflare/test/plugins/local-explorer/email.spec.ts diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index 64e3c16cdf8..b45b9f2939d 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -1946,6 +1946,44 @@ 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, + modules: true, + script: ` + export default { + fetch() { + return new Response("ok"); + } + }`, + unsafeTriggerHandlers: true, + }); + useDispose(mf); + + const res = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?from=someone@example.com&to=someone-else@example.com", + { + body: `From: someone +To: someone else +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain + +This is a random email body. +`, + method: "POST", + } + ); + expect(await res.text()).toBe( + "Worker does not export an email() handler; message stored without delivery." + ); + expect(res.status).toBe(500); +}); + test("Miniflare: manually triggered email handler - reply handler works", async ({ expect, }) => { @@ -2071,7 +2109,10 @@ test("Miniflare: manually triggered email handler - structured result", async ({ timestamp: string; messageId: string; } - | { type: "reject"; timestamp: string } + | { + type: "received" | "reject" | "unhandled"; + timestamp: string; + } )[]; }; } @@ -2095,6 +2136,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), @@ -2115,6 +2160,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) }, ]); @@ -2137,6 +2183,7 @@ test("Miniflare: manually triggered email handler - structured result", async ({ ], }); expect(exceptionResult.events).toEqual([ + { type: "received", timestamp: expect.any(String) }, { type: "forward", timestamp: expect.any(String), diff --git a/packages/miniflare/test/plugins/email/artifacts.spec.ts b/packages/miniflare/test/plugins/email/artifacts.spec.ts new file mode 100644 index 00000000000..dc04f8be4b2 --- /dev/null +++ b/packages/miniflare/test/plugins/email/artifacts.spec.ts @@ -0,0 +1,141 @@ +import { describe, test, vi } from "vitest"; +import { EmailArtifactManager } from "../../../src/plugins/email/artifacts"; + +const ARTIFACT = { + recordId: "message-id@example.com", + prefix: "email", + id: "message-id@example.com", + extension: "eml", +}; + +function deferred() { + let resolvePromise: ((value: T | PromiseLike) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve(value: T) { + if (resolvePromise === undefined) { + throw new Error("Deferred promise was already resolved"); + } + resolvePromise(value); + }, + }; +} + +describe("EmailArtifactManager", () => { + test("serializes writes for the same artifact", async ({ expect }) => { + const manager = new EmailArtifactManager(); + const firstWrite = deferred(); + const writes: string[] = []; + + const first = manager.store(ARTIFACT, async () => { + writes.push("first"); + await firstWrite.promise; + return "/first.eml"; + }); + const second = manager.store(ARTIFACT, async () => { + writes.push("second"); + return "/second.eml"; + }); + + await vi.waitFor(() => expect(writes).toEqual(["first"])); + firstWrite.resolve(undefined); + + expect(await first).toBe("/first.eml"); + expect(await second).toBe("/second.eml"); + expect(writes).toEqual(["first", "second"]); + }); + + test("tombstones a queued write when its record is deleted", async ({ + expect, + }) => { + const manager = new EmailArtifactManager(); + const firstWrite = deferred(); + let removed: (typeof ARTIFACT)[] = []; + let firstStarted = false; + + const first = manager.store(ARTIFACT, async () => { + firstStarted = true; + await firstWrite.promise; + return "/first.eml"; + }); + await vi.waitFor(() => expect(firstStarted).toBe(true)); + const second = manager.store(ARTIFACT, async () => "/second.eml"); + const deletion = manager.delete([ARTIFACT], async (artifacts) => { + removed = artifacts; + }); + + firstWrite.resolve(undefined); + + expect(await first).toBe("/first.eml"); + expect(await second).toBeNull(); + await deletion; + expect(removed).toEqual([ARTIFACT]); + }); + + test("dispose clears deletion tombstones", async ({ expect }) => { + const manager = new EmailArtifactManager(); + const firstWrite = deferred(); + let firstStarted = false; + + const first = manager.store(ARTIFACT, async () => { + firstStarted = true; + await firstWrite.promise; + return "/first.eml"; + }); + await vi.waitFor(() => expect(firstStarted).toBe(true)); + const second = manager.store(ARTIFACT, async () => "/second.eml"); + const deletion = manager.delete([ARTIFACT], async () => undefined); + manager.dispose(); + firstWrite.resolve(undefined); + + expect(await first).toBe("/first.eml"); + expect(await second).toBe("/second.eml"); + await deletion; + }); + + test("normalizes artifacts before removing them", async ({ expect }) => { + const manager = new EmailArtifactManager(); + let removed: Array = []; + + await manager.delete( + [ + { + recordId: "../record", + prefix: "../email", + id: "../message", + extension: "../eml", + }, + ], + async (artifacts) => { + removed = artifacts; + } + ); + + expect(removed).toHaveLength(1); + expect(removed[0]).toBeDefined(); + expect(JSON.stringify(removed[0])).not.toContain(".."); + }); + + test("drain waits for pending operations", async ({ expect }) => { + const manager = new EmailArtifactManager(); + const write = deferred(); + let completed = false; + + void manager.store(ARTIFACT, async () => { + await write.promise; + completed = true; + return "/message.eml"; + }); + + const draining = manager.drain(); + await Promise.resolve(); + expect(completed).toBe(false); + + write.resolve(undefined); + await draining; + expect(completed).toBe(true); + }); +}); diff --git a/packages/miniflare/test/plugins/email/index.spec.ts b/packages/miniflare/test/plugins/email/index.spec.ts index 0f3dda90933..fdfb66ad4f7 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 { @@ -1314,7 +1314,7 @@ test("MessageBuilder log output format snapshot", async ({ expect }) => { .replace(/\x1b\[[0-9;]*m/g, "") // Replace dynamic file paths with placeholders (Unix and Windows) .replace( - /(?:[A-Z]:\\|\/)[^\s]*[/\\](email-text|email-html|email-attachment)[/\\][a-f0-9-]+\.(txt|html|png|pdf)/g, + /(?:[A-Z]:\\|\/)[^\s]*[/\\](email-text|email-html|email-attachment)[/\\][^/\\\s]+\.(txt|html|png|pdf)/g, "/$1/[FILE].$2" ); @@ -1956,10 +1956,10 @@ const SEND_EMAIL_RETURNS_RESULT_WORKER = dedent /* javascript */ ` `; // Both branches return an id in the shape production returns: -// `<{36 alphanumeric chars}@{sender domain}>`, angle brackets included. +// `<{base36 random}@{sender domain}>`, angle brackets included. function synthesizedMessageId(expect: ExpectStatic, domain: string) { return expect.stringMatching( - new RegExp(`^<[A-Za-z0-9]{36}@${domain.replace(/\./g, "\\.")}>$`) + new RegExp(`^<[A-Za-z0-9]+@${domain.replace(/\./g, "\\.")}>$`) ); } @@ -2001,6 +2001,46 @@ test("send() on an EmailMessage returns a synthesized messageId", async ({ }); }); +test("send() on an EmailMessage larger than 1 MiB still succeeds", async ({ + expect, +}) => { + const mf = new Miniflare({ + modules: true, + script: SEND_EMAIL_RETURNS_RESULT_WORKER, + email: { + send_email: [{ name: "SEND_EMAIL" }], + }, + compatibilityDate: "2025-03-17", + }); + + useDispose(mf); + + const email = + [ + "From: someone ", + "To: someone else ", + "Message-ID: ", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + "x".repeat(2 * 1024 * 1024), + ].join("\r\n") + "\r\n"; + + const res = await mf.dispatchFetch( + "http://localhost/?" + + new URLSearchParams({ + from: "someone@sender.domain", + to: "someone-else@example.com", + }).toString(), + { body: email, method: "POST" } + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + messageId: synthesizedMessageId(expect, "sender.domain"), + }); +}); + test("send() on a MessageBuilder returns a synthesized messageId", async ({ expect, }) => { @@ -2039,6 +2079,44 @@ test("send() on a MessageBuilder returns a synthesized messageId", async ({ }); }); +test("send() on a MessageBuilder larger than 1 MiB still succeeds", async ({ + expect, +}) => { + const mf = new Miniflare({ + modules: true, + script: 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); + }, + }; + `, + email: { + send_email: [{ name: "SEND_EMAIL" }], + }, + compatibilityDate: "2025-03-17", + }); + + useDispose(mf); + + const res = await mf.dispatchFetch("http://localhost", { + method: "POST", + body: JSON.stringify({ + from: "sender@sender.domain", + to: "recipient@example.com", + subject: "Large builder", + text: "y".repeat(2 * 1024 * 1024), + }), + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + messageId: synthesizedMessageId(expect, "sender.domain"), + }); +}); + test("send_email binding is available from getBindings", async ({ expect }) => { const mf = new Miniflare({ modules: true, @@ -2079,43 +2157,56 @@ test("disposing does not remove a concurrent email session", async ({ const projectTmpPath = await useProjectTmpPath(); const mf = new Miniflare({ modules: true, - script: "", + script: MESSAGE_BUILDER_WORKER, email: { send_email: [{ name: "SEND_EMAIL" }], }, resourceTmpPath: projectTmpPath, compatibilityDate: "2025-03-17", }); + let disposed = false; - 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); + try { + const response = await mf.dispatchFetch("http://localhost", { + method: "POST", + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Concurrent session", + text: "Creates a project email session", + }), + }); + expect(await response.text()).toBe("ok"); - // A separate emptiness check reintroduces the race. Return a stale result so - // regressing to read-then-remove would delete the concurrent session. - const readdirSpy = vi.spyOn(fs.promises, "readdir").mockResolvedValueOnce([]); + const emailParentPath = path.join(projectTmpPath, "email"); + const sessionName = await vi.waitFor(async () => { + const sessions = await readdir(emailParentPath); + if (sessions[0] === undefined) { + throw new Error("Expected an email session directory"); + } + return sessions[0]; + }); + const concurrentSessionPath = path.join( + emailParentPath, + "concurrent-session" + ); + await mkdir(concurrentSessionPath); - await mf.dispose(); + await mf.dispose(); + disposed = true; - expect(readdirSpy).not.toHaveBeenCalled(); - expect(existsSync(concurrentSessionPath)).toBe(true); + expect(existsSync(path.join(emailParentPath, sessionName))).toBe(false); + expect(existsSync(concurrentSessionPath)).toBe(true); + } finally { + if (!disposed) { + await mf.dispose(); + } + } }); describe("EMAIL_PLUGIN.getServices", () => { - test("creates disk services for system temp and project directories", async ({ - expect, - }) => { + test("creates a worker-scoped send_email service", async ({ expect }) => { const tmp = await useTmp(); - const projectTmpPath = path.join(tmp, ".wrangler", "tmp"); const result = await EMAIL_PLUGIN.getServices({ options: { @@ -2123,7 +2214,7 @@ describe("EMAIL_PLUGIN.getServices", () => { }, sharedOptions: {}, tmpPath: tmp, - resourceTmpPath: projectTmpPath, + resourceTmpPath: undefined, workerNames: ["default"], workerIndex: 0, } as unknown as Parameters[0]); @@ -2133,80 +2224,23 @@ describe("EMAIL_PLUGIN.getServices", () => { } const services = result; - expect(services).toHaveLength(3); - - const diskServices = services.filter((s) => "disk" in s) as Array<{ - name: string; - disk: { path: string; writable?: boolean }; - }>; - expect(diskServices).toHaveLength(2); - - const systemTempDisk = diskServices.find( - (s) => s.name === "email:disk:system" - ); - const projectDisk = diskServices.find( - (s) => s.name === "email:disk:project" - ); - if (!systemTempDisk || !projectDisk) { - throw new Error("Expected both disk services to be present"); - } - - // System temp directory - expect(systemTempDisk.disk.path).toBe(path.join(tmp, "email")); - expect(existsSync(systemTempDisk.disk.path)).toBe(true); - - // Project temp directory - expect(projectDisk.disk.path).toBe( - path.join(projectTmpPath, "email", path.basename(tmp)) - ); - expect(existsSync(projectDisk.disk.path)).toBe(true); - - const workerService = services.find( - (s) => s.name === "SEND-EMAIL-WORKER:SEND_EMAIL" - ) as - | { - name: string; - worker: { bindings: { name: string; json?: string }[] }; - } - | undefined; - if (!workerService) { + expect(services).toHaveLength(1); + expect(services[0]?.name).toBe("SEND-EMAIL-WORKER:default:SEND_EMAIL"); + if (services[0] === undefined || !("worker" in services[0])) { throw new Error("Expected send_email worker service to be present"); } - - const bindings = workerService.worker.bindings; - - // Each disk service is bound so the worker can write to it via fetch. - const systemServiceBinding = bindings.find( - (b) => b.name === "MINIFLARE_EMAIL_DISK_SYSTEM" - ) as { name: string; service?: { name: string } } | undefined; - const projectServiceBinding = bindings.find( - (b) => b.name === "MINIFLARE_EMAIL_DISK_PROJECT" - ) as { name: string; service?: { name: string } } | undefined; - expect(systemServiceBinding?.service?.name).toBe("email:disk:system"); - expect(projectServiceBinding?.service?.name).toBe("email:disk:project"); - - const emailDiskServicesBinding = bindings.find( - (b) => b.name === "email_disk_services" - ); - if (!emailDiskServicesBinding?.json) { - throw new Error("Expected email_disk_services binding with JSON value"); + const worker = services[0].worker; + if (worker === undefined) { + throw new Error("Expected send_email worker service configuration"); } - - const emailDiskServices = JSON.parse(emailDiskServicesBinding.json); - expect(emailDiskServices).toHaveLength(2); - expect(emailDiskServices[0].bindingName).toBe( - "MINIFLARE_EMAIL_DISK_SYSTEM" - ); - expect(emailDiskServices[0].location).toBe("system"); - expect(emailDiskServices[0].path).toBe(path.join(tmp, "email")); - expect(emailDiskServices[1].bindingName).toBe( - "MINIFLARE_EMAIL_DISK_PROJECT" - ); - expect(emailDiskServices[1].location).toBe("project"); - expect(emailDiskServices[1].path).toBe(projectDisk.disk.path); + expect( + (worker.bindings ?? []).some( + (binding) => binding.name === "MINIFLARE_EMAIL_STORE" + ) + ).toBe(false); }); - test("creates only system disk service when resourceTmpPath is undefined", async ({ + test("binds the email store and owning worker for local explorer", async ({ expect, }) => { const tmp = await useTmp(); @@ -2215,9 +2249,9 @@ describe("EMAIL_PLUGIN.getServices", () => { options: { email: { send_email: [{ name: "SEND_EMAIL" }] }, }, - sharedOptions: {}, + sharedOptions: { unsafeLocalExplorer: true }, tmpPath: tmp, - resourceTmpPath: undefined, + resourceTmpPath: path.join(tmp, ".wrangler", "tmp"), workerNames: ["default"], workerIndex: 0, } as unknown as Parameters[0]); @@ -2227,62 +2261,21 @@ describe("EMAIL_PLUGIN.getServices", () => { } const services = result; - expect(services).toHaveLength(2); - - const diskServices = services.filter((s) => "disk" in s) as Array<{ - name: string; - disk: { path: string; writable?: boolean }; - }>; - expect(diskServices).toHaveLength(1); - - const systemTempDisk = diskServices.find( - (s) => s.name === "email:disk:system" - ); - if (!systemTempDisk) { - throw new Error("Expected system disk service to be present"); - } - - expect(systemTempDisk.disk.path).toBe(path.join(tmp, "email")); - expect(existsSync(systemTempDisk.disk.path)).toBe(true); - - const workerService = services.find( - (s) => s.name === "SEND-EMAIL-WORKER:SEND_EMAIL" - ) as - | { - name: string; - worker: { bindings: { name: string; json?: string }[] }; - } - | undefined; - if (!workerService) { + expect(services).toHaveLength(1); + if (services[0] === undefined || !("worker" in services[0])) { throw new Error("Expected send_email worker service to be present"); } - - const bindings = workerService.worker.bindings; - - const systemServiceBinding = bindings.find( - (b) => b.name === "MINIFLARE_EMAIL_DISK_SYSTEM" - ) as { name: string; service?: { name: string } } | undefined; - expect(systemServiceBinding?.service?.name).toBe("email:disk:system"); - - const projectServiceBinding = bindings.find( - (b) => b.name === "MINIFLARE_EMAIL_DISK_PROJECT" - ); - expect(projectServiceBinding).toBeUndefined(); - - const emailDiskServicesBinding = bindings.find( - (b) => b.name === "email_disk_services" - ); - if (!emailDiskServicesBinding?.json) { - throw new Error("Expected email_disk_services binding with JSON value"); + const worker = services[0].worker; + if (worker === undefined) { + throw new Error("Expected send_email worker service configuration"); } - - const emailDiskServices = JSON.parse(emailDiskServicesBinding.json); - expect(emailDiskServices).toHaveLength(1); - expect(emailDiskServices[0].bindingName).toBe( - "MINIFLARE_EMAIL_DISK_SYSTEM" - ); - expect(emailDiskServices[0].location).toBe("system"); - expect(emailDiskServices[0].path).toBe(path.join(tmp, "email")); + const bindings = worker.bindings ?? []; + expect( + bindings.find((binding) => binding.name === "MINIFLARE_EMAIL_STORE") + ).toMatchObject({ service: { name: "email:store" } }); + expect( + bindings.find((binding) => binding.name === "SEND_EMAIL_OWNER_WORKER") + ).toMatchObject({ json: JSON.stringify("default") }); }); }); diff --git a/packages/miniflare/test/plugins/local-explorer/email.spec.ts b/packages/miniflare/test/plugins/local-explorer/email.spec.ts new file mode 100644 index 00000000000..8a95a108323 --- /dev/null +++ b/packages/miniflare/test/plugins/local-explorer/email.spec.ts @@ -0,0 +1,776 @@ +import { Buffer } from "node:buffer"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { removeDirSync } from "@cloudflare/workers-utils"; +import { Miniflare } from "miniflare"; +import dedent from "ts-dedent"; +import { afterAll, beforeAll, describe, test } from "vitest"; +import { CorePaths } from "../../../src/workers/core/constants"; +import { MAX_LOCAL_EMAIL_BYTES } from "../../../src/workers/email/capture"; +import { + zEmailGetRoutingResponse, + zEmailGetSendingResponse, + zEmailListRoutingResponse, + zEmailListSendingResponse, + zWorkersApiResponseCommonFailure, +} from "../../../src/workers/local-explorer/generated/zod.gen"; +import { disposeWithRetry, waitForWorkersInRegistry } from "../../test-shared"; +import { expectValidResponse } from "./helpers"; + +const BASE_URL = `http://localhost${CorePaths.EXPLORER}/api`; +const WORKER_NAME = "email-worker"; + +const EMAIL_WORKER = dedent /* javascript */ ` + import { EmailMessage } from "cloudflare:email"; + + export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/send-raw") { + const message = await env.SEND_EMAIL.send(new EmailMessage( + url.searchParams.get("from"), + url.searchParams.get("to"), + request.body + )); + return Response.json(message); + } + + if (url.pathname === "/send-builder") { + return Response.json(await env.SEND_EMAIL.send(await request.json())); + } + + return new Response("ok"); + }, + + async email(message) { + const mode = message.headers.get("x-test-mode"); + if (mode === "forward") { + await message.forward("forwarded@example.com"); + } else if (mode === "reply") { + await message.reply( + new EmailMessage( + "reply@example.com", + message.from, + "From: reply@example.com\\n" + + "To: sender@example.com\\n" + + "Subject: =?UTF-8?B?UmVwbHkgc3ViamVjdA==?=\\n" + + "In-Reply-To: \\n" + + "Message-ID: \\n" + + "MIME-Version: 1.0\\n" + + "Content-Type: text/plain\\n\\n" + + "Body literal =?UTF-8?B?U2hvdWxkIHN0YXkgcmF3?=" + ) + ); + } else if (mode === "reply-large") { + const filler = "z".repeat(2 * 1024 * 1024); + await message.reply( + new EmailMessage( + "reply@example.com", + message.from, + "From: reply@example.com\\n" + + "To: sender@example.com\\n" + + "Subject: Large reply\\n" + + "In-Reply-To: \\n" + + "Message-ID: \\n" + + "MIME-Version: 1.0\\n" + + "Content-Type: text/plain\\n\\n" + + filler + ) + ); + } else if (mode === "reject") { + message.setReject("Rejected by test worker"); + } + }, + }; +`; + +describe("Local Explorer email API", () => { + let mf: Miniflare; + + beforeAll(async () => { + mf = new Miniflare({ + compatibilityDate: "2025-03-17", + inspectorPort: 0, + unsafeLocalExplorer: true, + unsafeTriggerHandlers: true, + workers: [ + { + name: WORKER_NAME, + compatibilityDate: "2025-03-17", + modules: true, + script: EMAIL_WORKER, + email: { + send_email: [{ name: "SEND_EMAIL" }], + }, + }, + ], + }); + await mf.ready; + }); + + afterAll(async () => { + await disposeWithRetry(mf); + }); + + test("captures a sent EmailMessage with raw content", async ({ expect }) => { + const raw = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + Subject: Raw message + MIME-Version: 1.0 + Content-Type: text/plain + + Raw message body. + `; + + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-raw?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + }).toString(), + { + method: "POST", + body: raw, + } + ); + + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + expect(sentResult).toEqual({ + messageId: expect.stringMatching(/^<[A-Za-z0-9]+@example\.com>$/), + }); + const sentMessageId = sentResult.messageId; + + const listResponse = await mf.dispatchFetch(`${BASE_URL}/email/sending`); + const list = await expectValidResponse( + listResponse, + zEmailListSendingResponse, + expect + ); + const item = 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}/email/sending/${encodeURIComponent(sentMessageId)}` + ); + const detail = await expectValidResponse( + detailResponse, + zEmailGetSendingResponse, + expect + ); + expect(detail.result).toMatchObject({ + worker: WORKER_NAME, + messageId: sentMessageId, + raw, + rawBase64: Buffer.from(raw).toString("base64"), + }); + }); + + test("captures a MessageBuilder and omits large fields from list results", async ({ + expect, + }) => { + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-builder", + { + method: "POST", + body: JSON.stringify({ + from: { name: "Sender", email: "sender@example.com" }, + to: "recipient@example.com", + subject: "Builder message", + text: "Plain text", + html: "

HTML

", + headers: { "Message-ID": "" }, + attachments: [ + { + filename: "hello.txt", + type: "text/plain", + disposition: "attachment", + content: "SGVsbG8=", + }, + ], + }), + } + ); + + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + expect(sentResult).toEqual({ + messageId: expect.stringMatching(/^<[A-Za-z0-9]+@example\.com>$/), + }); + const sentMessageId = sentResult.messageId; + + const list = await expectValidResponse( + await mf.dispatchFetch(`${BASE_URL}/email/sending`), + zEmailListSendingResponse, + expect + ); + const item = 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}/email/sending/${encodeURIComponent(sentMessageId)}` + ), + zEmailGetSendingResponse, + expect + ); + expect(detail.result).toMatchObject({ + text: "Plain text", + html: "

HTML

", + }); + }); + + test("sends a >1 MiB EmailMessage and captures a truncated copy", async ({ + expect, + }) => { + const filler = "x".repeat(2 * 1024 * 1024); + const raw = + [ + "From: sender@example.com", + "To: recipient@example.com", + "Message-ID: ", + "Subject: Large raw message", + "MIME-Version: 1.0", + "Content-Type: text/plain", + "", + filler, + ].join("\r\n") + "\r\n"; + expect(new TextEncoder().encode(raw).byteLength).toBeGreaterThan( + MAX_LOCAL_EMAIL_BYTES + ); + + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-raw?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + }).toString(), + { method: "POST", body: raw } + ); + + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + expect(sentResult.messageId).toMatch(/^<[A-Za-z0-9]+@example\.com>$/); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/email/sending/${encodeURIComponent(sentResult.messageId)}` + ), + zEmailGetSendingResponse, + expect + ); + // Delivery used the full body, but the captured copy is truncated to the + // local limit. + expect(detail.result?.subject).toBe("Large raw message"); + const capturedBytes = Buffer.from( + String(detail.result?.rawBase64), + "base64" + ).byteLength; + expect(capturedBytes).toBe(MAX_LOCAL_EMAIL_BYTES); + }); + + test("sends a >1 MiB MessageBuilder and captures a truncated copy", async ({ + expect, + }) => { + const text = "y".repeat(2 * 1024 * 1024); + const sendResponse = await mf.dispatchFetch( + "http://localhost/send-builder", + { + method: "POST", + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Large builder message", + text, + headers: { "Message-ID": "" }, + }), + } + ); + + expect(sendResponse.status).toBe(200); + const sentResult = (await sendResponse.json()) as { messageId: string }; + expect(sentResult.messageId).toMatch(/^<[A-Za-z0-9]+@example\.com>$/); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/email/sending/${encodeURIComponent(sentResult.messageId)}` + ), + zEmailGetSendingResponse, + expect + ); + expect(detail.result?.subject).toBe("Large builder message"); + expect(new TextEncoder().encode(detail.result?.text ?? "").byteLength).toBe( + MAX_LOCAL_EMAIL_BYTES + ); + }); + + test("captures a >1 MiB reply as a truncated copy", async ({ expect }) => { + // The incoming email stays small; the worker self-generates a >1 MiB + // reply body so we exercise reply capture without tripping the + // test-send guard on the incoming message. + const response = await mf.dispatchFetch( + `${BASE_URL}/email/routing/send?worker=${WORKER_NAME}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Large reply target", + text: "Large reply target", + headers: { + "Message-ID": "", + "X-Test-Mode": "reply-large", + }, + }), + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: { + messageId: "", + outcome: "ok", + }, + }); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/email/routing/${encodeURIComponent("")}` + ), + zEmailGetRoutingResponse, + expect + ); + expect(detail.result?.events.map(({ type }) => type)).toEqual([ + "received", + "reply", + ]); + const reply = detail.result?.replies[0]; + expect(reply?.messageId).toBe(""); + // The reply was delivered and captured, trimmed to the local limit. + expect(new TextEncoder().encode(reply?.raw ?? "").byteLength).toBe( + MAX_LOCAL_EMAIL_BYTES + ); + }); + + test("delivers and captures a truncated copy of a >1 MiB received email", async ({ + expect, + }) => { + const headers = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain + + `; + const headerBytes = new TextEncoder().encode(headers).byteLength; + const raw = headers + "x".repeat(2 * 1024 * 1024 - headerBytes); + + const response = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + format: "json", + }).toString(), + { method: "POST", body: raw } + ); + + // Delivery succeeds regardless of size. + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ outcome: "ok" }); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/email/routing/${encodeURIComponent("")}` + ), + zEmailGetRoutingResponse, + expect + ); + // Full original size is recorded, but the captured raw is truncated. + expect(detail.result?.rawSize).toBe(2 * 1024 * 1024); + expect( + Buffer.from(String(detail.result?.rawBase64), "base64").byteLength + ).toBe(MAX_LOCAL_EMAIL_BYTES); + }); + + test("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}/email/routing?worker=${WORKER_NAME}`), + zEmailListRoutingResponse, + expect + ); + const item = 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}/email/routing/${encodeURIComponent("")}` + ), + zEmailGetRoutingResponse, + expect + ); + expect(detail.result).toMatchObject({ + raw, + rawBase64: Buffer.from(raw).toString("base64"), + }); + }); + + test("captures a received email at the local size limit", async ({ + expect, + }) => { + const headers = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain + + `; + const headerBytes = new TextEncoder().encode(headers).byteLength; + const raw = headers + "x".repeat(MAX_LOCAL_EMAIL_BYTES - headerBytes); + + const response = await mf.dispatchFetch( + "http://localhost/cdn-cgi/local/email?" + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + format: "json", + }).toString(), + { + method: "POST", + body: raw, + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ outcome: "ok" }); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/email/routing/${encodeURIComponent("")}` + ), + zEmailGetRoutingResponse, + expect + ); + expect(detail.result).toMatchObject({ + messageId: "", + rawSize: MAX_LOCAL_EMAIL_BYTES, + raw, + rawBase64: Buffer.from(raw).toString("base64"), + }); + }); + + test("filters received emails by worker and records rejection", async ({ + expect, + }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/email/routing/send?worker=${WORKER_NAME}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Rejected message", + text: "Rejected", + headers: { + "Message-ID": "", + "X-Test-Mode": "reject", + }, + }), + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: { + messageId: "", + outcome: "ok", + rejectReason: "Rejected by test worker", + }, + }); + + const filtered = await expectValidResponse( + await mf.dispatchFetch(`${BASE_URL}/email/routing?worker=other-worker`), + zEmailListRoutingResponse, + expect + ); + expect(filtered.result).toEqual([]); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/email/routing/${encodeURIComponent("")}?worker=other-worker` + ), + zWorkersApiResponseCommonFailure, + expect, + 404 + ); + expect(detail.result).toBeNull(); + }); + + test("stores reply events and reply content", async ({ expect }) => { + const response = await mf.dispatchFetch( + `${BASE_URL}/email/routing/send?worker=${WORKER_NAME}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Reply target", + text: "Reply target", + headers: { + "Message-ID": "", + "X-Test-Mode": "reply", + }, + }), + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + result: { + messageId: "", + outcome: "ok", + }, + }); + + const detail = await expectValidResponse( + await mf.dispatchFetch( + `${BASE_URL}/email/routing/${encodeURIComponent("")}` + ), + zEmailGetRoutingResponse, + expect + ); + expect(detail.result?.events.map(({ type }) => type)).toEqual([ + "received", + "reply", + ]); + const reply = detail.result?.replies[0]; + expect(reply).toMatchObject({ + messageId: "", + sender: "reply@example.com", + raw: expect.stringContaining("References: "), + rawBase64: expect.any(String), + }); + expect(reply?.raw).toContain("Subject: Reply subject"); + expect(reply?.raw).toContain( + "Body literal =?UTF-8?B?U2hvdWxkIHN0YXkgcmF3?=" + ); + expect( + Buffer.from(String(reply?.rawBase64), "base64").toString() + ).toContain("Subject: =?UTF-8?B?UmVwbHkgc3ViamVjdA==?="); + }); + + test("retains only the newest 200 received emails", async ({ expect }) => { + for (let index = 0; index <= 200; index++) { + const messageId = ``; + const response = await mf.dispatchFetch( + `${BASE_URL}/email/routing/send?worker=${WORKER_NAME}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Retention test", + text: `Message ${index}`, + headers: { "Message-ID": messageId }, + }), + } + ); + expect(response.status).toBe(200); + await response.json(); + } + + const list = await expectValidResponse( + await mf.dispatchFetch(`${BASE_URL}/email/routing`), + zEmailListRoutingResponse, + expect + ); + const retained = list.result?.filter((email) => + email.messageId.startsWith(""); + expect( + retained?.some((email) => email.messageId === "") + ).toBe(false); + }); +}); + +describe("Local Explorer email aggregation", () => { + let registryPath: string; + let instanceA: Miniflare; + let instanceB: Miniflare; + + beforeAll(async () => { + registryPath = mkdtempSync(path.join(tmpdir(), "mf-email-registry-")); + instanceA = new Miniflare({ + name: "email-a", + unsafeRegisterWorker: true, + inspectorPort: 0, + compatibilityDate: "2025-03-17", + modules: true, + script: EMAIL_WORKER, + unsafeLocalExplorer: true, + unsafeTriggerHandlers: true, + unsafeDevRegistryPath: registryPath, + }); + instanceB = new Miniflare({ + name: "email-b", + unsafeRegisterWorker: true, + inspectorPort: 0, + compatibilityDate: "2025-03-17", + modules: true, + script: EMAIL_WORKER, + unsafeLocalExplorer: true, + unsafeTriggerHandlers: true, + unsafeDevRegistryPath: registryPath, + }); + await Promise.all([instanceA.ready, instanceB.ready]); + await waitForWorkersInRegistry(registryPath, ["email-a", "email-b"]); + }); + + afterAll(async () => { + await Promise.all([ + disposeWithRetry(instanceA), + disposeWithRetry(instanceB), + ]); + removeDirSync(registryPath); + }); + + test("aggregates peer records and proxies peer details", async ({ + expect, + }) => { + const messageId = ""; + const response = await instanceA.dispatchFetch( + `${BASE_URL}/email/routing/send?worker=email-b`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: ["recipient@example.com"], + subject: "Peer email", + text: "Stored by the peer instance", + headers: { "Message-ID": messageId }, + }), + } + ); + expect(response.status).toBe(200); + await response.json(); + + const list = await expectValidResponse( + await instanceA.dispatchFetch(`${BASE_URL}/email/routing`), + zEmailListRoutingResponse, + expect + ); + expect(list.result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ worker: "email-b", messageId }), + ]) + ); + + const detail = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/email/routing/${encodeURIComponent(messageId)}` + ), + zEmailGetRoutingResponse, + expect + ); + expect(detail.result).toMatchObject({ + worker: "email-b", + messageId, + }); + + const wrongWorker = await expectValidResponse( + await instanceA.dispatchFetch( + `${BASE_URL}/email/routing/${encodeURIComponent(messageId)}?worker=email-a` + ), + zWorkersApiResponseCommonFailure, + expect, + 404 + ); + expect(wrongWorker.result).toBeNull(); + }); +}); diff --git a/packages/miniflare/test/plugins/local-explorer/index.spec.ts b/packages/miniflare/test/plugins/local-explorer/index.spec.ts index fb784b512f7..083dec1437f 100644 --- a/packages/miniflare/test/plugins/local-explorer/index.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/index.spec.ts @@ -712,6 +712,7 @@ describe("Local Explorer /api/local/workers endpoint", () => { "id": "r2-bucket-name", }, ], + "sendEmail": [], "workflows": [], }, "isSelf": true, @@ -728,6 +729,7 @@ describe("Local Explorer /api/local/workers endpoint", () => { }, ], "r2": [], + "sendEmail": [], "workflows": [], }, "isSelf": true, @@ -744,6 +746,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 745b75ebce8..6c5352999f7 100644 --- a/packages/wrangler/e2e/createTestHarness.test.ts +++ b/packages/wrangler/e2e/createTestHarness.test.ts @@ -1876,6 +1876,10 @@ describe("createTestHarness", () => { }, ], events: [ + { + type: "received", + timestamp: expect.any(String), + }, { type: "forward", timestamp: expect.any(String), @@ -1900,7 +1904,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( @@ -1927,6 +1934,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 8a59df983e2..3f963f072d4 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"; @@ -2629,6 +2630,149 @@ 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"); + } else { + message.setReject("Rejected by E2E worker"); + } + }, + }; + `, + }); + + const worker = helper.runLongLived("wrangler dev"); + const { url } = await worker.waitForReady(); + const apiUrl = `${url}/cdn-cgi/local/explorer/api`; + + const sentResponse = await fetch(`${url}/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Explorer sent email", + text: "Sent through Wrangler dev", + headers: { "Message-ID": "" }, + }), + }); + expect(sentResponse.status).toBe(200); + const sentResult = (await sentResponse.json()) as { messageId: string }; + expect(sentResult).toEqual({ + messageId: expect.stringMatching(/^<[A-Za-z0-9]+@example\.com>$/), + }); + const sentMessageId = sentResult.messageId; + + const sentList = await fetchJson<{ + result: Array<{ + worker?: string; + messageId: string; + subject: string; + text?: string; + }>; + }>(`${apiUrl}/email/sending?worker=${workerName}`); + const sentItem = sentList.result.find( + (email) => email.messageId === sentMessageId + ); + expect(sentItem).toMatchObject({ + worker: workerName, + subject: "Explorer sent email", + }); + expect(sentItem).not.toHaveProperty("text"); + + const sentDetail = await fetchJson<{ + result: { text?: string; messageId: string }; + }>(`${apiUrl}/email/sending/${encodeURIComponent(sentMessageId)}`); + expect(sentDetail.result).toMatchObject({ + messageId: sentMessageId, + text: "Sent through Wrangler dev", + }); + + const receivedRaw = dedent` + From: sender@example.com + To: recipient@example.com + Message-ID: + X-Test-Mode: forward + MIME-Version: 1.0 + Content-Type: text/plain + + Received through Wrangler dev. + `; + const receivedResponse = await fetch( + `${url}/cdn-cgi/local/email?` + + new URLSearchParams({ + from: "sender@example.com", + to: "recipient@example.com", + format: "json", + }).toString(), + { + method: "POST", + body: receivedRaw, + } + ); + expect(receivedResponse.status).toBe(200); + expect(await receivedResponse.json()).toMatchObject({ + outcome: "ok", + forwards: [{ recipient: "forwarded@example.com" }], + }); + + const receivedList = await fetchJson<{ + result: Array<{ + worker?: string; + messageId: string; + outcome: string; + forwards: Array<{ recipient: string }>; + }>; + }>(`${apiUrl}/email/routing?worker=${workerName}`); + expect(receivedList.result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + worker: workerName, + messageId: "", + outcome: "ok", + forwards: expect.arrayContaining([ + expect.objectContaining({ recipient: "forwarded@example.com" }), + ]), + }), + ]) + ); + + const receivedDetail = await fetchJson<{ + result: { + raw: string; + events: Array<{ type: string }>; + }; + }>( + `${apiUrl}/email/routing/${encodeURIComponent("")}` + ); + expect(receivedDetail.result).toMatchObject({ + raw: receivedRaw, + events: [{ type: "received" }, { type: "forward" }], + }); + }); }); describe("r2 local S3-compatible API", () => { diff --git a/packages/wrangler/e2e/get-platform-proxy.test.ts b/packages/wrangler/e2e/get-platform-proxy.test.ts index 72bce990bb0..b16ebbd93f9 100644 --- a/packages/wrangler/e2e/get-platform-proxy.test.ts +++ b/packages/wrangler/e2e/get-platform-proxy.test.ts @@ -694,7 +694,7 @@ describe("getPlatformProxy()", () => { encoding: "utf-8", }); - expect(stdout).toMatch(/^<[A-Za-z0-9]{36}@sender\.domain>/); + expect(stdout).toMatch(/^<[A-Za-z0-9]+@sender\.domain>/); }); }); }); diff --git a/packages/wrangler/e2e/multiworker-dev.test.ts b/packages/wrangler/e2e/multiworker-dev.test.ts index e3acb5aee1a..708817b149b 100644 --- a/packages/wrangler/e2e/multiworker-dev.test.ts +++ b/packages/wrangler/e2e/multiworker-dev.test.ts @@ -650,3 +650,76 @@ describe("multiworker", () => { }); }); }); + +describe("multiworker email local dev", () => { + it("filters captured emails by the selected worker", async ({ expect }) => { + const helper = new WranglerE2ETestHelper(); + const workerAName = generateResourceName("worker"); + const workerBName = generateResourceName("worker"); + const script = dedent /* javascript */ ` + export default { + async email(message) { + await message.forward("forwarded@example.com"); + }, + }; + `; + const rootA = await makeRoot(); + await baseSeed(rootA, { + "wrangler.toml": dedent` + name = "${workerAName}" + main = "src/index.ts" + compatibility_date = "2025-03-17" + `, + "src/index.ts": script, + }); + + const rootB = await makeRoot(); + await baseSeed(rootB, { + "wrangler.toml": dedent` + name = "${workerBName}" + main = "src/index.ts" + compatibility_date = "2025-03-17" + `, + "src/index.ts": script, + }); + + const worker = helper.runLongLived( + `wrangler dev -c wrangler.toml -c ${rootB}/wrangler.toml`, + { cwd: rootA } + ); + const { url } = await worker.waitForReady(30_000); + const messageId = ""; + const response = await fetch( + `${url}/cdn-cgi/local/explorer/api/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}/email/routing?worker=${workerAName}`); + const workerBEmails = await fetchJson<{ + result: Array<{ messageId: string; worker?: string }>; + }>(`${apiUrl}/email/routing?worker=${workerBName}`); + + expect( + workerAEmails.result.some((email) => email.messageId === messageId) + ).toBe(false); + expect(workerBEmails.result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ messageId, worker: workerBName }), + ]) + ); + }); +}); From e5d3aafa3694ca30bfea503b473b6cc3fa1b3151 Mon Sep 17 00:00:00 2001 From: tmo Date: Thu, 6 Aug 2026 18:15:43 +0100 Subject: [PATCH 13/13] Changeset --- .changeset/local-email-capture.md | 58 +++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .changeset/local-email-capture.md diff --git a/.changeset/local-email-capture.md b/.changeset/local-email-capture.md new file mode 100644 index 00000000000..77bcd73f39b --- /dev/null +++ b/.changeset/local-email-capture.md @@ -0,0 +1,58 @@ +--- +"miniflare": minor +"wrangler": minor +--- + +Capture locally sent and received emails so you can inspect them during development. Emails stored in the user's project directory (or system temporary directory) are now stored using their message ID rather than a UUID. + +The email test harness result now includes a chronological list of handler events, so programmatic local email tests can assert on the order in which events occurred. + +Note that the file path logged by the `send_email` binding (the `send_email binding called with ...` log line) is now written asynchronously, so it may not exist immediately after `send()` resolves. When reading the logged file path immediately after awaiting `send()`, do not assume the file exists yet. + +Sending, replying to, or receiving an email larger than the 1 MiB local capture limit no longer fails: the email is delivered in full and a copy truncated to the first 1 MiB is captured for the Local Explorer (a warning is logged when truncation occurs). + +```ts +const result = await server.getWorker().email({ + from: "sender@example.com", + to: "inbox@example.com", + raw: [ + "From: Sender ", + "To: Inbox ", + "Message-ID: ", + "Subject: Test email", + "", + "Hello from the test harness", + ].join("\r\n"), +}); + +expect(result).toEqual({ + outcome: "ok", + forwards: [ + { + messageId: expect.any(String), + recipient: "archive@example.com", + headers: [], + }, + ], + replies: [ + { + messageId: expect.any(String), + sender: "reply@example.com", + raw: expect.stringContaining("Thanks for your email"), + }, + ], + events: [ + { type: "received", timestamp: expect.any(String) }, + { + type: "forward", + timestamp: expect.any(String), + messageId: expect.any(String), + }, + { + type: "reply", + timestamp: expect.any(String), + messageId: expect.any(String), + }, + ], +}); +```