Skip to content
This repository was archived by the owner on Aug 8, 2026. It is now read-only.

Commit 99f9952

Browse files
authored
feat(cloudflare-runtime): local send_email stub that logs under alchemy dev (#76)
1 parent 70cbfcc commit 99f9952

3 files changed

Lines changed: 100 additions & 16 deletions

File tree

packages/cloudflare-runtime/src/bindings/send-email/email.worker.ts renamed to packages/cloudflare-runtime/src/bindings/send-email/EmailMessage.worker.ts

File renamed without changes.
Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as Effect from "effect/Effect";
22
import * as Layer from "effect/Layer";
3-
import * as EmailWorker from "worker:./email.worker.ts";
3+
import * as EmailMessageWorker from "worker:./EmailMessage.worker.ts";
4+
import * as SendEmailBindingWorker from "worker:./SendEmailBinding.worker.ts";
45
import { formatExtensionModule } from "../../internal/internal-modules.ts";
56
import * as Plugin from "../../Plugin.ts";
67
import type { BindingHook } from "../../PluginContext.ts";
@@ -9,26 +10,43 @@ import { makeRemoteBinding } from "../../remote-bindings/RemoteBindings.ts";
910

1011
export class SendEmail extends Plugin.Service<SendEmail>()("cloudflare-runtime/plugin/SendEmail") {}
1112

13+
/**
14+
* Wrapped-binding module for the local `send()` stub — see
15+
* `send-email.worker.ts`. Registered alongside the `cloudflare-internal:email`
16+
* shim so {@link local} can point its wrapped binding at it.
17+
*/
18+
const LOCAL_SEND_EMAIL_MODULE = "cloudflare-runtime:send-email";
19+
1220
export const SendEmailLive = Layer.succeed(
1321
SendEmail,
1422
SendEmail.of(
15-
Effect.map(formatExtensionModule(EmailWorker), (esModule) => ({
16-
extensions: [
17-
{
18-
modules: [
19-
{
20-
name: "cloudflare-internal:email",
21-
internal: true,
22-
esModule,
23-
},
24-
],
25-
},
26-
],
27-
})),
23+
Effect.zipWith(
24+
formatExtensionModule(EmailMessageWorker),
25+
formatExtensionModule(SendEmailBindingWorker),
26+
(emailMessage, sendEmailBinding) => ({
27+
extensions: [
28+
{
29+
modules: [
30+
{
31+
name: "cloudflare-internal:email",
32+
internal: true,
33+
esModule: emailMessage,
34+
},
35+
{
36+
name: LOCAL_SEND_EMAIL_MODULE,
37+
internal: true,
38+
esModule: sendEmailBinding,
39+
},
40+
],
41+
},
42+
],
43+
}),
44+
{ concurrent: true },
45+
),
2846
),
2947
);
3048

31-
export interface RemoteSendEmailProps {
49+
export interface SendEmailProps {
3250
readonly binding: string;
3351
readonly destinationAddress?: string;
3452
readonly allowedDestinationAddresses?: Array<string>;
@@ -42,7 +60,7 @@ export interface RemoteSendEmailProps {
4260
* the `EmailMessage` class to user code (workerd does not ship it
4361
* natively), matching the upstream Miniflare behavior.
4462
*/
45-
export const remote = (props: RemoteSendEmailProps): BindingHook<RemoteBindings | SendEmail> =>
63+
export const remote = (props: SendEmailProps): BindingHook<RemoteBindings | SendEmail> =>
4664
Plugin.use(SendEmail, () =>
4765
makeRemoteBinding(
4866
{
@@ -58,3 +76,22 @@ export const remote = (props: RemoteSendEmailProps): BindingHook<RemoteBindings
5876
}),
5977
),
6078
);
79+
80+
/**
81+
* Bind a local `send_email` stub for `alchemy dev`. `send()` logs the message
82+
* and resolves instead of delivering real mail — matching how Miniflare and
83+
* Wrangler treat `send_email` unless you opt into the real, remote binding.
84+
*
85+
* Reuses the `cloudflare-internal:email` `EmailMessage` shim registered by
86+
* {@link SendEmailLive} (same as {@link remote}), so user code can still
87+
* construct `EmailMessage` instances in dev. The address props are accepted
88+
* to mirror {@link remote}'s signature; the stub does not enforce them.
89+
*/
90+
export const local = (props: SendEmailProps): BindingHook<SendEmail> =>
91+
Plugin.useSync(SendEmail, () => ({
92+
name: props.binding,
93+
wrapped: {
94+
moduleName: LOCAL_SEND_EMAIL_MODULE,
95+
innerBindings: [],
96+
},
97+
}));
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Local `send_email` binding stub. Workerd has no real outbound email, and
3+
* under `alchemy dev` we deliberately do not proxy to the deployed binding
4+
* unless asked — so `send()` logs the message metadata and resolves instead
5+
* of delivering. This matches how Miniflare/Wrangler treat `send_email`
6+
* unless you opt into the real, remote binding. Mirrors the no-op
7+
* `analytics-engine.worker.ts` stub, but logs rather than discards so the
8+
* dev loop still shows what would have been sent.
9+
*/
10+
11+
interface LoggableMessage {
12+
from?: unknown;
13+
to?: unknown;
14+
subject?: unknown;
15+
}
16+
17+
const formatAddress = (value: unknown): string => {
18+
if (typeof value === "string") return value;
19+
if (Array.isArray(value)) return value.map(formatAddress).join(", ");
20+
if (value && typeof value === "object") {
21+
const email = (value as { email?: unknown }).email;
22+
if (typeof email === "string") return email;
23+
return JSON.stringify(value);
24+
}
25+
return "<unset>";
26+
};
27+
28+
class LocalSendEmail {
29+
// Accepts both the builder-form message and a pre-built `EmailMessage`
30+
// (which carries `from`/`to` but keeps the subject inside its raw MIME).
31+
async send(message: LoggableMessage): Promise<void> {
32+
const parts = [`from=${formatAddress(message?.from)}`, `to=${formatAddress(message?.to)}`];
33+
if (typeof message?.subject === "string") {
34+
parts.push(`subject=${JSON.stringify(message.subject)}`);
35+
}
36+
// Logging IS the behavior of this stub — surface the send in the dev loop.
37+
console.log(
38+
`[alchemy dev] send_email not delivered (${parts.join(" ")}); ` +
39+
`body suppressed locally — opt into the real binding with ` +
40+
`\`dev: { remote: true }\` to send for real`,
41+
);
42+
}
43+
}
44+
45+
export default function makeBinding(_env: unknown): LocalSendEmail {
46+
return new LocalSendEmail();
47+
}

0 commit comments

Comments
 (0)