From c17affb7bf3a25450f4fab4406a3023d48457f64 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Thu, 16 Apr 2026 22:06:07 -0500 Subject: [PATCH 1/3] fix(miniflare): return EmailSendResult from send_email binding's send() The binding's `send()` resolved to `undefined`, diverging from production (and the public `SendEmail` type), which returns `{ messageId }`. Workers that inspect the return value now see the same shape locally as deployed. - EmailMessage path: echo the parsed Message-ID with angle brackets stripped - MessageBuilder path: synthesize the id in the same `@example.com` form already used by the forward() path --- .changeset/miniflare-send-email-result.md | 9 ++ .../src/workers/email/send_email.worker.ts | 19 +++- .../test/plugins/email/index.spec.ts | 92 +++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 .changeset/miniflare-send-email-result.md diff --git a/.changeset/miniflare-send-email-result.md b/.changeset/miniflare-send-email-result.md new file mode 100644 index 0000000000..fbd6d7fda3 --- /dev/null +++ b/.changeset/miniflare-send-email-result.md @@ -0,0 +1,9 @@ +--- +"miniflare": patch +--- + +Return `EmailSendResult` from the `send_email` binding's `send()` in local mode + +The binding's `send()` used to resolve to `undefined`. It now returns `{ messageId: string }`, the same shape as the public `SendEmail` type in production. Workers that read the return value (for logging, or to pass the id downstream) no longer get `undefined` under miniflare. + +On the `EmailMessage` path, the parsed `Message-ID` header is returned with its angle brackets stripped. On the `MessageBuilder` path miniflare doesn't assemble MIME locally, so the id is synthesized as `<32 hex chars>@example.com`, which is the same format the `forward()` path already uses. diff --git a/packages/miniflare/src/workers/email/send_email.worker.ts b/packages/miniflare/src/workers/email/send_email.worker.ts index ab194f4894..f295f6b3d5 100644 --- a/packages/miniflare/src/workers/email/send_email.worker.ts +++ b/packages/miniflare/src/workers/email/send_email.worker.ts @@ -8,6 +8,15 @@ import { type MiniflareEmailMessage as EmailMessage } from "./email.worker"; import type { EmailAddress, MessageBuilder } from "./types"; import type { Email } from "postal-mime"; +/** + * Trim a leading `<` and trailing `>` off a Message-ID. Production returns the + * bare token; postal-mime preserves the angle brackets as they appear in the + * header. + */ +function unwrapMessageId(messageId: string): string { + return messageId.replace(/^<(.*)>$/, "$1"); +} + /** * Extracts email address from string or EmailAddress object */ @@ -168,7 +177,7 @@ export class SendEmailBinding extends WorkerEntrypoint { async send( emailMessageOrBuilder: EmailMessage | MessageBuilder - ): Promise { + ): Promise { // Check if this is an EmailMessage (has RAW_EMAIL symbol) or MessageBuilder if (this.isEmailMessage(emailMessageOrBuilder)) { // Original EmailMessage API - validate and parse MIME @@ -217,6 +226,8 @@ export class SendEmailBinding extends WorkerEntrypoint { this.log( `${blue("send_email binding called with the following message:")}\n ${file}` ); + + return { messageId: unwrapMessageId(parsedEmail.messageId) }; } else { // New MessageBuilder API - just validate and log const builder = emailMessageOrBuilder; @@ -269,6 +280,12 @@ export class SendEmailBinding extends WorkerEntrypoint { this.log( `${blue("send_email binding called with MessageBuilder:")}\n${formatted}${fileInfo}` ); + + // The builder path doesn't assemble MIME locally, so there's no real + // Message-ID to surface. Synthesize one in the same shape the + // production runtime returns: 36 characters followed by a domain. + const uuid = crypto.randomUUID().replaceAll("-", ""); + return { messageId: `${uuid}@example.com` }; } } } diff --git a/packages/miniflare/test/plugins/email/index.spec.ts b/packages/miniflare/test/plugins/email/index.spec.ts index 811e6b5fc2..0911cffc7b 100644 --- a/packages/miniflare/test/plugins/email/index.spec.ts +++ b/packages/miniflare/test/plugins/email/index.spec.ts @@ -1508,3 +1508,95 @@ test("MessageBuilder backward compatibility - old EmailMessage API still works", expect(await res.text()).toBe("ok"); expect(res.status).toBe(200); }); + +const SEND_EMAIL_RETURNS_RESULT_WORKER = dedent /* javascript */ ` + import { EmailMessage } from "cloudflare:email"; + + export default { + async fetch(request, env) { + const url = new URL(request.url); + const result = await env.SEND_EMAIL.send(new EmailMessage( + url.searchParams.get("from"), + url.searchParams.get("to"), + request.body + )); + return Response.json(result); + }, + }; +`; + +test("send() on an EmailMessage returns the parsed Message-ID", 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 messageId = "a-message-id-to-echo-back@example.com"; + const email = dedent` + From: someone + To: someone else + Message-ID: <${messageId}> + MIME-Version: 1.0 + Content-Type: text/plain + + body`; + + const res = await mf.dispatchFetch( + "http://localhost/?" + + new URLSearchParams({ + from: "someone@example.com", + to: "someone-else@example.com", + }).toString(), + { body: email, method: "POST" } + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ messageId }); +}); + +test("send() on a MessageBuilder returns a synthesized messageId", 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@example.com", + to: "recipient@example.com", + subject: "s", + text: "t", + }), + }); + + expect(res.status).toBe(200); + // Synthesized shape matches production: 32-hex-char ID followed by a domain. + expect(await res.json()).toEqual({ + messageId: expect.stringMatching(/^[0-9a-f]{32}@example\.com$/), + }); +}); From 2d52dc11e5aec72408a4d2e434497ba4997899f3 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Thu, 16 Apr 2026 22:13:35 -0500 Subject: [PATCH 2/3] Update packages/miniflare/src/workers/email/send_email.worker.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/miniflare/src/workers/email/send_email.worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/miniflare/src/workers/email/send_email.worker.ts b/packages/miniflare/src/workers/email/send_email.worker.ts index f295f6b3d5..ace86d2f8e 100644 --- a/packages/miniflare/src/workers/email/send_email.worker.ts +++ b/packages/miniflare/src/workers/email/send_email.worker.ts @@ -283,7 +283,7 @@ export class SendEmailBinding extends WorkerEntrypoint { // The builder path doesn't assemble MIME locally, so there's no real // Message-ID to surface. Synthesize one in the same shape the - // production runtime returns: 36 characters followed by a domain. + // production runtime returns: 32 hex characters followed by a domain. const uuid = crypto.randomUUID().replaceAll("-", ""); return { messageId: `${uuid}@example.com` }; } From 2df9c5cb8832656dfa4396937949253b847d245b Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Fri, 17 Apr 2026 13:00:19 -0500 Subject: [PATCH 3/3] actually match prod behavior --- .changeset/miniflare-send-email-result.md | 2 +- .../src/workers/email/send_email.worker.ts | 26 +++++++++-------- .../test/plugins/email/index.spec.ts | 28 ++++++++++++------- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/.changeset/miniflare-send-email-result.md b/.changeset/miniflare-send-email-result.md index fbd6d7fda3..c6fc0ac0b7 100644 --- a/.changeset/miniflare-send-email-result.md +++ b/.changeset/miniflare-send-email-result.md @@ -6,4 +6,4 @@ Return `EmailSendResult` from the `send_email` binding's `send()` in local mode The binding's `send()` used to resolve to `undefined`. It now returns `{ messageId: string }`, the same shape as the public `SendEmail` type in production. Workers that read the return value (for logging, or to pass the id downstream) no longer get `undefined` under miniflare. -On the `EmailMessage` path, the parsed `Message-ID` header is returned with its angle brackets stripped. On the `MessageBuilder` path miniflare doesn't assemble MIME locally, so the id is synthesized as `<32 hex chars>@example.com`, which is the same format the `forward()` path already uses. +Both branches synthesize an id in the shape production returns — `<{36 alphanumeric chars}@{sender domain}>`, angle brackets included — using the envelope `from` for the `EmailMessage` path and the builder's `from` for the `MessageBuilder` path. Production synthesizes its own id rather than echoing anything submitted, so miniflare does the same. diff --git a/packages/miniflare/src/workers/email/send_email.worker.ts b/packages/miniflare/src/workers/email/send_email.worker.ts index ace86d2f8e..1c348fa1fe 100644 --- a/packages/miniflare/src/workers/email/send_email.worker.ts +++ b/packages/miniflare/src/workers/email/send_email.worker.ts @@ -9,12 +9,18 @@ import type { EmailAddress, MessageBuilder } from "./types"; import type { Email } from "postal-mime"; /** - * Trim a leading `<` and trailing `>` off a Message-ID. Production returns the - * bare token; postal-mime preserves the angle brackets as they appear in the - * header. + * Build a Message-ID in the shape the production `send_email` binding returns: + * `<{36 alphanumeric chars}@{sender domain}>`, brackets included. The body is + * random — production synthesizes its own id rather than echoing any header + * present in the submitted email. */ -function unwrapMessageId(messageId: string): string { - return messageId.replace(/^<(.*)>$/, "$1"); +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}>`; } /** @@ -227,7 +233,7 @@ export class SendEmailBinding extends WorkerEntrypoint { `${blue("send_email binding called with the following message:")}\n ${file}` ); - return { messageId: unwrapMessageId(parsedEmail.messageId) }; + return { messageId: synthesizeMessageId(emailMessage.from) }; } else { // New MessageBuilder API - just validate and log const builder = emailMessageOrBuilder; @@ -281,11 +287,9 @@ export class SendEmailBinding extends WorkerEntrypoint { `${blue("send_email binding called with MessageBuilder:")}\n${formatted}${fileInfo}` ); - // The builder path doesn't assemble MIME locally, so there's no real - // Message-ID to surface. Synthesize one in the same shape the - // production runtime returns: 32 hex characters followed by a domain. - const uuid = crypto.randomUUID().replaceAll("-", ""); - return { messageId: `${uuid}@example.com` }; + return { + messageId: synthesizeMessageId(extractEmailAddress(builder.from)), + }; } } } diff --git a/packages/miniflare/test/plugins/email/index.spec.ts b/packages/miniflare/test/plugins/email/index.spec.ts index 0911cffc7b..eeaaf4f09c 100644 --- a/packages/miniflare/test/plugins/email/index.spec.ts +++ b/packages/miniflare/test/plugins/email/index.spec.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { LogLevel, Miniflare } from "miniflare"; import dedent from "ts-dedent"; -import { test, vi } from "vitest"; +import { type ExpectStatic, test, vi } from "vitest"; import { TestLog, useDispose } from "../../test-shared"; const SEND_EMAIL_WORKER = dedent /* javascript */ ` @@ -1525,7 +1525,15 @@ const SEND_EMAIL_RETURNS_RESULT_WORKER = dedent /* javascript */ ` }; `; -test("send() on an EmailMessage returns the parsed Message-ID", async ({ +// Both branches return an id in the shape production returns: +// `<{36 alphanumeric chars}@{sender domain}>`, angle brackets included. +function synthesizedMessageId(expect: ExpectStatic, domain: string) { + return expect.stringMatching( + new RegExp(`^<[A-Za-z0-9]{36}@${domain.replace(/\./g, "\\.")}>$`) + ); +} + +test("send() on an EmailMessage returns a synthesized messageId", async ({ expect, }) => { const mf = new Miniflare({ @@ -1539,11 +1547,10 @@ test("send() on an EmailMessage returns the parsed Message-ID", async ({ useDispose(mf); - const messageId = "a-message-id-to-echo-back@example.com"; const email = dedent` - From: someone + From: someone To: someone else - Message-ID: <${messageId}> + Message-ID: MIME-Version: 1.0 Content-Type: text/plain @@ -1552,14 +1559,16 @@ test("send() on an EmailMessage returns the parsed Message-ID", async ({ const res = await mf.dispatchFetch( "http://localhost/?" + new URLSearchParams({ - from: "someone@example.com", + 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 }); + expect(await res.json()).toEqual({ + messageId: synthesizedMessageId(expect, "sender.domain"), + }); }); test("send() on a MessageBuilder returns a synthesized messageId", async ({ @@ -1587,7 +1596,7 @@ test("send() on a MessageBuilder returns a synthesized messageId", async ({ const res = await mf.dispatchFetch("http://localhost", { method: "POST", body: JSON.stringify({ - from: "sender@example.com", + from: "sender@sender.domain", to: "recipient@example.com", subject: "s", text: "t", @@ -1595,8 +1604,7 @@ test("send() on a MessageBuilder returns a synthesized messageId", async ({ }); expect(res.status).toBe(200); - // Synthesized shape matches production: 32-hex-char ID followed by a domain. expect(await res.json()).toEqual({ - messageId: expect.stringMatching(/^[0-9a-f]{32}@example\.com$/), + messageId: synthesizedMessageId(expect, "sender.domain"), }); });