Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.

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.
19 changes: 18 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,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
*/
Expand Down Expand Up @@ -168,7 +177,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 +226,8 @@ export class SendEmailBinding extends WorkerEntrypoint<SendEmailEnv> {
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;
Expand Down Expand Up @@ -269,6 +280,12 @@ export class SendEmailBinding extends WorkerEntrypoint<SendEmailEnv> {
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.
Comment thread
connyay marked this conversation as resolved.
Outdated
const uuid = crypto.randomUUID().replaceAll("-", "");
return { messageId: `${uuid}@example.com` };
}
}
}
92 changes: 92 additions & 0 deletions packages/miniflare/test/plugins/email/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <someone@example.com>
To: someone else <someone-else@example.com>
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.
Comment thread
dario-piotrowicz marked this conversation as resolved.
Outdated
expect(await res.json()).toEqual({
messageId: expect.stringMatching(/^[0-9a-f]{32}@example\.com$/),
});
});
Loading