Skip to content
Closed
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
17 changes: 17 additions & 0 deletions alchemy/src/cloudflare/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export type WorkerBindingSpec =
| WorkerBindingSecretText
| WorkerBindingSecretsStore
| WorkerBindingSecretsStoreSecret
| WorkerBindingSendEmail
| WorkerBindingService
| WorkerBindingStaticContent
| WorkerBindingTailConsumer
Expand Down Expand Up @@ -407,6 +408,22 @@ export interface WorkerBindingSecretsStoreSecret {
secret_name: string;
}

/**
* Send Email binding type
*/
export interface WorkerBindingSendEmail {
/** The name of the binding */
name: string;
/** Type identifier for Send Email binding */
type: "send_email";
/** Single destination address (mutually exclusive with allowed_destination_addresses) */
destination_address?: string;
/** Allowlist of destination addresses (mutually exclusive with destination_address) */
allowed_destination_addresses?: string[];
/** Optional allowlist of sender addresses */
allowed_sender_addresses?: string[];
}

/**
* Service binding type
*/
Expand Down
49 changes: 47 additions & 2 deletions alchemy/src/cloudflare/miniflare/build-worker-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
} from "../bindings.ts";
import { isQueueEventSource, type EventSource } from "../event-source.ts";
import type { WorkerBundle, WorkerBundleSource } from "../worker-bundle.ts";
import type { AssetsConfig } from "../worker.ts";
import type { AssetsConfig, SendEmailConfig } from "../worker.ts";
import { createRemoteProxyWorker } from "./remote-binding-proxy.ts";

