diff --git a/.changeset/miniflare-send-email-result.md b/.changeset/miniflare-send-email-result.md new file mode 100644 index 0000000000..c6fc0ac0b7 --- /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. + +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 ab194f4894..1c348fa1fe 100644 --- a/packages/miniflare/src/workers/email/send_email.worker.ts +++ b/packages/miniflare/src/workers/email/send_email.worker.ts @@ -8,6 +8,21 @@ import { type MiniflareEmailMessage as EmailMessage } from "./email.worker"; import type { EmailAddress, MessageBuilder } from "./types"; import type { Email } from "postal-mime"; +/** + * Build a Message-ID in the shape the production `send_email` binding returns: + * `<{36 alphanumeric chars}@{sender domain}>`, brackets included. The body is + * random — production synthesizes its own id rather than echoing any header + * present in the submitted email. + */ +function synthesizeMessageId(senderEmail: string): string { + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const bytes = crypto.getRandomValues(new Uint8Array(36)); + const id = Array.from(bytes, (b) => alphabet[b % alphabet.length]).join(""); + const domain = senderEmail.slice(senderEmail.lastIndexOf("@") + 1); + return `<${id}@${domain}>`; +} + /** * Extracts email address from string or EmailAddress object */ @@ -168,7 +183,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 +232,8 @@ export class SendEmailBinding extends WorkerEntrypoint { this.log( `${blue("send_email binding called with the following message:")}\n ${file}` ); + + return { messageId: synthesizeMessageId(emailMessage.from) }; } else { // New MessageBuilder API - just validate and log const builder = emailMessageOrBuilder; @@ -269,6 +286,10 @@ export class SendEmailBinding extends WorkerEntrypoint { this.log( `${blue("send_email binding called with MessageBuilder:")}\n${formatted}${fileInfo}` ); + + 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 811e6b5fc2..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 */ ` @@ -1508,3 +1508,103 @@ 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); + }, + }; +`; + +// 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({ + modules: true, + script: SEND_EMAIL_RETURNS_RESULT_WORKER, + email: { + send_email: [{ name: "SEND_EMAIL" }], + }, + compatibilityDate: "2025-03-17", + }); + + useDispose(mf); + + const email = dedent` + From: someone + To: someone else + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain + + body`; + + 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, +}) => { + 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: "s", + text: "t", + }), + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + messageId: synthesizedMessageId(expect, "sender.domain"), + }); +});