From 18a42a878c24199be071ba0fe3fea7317aa3d065 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Thu, 17 Sep 2026 15:06:25 +0100 Subject: [PATCH] Fix Illegal invocation in moderation emails --- src/services/extensions/v2/email/mxroute.ts | 17 ++++--- src/services/extensions/v2/email/resend.ts | 17 ++++--- src/services/extensions/v2/email/types.ts | 6 +++ test/services/extensions/v2/email.test.ts | 56 +++++++++++++++++++++ 4 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src/services/extensions/v2/email/mxroute.ts b/src/services/extensions/v2/email/mxroute.ts index cdecadb..c212d7d 100644 --- a/src/services/extensions/v2/email/mxroute.ts +++ b/src/services/extensions/v2/email/mxroute.ts @@ -1,10 +1,11 @@ -import type { - EmailIdentity, - EmailMessage, - EmailSender, - EmailSendResult, - EnvReader, - FetchFn +import { + defaultFetch, + type EmailIdentity, + type EmailMessage, + type EmailSender, + type EmailSendResult, + type EnvReader, + type FetchFn } from "./types"; const SMTP_API_URL = "https://smtpapi.mxroute.com/"; @@ -33,7 +34,7 @@ export function loadMxrouteConfig( export class MxrouteSender implements EmailSender { constructor( private config: MxrouteConfig, - private fetchFn: FetchFn = globalThis.fetch + private fetchFn: FetchFn = defaultFetch ) {} async send(message: EmailMessage): Promise { diff --git a/src/services/extensions/v2/email/resend.ts b/src/services/extensions/v2/email/resend.ts index 533376f..e67a6d4 100644 --- a/src/services/extensions/v2/email/resend.ts +++ b/src/services/extensions/v2/email/resend.ts @@ -1,10 +1,11 @@ -import type { - EmailIdentity, - EmailMessage, - EmailSender, - EmailSendResult, - EnvReader, - FetchFn +import { + defaultFetch, + type EmailIdentity, + type EmailMessage, + type EmailSender, + type EmailSendResult, + type EnvReader, + type FetchFn } from "./types"; const RESEND_API_URL = "https://api.resend.com/emails"; @@ -29,7 +30,7 @@ export function loadResendConfig( export class ResendSender implements EmailSender { constructor( private config: ResendConfig, - private fetchFn: FetchFn = globalThis.fetch + private fetchFn: FetchFn = defaultFetch ) {} async send(message: EmailMessage): Promise { diff --git a/src/services/extensions/v2/email/types.ts b/src/services/extensions/v2/email/types.ts index 8db1105..0e16130 100644 --- a/src/services/extensions/v2/email/types.ts +++ b/src/services/extensions/v2/email/types.ts @@ -13,6 +13,12 @@ export interface EmailSender { export type FetchFn = typeof globalThis.fetch; +// Workers' fetch requires its `this` receiver: storing `globalThis.fetch` +// as a bare reference and calling it later throws "Illegal invocation". +// Keep the lookup behind an arrow so the default sender always calls it +// bound. +export const defaultFetch: FetchFn = (...args) => globalThis.fetch(...args); + // Minimal env surface the email subsystem needs. Shaped to match // PlatformContext, so routes pass getPlatform(c) directly — the same way // GITHUB_TOKEN and the assertion secrets are read everywhere else. diff --git a/test/services/extensions/v2/email.test.ts b/test/services/extensions/v2/email.test.ts index b7b6442..306a9d9 100644 --- a/test/services/extensions/v2/email.test.ts +++ b/test/services/extensions/v2/email.test.ts @@ -258,6 +258,62 @@ describe("factory", () => { }); }); +describe("default fetch binding", () => { + it("sends through globalThis.fetch without an Illegal invocation error", async () => { + // Workers throws "Illegal invocation" for a detached fetch while Node + // tolerates it: enforce the Workers behaviour so the default stays bound. + const originalFetch = globalThis.fetch; + const urls: string[] = []; + function workersLikeFetch( + this: unknown, + input: string | URL | Request, + _init?: RequestInit + ): Promise { + if (this !== globalThis) { + throw new TypeError( + "Illegal invocation: function called with incorrect `this` reference." + ); + } + urls.push(String(input)); + return Promise.resolve(jsonResponse({ success: true, message: "sent" })); + } + + globalThis.fetch = workersLikeFetch as typeof fetch; + try { + const message = { + to: "author@example.com", + subject: "s", + html: "

hi

", + text: "hi" + }; + const mxrouteConfig = loadMxrouteConfig(reader(MXROUTE_VARS), IDENTITY)!; + await expect( + new MxrouteSender(mxrouteConfig).send(message) + ).resolves.toEqual({ ok: true }); + + const resendConfig = loadResendConfig( + reader({ EXTENSIONS_V2_RESEND_API_KEY: "re_key" }), + IDENTITY + )!; + await expect( + new ResendSender(resendConfig).send(message) + ).resolves.toEqual({ ok: true }); + + // notify.ts calls createEmailSender(env) with no injected fetch. + const viaFactory = createEmailSender(reader(MXROUTE_VARS)); + await expect(viaFactory.send(message)).resolves.toEqual({ ok: true }); + + expect(urls).toEqual([ + "https://smtpapi.mxroute.com/", + "https://api.resend.com/emails", + "https://smtpapi.mxroute.com/" + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + describe("moderation templates", () => { it("includes the reason and escapes markup", () => { const message = buildModerationEmail({