export interface MiniflareWorkerInput {
Expand All @@ -29,6 +29,7 @@ export interface MiniflareWorkerInput {
port: number | undefined;
tunnel: boolean | undefined;
cwd: string;
sendEmail: SendEmailConfig[] | undefined;
}

type RemoteOnlyBindingType =
Expand All @@ -42,7 +43,8 @@ type RemoteOptionalBindingType =
| "images"
| "kv_namespace"
| "queue"
| "r2_bucket";
| "r2_bucket"
| "send_email";

type RemoteBinding =
| (Extract<
Expand Down Expand Up @@ -363,6 +365,43 @@ export const buildWorkerOptions = async (
(options.queueConsumers ??= {})[eventSource.name] = {};
}
}
// Process SendEmail configurations
for (const config of input.sendEmail ?? []) {
if (config.dev?.remote) {
// Remote mode: Connect to real Cloudflare Email Routing
remoteBindings.push({
type: "send_email",
name: config.name,
destination_address:
"destinationAddress" in config
? config.destinationAddress
: undefined,
allowed_destination_addresses:
"allowedDestinationAddresses" in config
? config.allowedDestinationAddresses
: undefined,
allowed_sender_addresses: config.allowedSenderAddresses,
raw: true,
});
} else {
// Local mode: Create mock binding that logs to console
(options.bindings ??= {})[config.name] = {
send: (message: any) => {
logger.info(
`[SendEmail Mock] Would send email from ${message.from} to ${Array.isArray(message.to) ? message.to.join(", ") : message.to}`,
);
logger.info(`[SendEmail Mock] Subject: ${message.subject}`);
logger.info(
`[SendEmail Mock] Body: ${message.content?.text || message.content?.html || "(no content)"}`,
);
return Promise.resolve({
success: true,
id: `mock-${Date.now()}`,
});
},
};
}
}
async function* watch(signal: AbortSignal) {
for await (const bundle of input.bundle.watch(signal)) {
const { modules, rootPath } = normalizeBundle(bundle);
Expand Down Expand Up @@ -435,6 +474,12 @@ export const buildWorkerOptions = async (
remoteProxyConnectionString: remoteProxy.connectionString,
};
break;
case "send_email":
(options.bindings ??= {})[binding.name] = {
name: binding.name,
remoteProxyConnectionString: remoteProxy.connectionString,
};
break;
case "service":
(options.serviceBindings ??= {})[binding.name] = {
name: binding.name,
Expand Down
42 changes: 42 additions & 0 deletions alchemy/src/cloudflare/worker-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,15 @@ export interface WorkerMetadata {
cpu_ms?: number;
};
tail_consumers?: Array<Worker | { service: string }>;
/**
* Send Email configurations (snake_case for API)
*/
send_email?: Array<{
name: string;
destination_address?: string;
allowed_destination_addresses?: string[];
allowed_sender_addresses?: string[];
}>;
}

export async function prepareWorkerMetadata(
Expand Down Expand Up @@ -339,13 +348,46 @@ export async function prepareWorkerMetadata(

const observability = camelToSnakeObjectDeep(props.observability);

// Validate sendEmail configurations
if (props.sendEmail) {
for (const config of props.sendEmail) {
// Validate mutual exclusivity
if (config.destinationAddress && config.allowedDestinationAddresses) {
throw new Error(
`Send Email config "${config.name}": cannot specify both destinationAddress and allowedDestinationAddresses`,
);
}

// Basic email validation
const validateEmail = (email: string) => {
if (!email.includes("@")) {
throw new Error(`Invalid email address: ${email}`);
}
};

if (config.destinationAddress) validateEmail(config.destinationAddress);
if (config.allowedDestinationAddresses) {
config.allowedDestinationAddresses.forEach(validateEmail);
}
if (config.allowedSenderAddresses) {
config.allowedSenderAddresses.forEach(validateEmail);
}
}
}

// Prepare metadata with bindings
const meta: WorkerMetadata = {
compatibility_date: props.compatibilityDate,
compatibility_flags: props.compatibilityFlags,
tail_consumers: props.tailConsumers?.map((consumer) =>
isWorker(consumer) ? { service: consumer.name } : consumer,
),
send_email: props.sendEmail?.map((config) => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this always at the top level instead of under bindings?

I understand it's at the top level in a wrangler.jsonc but the API docs seem to indicate this goes in the bindings array of the worker metadata object (link).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is at the top level. The API might change, I am not 100% sure.

name: config.name,
destination_address: config.destinationAddress,
allowed_destination_addresses: config.allowedDestinationAddresses,
allowed_sender_addresses: config.allowedSenderAddresses,
})),
bindings: [],
observability: {
...observability,
Expand Down
99 changes: 99 additions & 0 deletions alchemy/src/cloudflare/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,14 @@ export interface BaseWorkerProps<
* Tail consumers that will receive execution logs from this worker
*/
tailConsumers?: Array<Worker | { service: string }>;

/**
* Send Email configurations for this worker
*
* @example
* sendEmail: [{ name: "EMAIL", allowedSenderAddresses: ["[email protected]"] }]
*/
sendEmail?: SendEmailConfig[];
}

export interface WorkerObservability {
Expand Down Expand Up @@ -601,6 +609,96 @@ export interface WorkerPlacementHostname {
hostname: string;
}

/**
* Configuration for a Send Email binding
*
* Allows Workers to send transactional emails from verified domains.
* Requires Cloudflare Email Routing enabled and Workers Paid plan.
*
* Only one destination mode can be specified at a time (mutually exclusive).
*
* @see https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/
*/
export type SendEmailConfig =
| SendEmailSingleDestination
| SendEmailMultipleDestinations;

/**
* Send Email configuration with a single restricted destination address.
*
* Use this when you want to restrict all emails sent via this binding
* to a single destination address.
*
* @see https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/
*/
export interface SendEmailSingleDestination {
/**
* The binding name to access the Send Email service
*/
name: string;

/**
* Restrict emails to this single destination address
*/
destinationAddress: string;

/**
* Optional allowlist of sender addresses
*/
allowedSenderAddresses?: string[];

/**
* Development configuration
*/
dev?: {
/**
* Whether to run remotely instead of using local mock
* - false (default): Logs email attempts to console without sending
* - true: Connects to Cloudflare Email Routing to actually send emails
* @default false
*/
remote?: boolean;
};
}

/**
* Send Email configuration with multiple allowed destination addresses.
*
* Use this when you want to allow emails to be sent to a specific set
* of destination addresses.
*
* @see https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/
*/
export interface SendEmailMultipleDestinations {
/**
* The binding name to access the Send Email service
*/
name: string;

/**
* Allowlist of destination addresses
*/
allowedDestinationAddresses: string[];

/**
* Optional allowlist of sender addresses
*/
allowedSenderAddresses?: string[];

/**
* Development configuration
*/
dev?: {
/**
* Whether to run remotely instead of using local mock
* - false (default): Logs email attempts to console without sending
* - true: Connects to Cloudflare Email Routing to actually send emails
* @default false
*/
remote?: boolean;
};
}

export interface InlineWorkerProps<
B extends Bindings | undefined = Bindings,
RPC extends Rpc.WorkerEntrypointBranded = Rpc.WorkerEntrypointBranded,
Expand Down Expand Up @@ -1128,6 +1226,7 @@ const _Worker = Resource(
port: props.dev?.port,
tunnel: props.dev?.tunnel ?? this.scope.tunnel,
cwd: props.cwd ?? process.cwd(),
sendEmail: props.sendEmail,
});
this.onCleanup(() => controller.dispose());
}
Expand Down
9 changes: 9 additions & 0 deletions alchemy/src/cloudflare/wrangler.json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,15 @@ export async function WranglerJson(
});
}

if (worker.sendEmail && worker.sendEmail.length > 0) {
spec.send_email = worker.sendEmail.map((config) => ({
name: config.name,
destination_address: config.destinationAddress,
allowed_destination_addresses: config.allowedDestinationAddresses,
allowed_sender_addresses: config.allowedSenderAddresses,
}));
}

if (worker.crons && worker.crons.length > 0) {
spec.triggers = { crons: worker.crons };
}
Expand Down
Loading