Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/miniflare-send-email-result.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 22 additions & 1 deletion packages/miniflare/src/workers/email/send_email.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -168,7 +183,7 @@ export class SendEmailBinding extends WorkerEntrypoint<SendEmailEnv> {

async send(
emailMessageOrBuilder: EmailMessage | MessageBuilder
): Promise<void> {
): Promise<EmailSendResult> {
// Check if this is an EmailMessage (has RAW_EMAIL symbol) or MessageBuilder
if (this.isEmailMessage(emailMessageOrBuilder)) {
// Original EmailMessage API - validate and parse MIME
Expand Down Expand Up @@ -217,6 +232,8 @@ export class SendEmailBinding extends WorkerEntrypoint<SendEmailEnv> {
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;
Expand Down Expand Up @@ -269,6 +286,10 @@ export class SendEmailBinding extends WorkerEntrypoint<SendEmailEnv> {
this.log(
`${blue("send_email binding called with MessageBuilder:")}\n${formatted}${fileInfo}`
);

return {
messageId: synthesizeMessageId(extractEmailAddress(builder.from)),
};
}
}
}
102 changes: 101 additions & 1 deletion packages/miniflare/test/plugins/email/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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 */ `
Expand Down Expand Up @@ -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 <someone@sender.domain>
To: someone else <someone-else@example.com>
Message-ID: <do-not-echo-this@example.com>
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"),
});
});
Loading