Skip to content

Commit e456952

Browse files
fix(miniflare): return EmailSendResult from send_email binding's send() (#13577)
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 5716d69 commit e456952

3 files changed

Lines changed: 132 additions & 2 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"miniflare": patch
3+
---
4+
5+
Return `EmailSendResult` from the `send_email` binding's `send()` in local mode
6+
7+
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.
8+
9+
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.

packages/miniflare/src/workers/email/send_email.worker.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ import { type MiniflareEmailMessage as EmailMessage } from "./email.worker";
88
import type { EmailAddress, MessageBuilder } from "./types";
99
import type { Email } from "postal-mime";
1010

11+
/**
12+
* Build a Message-ID in the shape the production `send_email` binding returns:
13+
* `<{36 alphanumeric chars}@{sender domain}>`, brackets included. The body is
14+
* random — production synthesizes its own id rather than echoing any header
15+
* present in the submitted email.
16+
*/
17+
function synthesizeMessageId(senderEmail: string): string {
18+
const alphabet =
19+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
20+
const bytes = crypto.getRandomValues(new Uint8Array(36));
21+
const id = Array.from(bytes, (b) => alphabet[b % alphabet.length]).join("");
22+
const domain = senderEmail.slice(senderEmail.lastIndexOf("@") + 1);
23+
return `<${id}@${domain}>`;
24+
}
25+
1126
/**
1227
* Extracts email address from string or EmailAddress object
1328
*/
@@ -168,7 +183,7 @@ export class SendEmailBinding extends WorkerEntrypoint<SendEmailEnv> {
168183

169184
async send(
170185
emailMessageOrBuilder: EmailMessage | MessageBuilder
171-
): Promise<void> {
186+
): Promise<EmailSendResult> {
172187
// Check if this is an EmailMessage (has RAW_EMAIL symbol) or MessageBuilder
173188
if (this.isEmailMessage(emailMessageOrBuilder)) {
174189
// Original EmailMessage API - validate and parse MIME
@@ -217,6 +232,8 @@ export class SendEmailBinding extends WorkerEntrypoint<SendEmailEnv> {
217232
this.log(
218233
`${blue("send_email binding called with the following message:")}\n ${file}`
219234
);
235+
236+
return { messageId: synthesizeMessageId(emailMessage.from) };
220237
} else {
221238
// New MessageBuilder API - just validate and log
222239
const builder = emailMessageOrBuilder;
@@ -269,6 +286,10 @@ export class SendEmailBinding extends WorkerEntrypoint<SendEmailEnv> {
269286
this.log(
270287
`${blue("send_email binding called with MessageBuilder:")}\n${formatted}${fileInfo}`
271288
);
289+
290+
return {
291+
messageId: synthesizeMessageId(extractEmailAddress(builder.from)),
292+
};
272293
}
273294
}
274295
}

packages/miniflare/test/plugins/email/index.spec.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readFile } from "node:fs/promises";
22
import { LogLevel, Miniflare } from "miniflare";
33
import dedent from "ts-dedent";
4-
import { test, vi } from "vitest";
4+
import { type ExpectStatic, test, vi } from "vitest";
55
import { TestLog, useDispose } from "../../test-shared";
66

77
const SEND_EMAIL_WORKER = dedent /* javascript */ `
@@ -1508,3 +1508,103 @@ test("MessageBuilder backward compatibility - old EmailMessage API still works",
15081508
expect(await res.text()).toBe("ok");
15091509
expect(res.status).toBe(200);
15101510
});
1511+
1512+
const SEND_EMAIL_RETURNS_RESULT_WORKER = dedent /* javascript */ `
1513+
import { EmailMessage } from "cloudflare:email";
1514+
1515+
export default {
1516+
async fetch(request, env) {
1517+
const url = new URL(request.url);
1518+
const result = await env.SEND_EMAIL.send(new EmailMessage(
1519+
url.searchParams.get("from"),
1520+
url.searchParams.get("to"),
1521+
request.body
1522+
));
1523+
return Response.json(result);
1524+
},
1525+
};
1526+
`;
1527+
1528+
// Both branches return an id in the shape production returns:
1529+
// `<{36 alphanumeric chars}@{sender domain}>`, angle brackets included.
1530+
function synthesizedMessageId(expect: ExpectStatic, domain: string) {
1531+
return expect.stringMatching(
1532+
new RegExp(`^<[A-Za-z0-9]{36}@${domain.replace(/\./g, "\\.")}>$`)
1533+
);
1534+
}
1535+
1536+
test("send() on an EmailMessage returns a synthesized messageId", async ({
1537+
expect,
1538+
}) => {
1539+
const mf = new Miniflare({
1540+
modules: true,
1541+
script: SEND_EMAIL_RETURNS_RESULT_WORKER,
1542+
email: {
1543+
send_email: [{ name: "SEND_EMAIL" }],
1544+
},
1545+
compatibilityDate: "2025-03-17",
1546+
});
1547+
1548+
useDispose(mf);
1549+
1550+
const email = dedent`
1551+
From: someone <someone@sender.domain>
1552+
To: someone else <someone-else@example.com>
1553+
Message-ID: <do-not-echo-this@example.com>
1554+
MIME-Version: 1.0
1555+
Content-Type: text/plain
1556+
1557+
body`;
1558+
1559+
const res = await mf.dispatchFetch(
1560+
"http://localhost/?" +
1561+
new URLSearchParams({
1562+
from: "someone@sender.domain",
1563+
to: "someone-else@example.com",
1564+
}).toString(),
1565+
{ body: email, method: "POST" }
1566+
);
1567+
1568+
expect(res.status).toBe(200);
1569+
expect(await res.json()).toEqual({
1570+
messageId: synthesizedMessageId(expect, "sender.domain"),
1571+
});
1572+
});
1573+
1574+
test("send() on a MessageBuilder returns a synthesized messageId", async ({
1575+
expect,
1576+
}) => {
1577+
const mf = new Miniflare({
1578+
modules: true,
1579+
script: dedent /* javascript */ `
1580+
export default {
1581+
async fetch(request, env) {
1582+
const builder = await request.json();
1583+
const result = await env.SEND_EMAIL.send(builder);
1584+
return Response.json(result);
1585+
},
1586+
};
1587+
`,
1588+
email: {
1589+
send_email: [{ name: "SEND_EMAIL" }],
1590+
},
1591+
compatibilityDate: "2025-03-17",
1592+
});
1593+
1594+
useDispose(mf);
1595+
1596+
const res = await mf.dispatchFetch("http://localhost", {
1597+
method: "POST",
1598+
body: JSON.stringify({
1599+
from: "sender@sender.domain",
1600+
to: "recipient@example.com",
1601+
subject: "s",
1602+
text: "t",
1603+
}),
1604+
});
1605+
1606+
expect(res.status).toBe(200);
1607+
expect(await res.json()).toEqual({
1608+
messageId: synthesizedMessageId(expect, "sender.domain"),
1609+
});
1610+
});

0 commit comments

Comments
 (0)