From 6a585d14c2da804ef8258de08cec2fc6aa03cc78 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Mon, 29 Jun 2026 13:33:14 +0100 Subject: [PATCH 1/3] [wrangler] Add top-level `addresses` config field for Email Routing EMAIL-1880: parse and validate the new top-level `addresses` field in wrangler config (literal recipients or `*@domain` catch-alls). It is top-level only (cannot be set under `env.*`), validated locally (array of non-empty strings, no exact duplicates), and printed on `wrangler deploy --dry-run` without any network calls. Applying the addresses during deploy follows in EMAIL-1882. --- .changeset/email-routing-addresses.md | 18 +++++ packages/workers-utils/src/config/config.ts | 15 +++++ .../workers-utils/src/config/validation.ts | 8 +++ .../normalize-and-validate-config.test.ts | 65 +++++++++++++++++++ .../__tests__/deploy/email-routing.test.ts | 59 +++++++++++++++++ 5 files changed, 165 insertions(+) create mode 100644 .changeset/email-routing-addresses.md create mode 100644 packages/wrangler/src/__tests__/deploy/email-routing.test.ts diff --git a/.changeset/email-routing-addresses.md b/.changeset/email-routing-addresses.md new file mode 100644 index 00000000000..f6ed11af09b --- /dev/null +++ b/.changeset/email-routing-addresses.md @@ -0,0 +1,18 @@ +--- +"wrangler": minor +--- + +Add a top-level `addresses` field to Wrangler configuration for Email Routing + +You can now declare the inbound email addresses handled by your Worker directly in `wrangler.json`: + +```json +{ + "name": "my-worker", + "main": "src/index.ts", + "compatibility_date": "2026-05-21", + "addresses": ["support@example.com", "*@example.com"] +} +``` + +Each entry is a literal recipient address or a `*@domain` catch-all. `addresses` is top-level only (it applies to every environment; setting it under `env.*` is ignored with a warning, like other top-level-only fields). Locally, Wrangler validates that the field is an array of strings; semantic checks (duplicate targets, address resolution) are performed by the Email Routing API at deploy time, and `wrangler deploy --dry-run` runs that validation without making any network calls. A non-dry-run `wrangler deploy` currently accepts the field without acting on it; applying these addresses during `wrangler deploy` follows in a later change. diff --git a/packages/workers-utils/src/config/config.ts b/packages/workers-utils/src/config/config.ts index 557125138a7..ed0a6c75a50 100644 --- a/packages/workers-utils/src/config/config.ts +++ b/packages/workers-utils/src/config/config.ts @@ -194,6 +194,20 @@ export interface ConfigFields { * @nonInheritable */ keep_vars?: boolean; + + /** + * The inbound email addresses handled by the Worker being deployed. + * + * Each entry is a literal recipient address (e.g. `"support@example.com"`) + * or a `*@domain` catch-all (e.g. `"*@example.com"`). Every entry creates an + * Email Routing rule whose action routes mail to this Worker. + * + * This field is top-level only and applies to every environment of the + * Worker; it cannot be set under `env.*`. + * + * @nonInheritable + */ + addresses?: string[]; } // Pages-specific configuration fields @@ -378,6 +392,7 @@ export const defaultWranglerConfig: Config = { data_blobs: undefined, keep_vars: undefined, alias: undefined, + addresses: undefined, /** INHERITABLE ENVIRONMENT FIELDS **/ account_id: undefined, diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index 97e3ecc7bf8..a65c96e70c1 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -341,6 +341,13 @@ export function normalizeAndValidateConfig( "boolean" ); + validateOptionalTypedArray( + diagnostics, + "addresses", + rawConfig.addresses, + "string" + ); + validateOptionalProperty( diagnostics, "", @@ -506,6 +513,7 @@ export function normalizeAndValidateConfig( send_metrics: rawConfig.send_metrics, dependencies_instrumentation: rawConfig.dependencies_instrumentation, keep_vars: rawConfig.keep_vars, + addresses: rawConfig.addresses, ...activeEnv, dev: normalizeAndValidateDev(diagnostics, rawConfig.dev ?? {}, args), site: normalizeAndValidateSite( diff --git a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts index 045dd4a6129..970d7cc0e9a 100644 --- a/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts +++ b/packages/workers-utils/tests/config/validation/normalize-and-validate-config.test.ts @@ -116,6 +116,7 @@ describe("normalizeAndValidateConfig()", () => { minify: undefined, first_party_worker: undefined, keep_vars: undefined, + addresses: undefined, logpush: undefined, upload_source_maps: undefined, placement: undefined, @@ -10780,3 +10781,67 @@ function normalizePath(text: string): string { .replace("src\\index.ts", "src/index.ts") .replace("path\\to\\tsconfig", "path/to/tsconfig"); } + +describe("normalizeAndValidateConfig() - addresses (Email Routing)", () => { + function validate(rawConfig: RawConfig) { + return normalizeAndValidateConfig(rawConfig, undefined, undefined, { + env: undefined, + }); + } + + it("defaults to undefined when not present", ({ expect }) => { + const { config, diagnostics } = validate({}); + expect(config.addresses).toBeUndefined(); + expect(diagnostics.hasErrors()).toBe(false); + }); + + it("accepts an array of literal and catch-all addresses", ({ expect }) => { + const { config, diagnostics } = validate({ + addresses: ["support@example.com", "*@example.com"], + }); + expect(diagnostics.hasErrors()).toBe(false); + expect(config.addresses).toEqual(["support@example.com", "*@example.com"]); + }); + + it("errors when addresses is not an array", ({ expect }) => { + // @ts-expect-error intentionally invalid type + const { diagnostics } = validate({ addresses: "support@example.com" }); + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.errors).toContain( + `Expected "addresses" to be an array of strings but got "support@example.com"` + ); + }); + + it("errors on a non-string entry", ({ expect }) => { + // @ts-expect-error intentionally invalid entry type + const { diagnostics } = validate({ addresses: ["ok@example.com", 123] }); + expect(diagnostics.hasErrors()).toBe(true); + expect(diagnostics.errors).toContain( + `Expected "addresses.[1]" to be of type string but got 123.` + ); + }); + + it("warns and ignores addresses set under an active env.* (top-level only)", ({ + expect, + }) => { + const { config, diagnostics } = normalizeAndValidateConfig( + { + env: { + staging: { + // @ts-expect-error addresses is top-level only, not a per-env field + addresses: ["support@example.com"], + }, + }, + }, + undefined, + undefined, + { env: "staging" } + ); + expect(diagnostics.hasWarnings()).toBe(true); + expect(diagnostics.renderWarnings()).toContain( + `Unexpected fields found in env.staging field: "addresses"` + ); + // Like other top-level-only fields, it is ignored rather than promoted. + expect(config.addresses).toBeUndefined(); + }); +}); diff --git a/packages/wrangler/src/__tests__/deploy/email-routing.test.ts b/packages/wrangler/src/__tests__/deploy/email-routing.test.ts new file mode 100644 index 00000000000..beadebfc509 --- /dev/null +++ b/packages/wrangler/src/__tests__/deploy/email-routing.test.ts @@ -0,0 +1,59 @@ +import * as fs from "node:fs"; +import { + runInTempDir, + writeWranglerConfig, +} from "@cloudflare/workers-utils/test-helpers"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { clearOutputFilePath } from "../../output"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import { clearDialogs } from "../helpers/mock-dialogs"; +import { useMockIsTTY } from "../helpers/mock-istty"; +import { runWrangler } from "../helpers/run-wrangler"; + +vi.mock("../../autoconfig/run"); + +describe("deploy --dry-run (Email Routing addresses)", () => { + mockAccountId(); + mockApiToken(); + runInTempDir(); + const { setIsTTY } = useMockIsTTY(); + const std = mockConsoleMethods(); + + beforeEach(() => { + setIsTTY(true); + }); + + afterEach(() => { + clearDialogs(); + clearOutputFilePath(); + }); + + it("accepts valid addresses on dry-run and exits without uploading", async ({ + expect, + }) => { + writeWranglerConfig({ + addresses: ["support@example.com", "*@example.com"], + }); + fs.writeFileSync("index.js", "export default {};"); + + await runWrangler("deploy index.js --dry-run"); + + expect(std.out).toContain("--dry-run: exiting now."); + expect(std.err).toBe(""); + }); + + it("fails validation for malformed addresses before uploading", async ({ + expect, + }) => { + writeWranglerConfig({ + // @ts-expect-error intentionally invalid entry type + addresses: ["ok@example.com", 123], + }); + fs.writeFileSync("index.js", "export default {};"); + + await expect(runWrangler("deploy index.js --dry-run")).rejects.toThrowError( + /to be of type string/ + ); + }); +}); From 827abcf2e8241698e527be43938066d067f2a8c8 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Fri, 3 Jul 2026 14:16:58 +0100 Subject: [PATCH 2/3] [wrangler] Support email triggers in new config --- .changeset/email-routing-addresses.md | 2 -- packages/config/src/__tests__/convert.test.ts | 16 ++++++++++ packages/config/src/convert.ts | 10 +++++- packages/config/src/public.ts | 1 + packages/config/src/schema.ts | 6 ++-- packages/config/src/triggers.ts | 32 +++++++++++++++++-- packages/config/src/types.ts | 12 +++++-- .../experimental-config/load.test.ts | 29 +++++++++++++++++ 8 files changed, 97 insertions(+), 11 deletions(-) diff --git a/.changeset/email-routing-addresses.md b/.changeset/email-routing-addresses.md index f6ed11af09b..3a628ea9bc1 100644 --- a/.changeset/email-routing-addresses.md +++ b/.changeset/email-routing-addresses.md @@ -14,5 +14,3 @@ You can now declare the inbound email addresses handled by your Worker directly "addresses": ["support@example.com", "*@example.com"] } ``` - -Each entry is a literal recipient address or a `*@domain` catch-all. `addresses` is top-level only (it applies to every environment; setting it under `env.*` is ignored with a warning, like other top-level-only fields). Locally, Wrangler validates that the field is an array of strings; semantic checks (duplicate targets, address resolution) are performed by the Email Routing API at deploy time, and `wrangler deploy --dry-run` runs that validation without making any network calls. A non-dry-run `wrangler deploy` currently accepts the field without acting on it; applying these addresses during `wrangler deploy` follows in a later change. diff --git a/packages/config/src/__tests__/convert.test.ts b/packages/config/src/__tests__/convert.test.ts index 1d9ab0fc29d..5f908fefb08 100644 --- a/packages/config/src/__tests__/convert.test.ts +++ b/packages/config/src/__tests__/convert.test.ts @@ -802,6 +802,22 @@ describe("convertToWranglerConfig", () => { }); describe("triggers", () => { + it("maps email triggers to addresses", ({ expect }) => { + const result = convertToWranglerConfig({ + ...baseConfig, + triggers: [ + { + type: "email", + addresses: ["support@example.com", "*@example.com"], + }, + ], + }); + expect(result.addresses).toEqual([ + "support@example.com", + "*@example.com", + ]); + }); + it("maps scheduled triggers to triggers.crons", ({ expect }) => { const result = convertToWranglerConfig({ ...baseConfig, diff --git a/packages/config/src/convert.ts b/packages/config/src/convert.ts index 7dd044955f9..bedd29115b7 100644 --- a/packages/config/src/convert.ts +++ b/packages/config/src/convert.ts @@ -749,7 +749,7 @@ function convertExports( } // ═══════════════════════════════════════════════════════════════════════════ -// TRIGGERS (scheduled + fetch + queue consumer) +// TRIGGERS (scheduled + fetch + queue consumer + email) // ═══════════════════════════════════════════════════════════════════════════ function convertTriggers( @@ -768,9 +768,14 @@ function convertTriggers( const queueConsumers: NonNullable< NonNullable["consumers"] > = result.queues?.consumers ? [...result.queues.consumers] : []; + const addresses: string[] = result.addresses ? [...result.addresses] : []; for (const trigger of triggers) { switch (trigger.type) { + case "email": { + addresses.push(...trigger.addresses); + break; + } case "scheduled": { crons.push(trigger.schedule); break; @@ -812,6 +817,9 @@ function convertTriggers( if (queueConsumers.length) { result.queues = { ...(result.queues ?? {}), consumers: queueConsumers }; } + if (addresses.length) { + result.addresses = addresses; + } } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/config/src/public.ts b/packages/config/src/public.ts index d8ca780a5bc..777a939b29c 100644 --- a/packages/config/src/public.ts +++ b/packages/config/src/public.ts @@ -53,6 +53,7 @@ export type { export { bindings } from "./bindings"; export type { Triggers, + EmailTrigger, FetchTrigger, QueueConsumerTrigger, ScheduledTrigger, diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index fb4fcf298cd..f05ab60a6ef 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -381,8 +381,10 @@ const TailConsumerSchema = z.strictObject({ }); const TriggerSchema = z.discriminatedUnion("type", [ - // TODO: email triggers not yet implemented - // z.strictObject({ type: z.literal("email") }), + z.strictObject({ + type: z.literal("email"), + addresses: z.array(z.string()), + }), z.strictObject({ type: z.literal("fetch"), pattern: z.string(), diff --git a/packages/config/src/triggers.ts b/packages/config/src/triggers.ts index b3f20577094..9d4103c7258 100644 --- a/packages/config/src/triggers.ts +++ b/packages/config/src/triggers.ts @@ -79,10 +79,29 @@ export interface ScheduledTrigger extends ScheduledTriggerOptions { type: "scheduled"; } +interface EmailTriggerOptions { + /** + * Inbound Email Routing addresses handled by this Worker. + * + * Each entry is a literal recipient address (e.g. `"support@example.com"`) + * or a `*@domain` catch-all (e.g. `"*@example.com"`). + */ + addresses: string[]; +} + /** - * Event triggers — fetch routes, queue consumers, and cron schedules - * — that invoke this Worker. Construct entries with `triggers.fetch(...)`, - * `triggers.queue(...)`, or `triggers.scheduled(...)`. + * Email trigger — invokes this Worker for the configured Email Routing + * addresses. + */ +export interface EmailTrigger extends EmailTriggerOptions { + type: "email"; +} + +/** + * Event triggers — fetch routes, queue consumers, cron schedules, and Email + * Routing addresses — that invoke this Worker. Construct entries with + * `triggers.fetch(...)`, `triggers.queue(...)`, `triggers.scheduled(...)`, or + * `triggers.email(...)`. * * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#triggers */ @@ -106,6 +125,11 @@ export interface Triggers { * More details here https://developers.cloudflare.com/workers/platform/cron-triggers */ scheduled(options: ScheduledTriggerOptions): ScheduledTrigger; + /** + * Email trigger — invokes this Worker for the configured Email Routing + * addresses. + */ + email(options: EmailTriggerOptions): EmailTrigger; } /** @@ -121,6 +145,7 @@ export interface Triggers { * triggers.queue({ name: "my-queue" }), * triggers.scheduled({ schedule: "0 * * * *" }), * triggers.scheduled({ schedule: "30 0 * * *" }), + * triggers.email({ addresses: ["support@example.com"] }), * ], * }); * ``` @@ -129,4 +154,5 @@ export const triggers: Triggers = { fetch: (options) => ({ type: "fetch", ...options }), queue: (options) => ({ type: "queue", ...options }), scheduled: (options) => ({ type: "scheduled", ...options }), + email: (options) => ({ type: "email", ...options }), }; diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index fe8e9ec225e..9922a14f6d9 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -56,6 +56,7 @@ import type { } from "./exports"; import type { WorkerModule } from "./inference"; import type { + EmailTrigger, FetchTrigger, QueueConsumerTrigger, ScheduledTrigger, @@ -107,7 +108,11 @@ type Binding = /** * Union of all trigger definitions accepted in `triggers`. */ -type Trigger = FetchTrigger | QueueConsumerTrigger | ScheduledTrigger; +type Trigger = + | EmailTrigger + | FetchTrigger + | QueueConsumerTrigger + | ScheduledTrigger; /** * Union of all export definitions accepted in `exports`. Worker entries @@ -211,9 +216,10 @@ export interface UserConfig { domains?: string[]; /** - * Event triggers — fetch routes, queue consumers, and cron schedules + * Event triggers — fetch routes, queue consumers, cron schedules, and Email + * Routing addresses * — that invoke this Worker. Construct entries with `triggers.fetch(...)`, - * `triggers.queue(...)`, or `triggers.scheduled(...)`. + * `triggers.queue(...)`, `triggers.scheduled(...)`, or `triggers.email(...)`. * * For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#triggers */ diff --git a/packages/wrangler/src/__tests__/experimental-config/load.test.ts b/packages/wrangler/src/__tests__/experimental-config/load.test.ts index b1edb804af0..714db81527d 100644 --- a/packages/wrangler/src/__tests__/experimental-config/load.test.ts +++ b/packages/wrangler/src/__tests__/experimental-config/load.test.ts @@ -317,6 +317,35 @@ describe("loadNewConfig", () => { }); }); + describe("Email Routing", () => { + it("loads email triggers as Wrangler addresses", async ({ expect }) => { + await seed({ + "cloudflare.config.ts": ` + export default { + name: "email-worker", + compatibilityDate: "2026-05-18", + triggers: [ + { + type: "email", + addresses: ["support@example.com", "*@example.com"], + }, + ], + }; + `, + }); + + const result = await loadNewConfig({ + cwd: process.cwd(), + args: {}, + }); + + expect(result.rawConfig.addresses).toEqual([ + "support@example.com", + "*@example.com", + ]); + }); + }); + describe("types.generate", () => { it("defaults to true when wrangler.config.ts is absent", async ({ expect, From 0ac6690d6ea7c4af29c3317ffb3151eec02bffad Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Wed, 15 Jul 2026 10:16:42 +0100 Subject: [PATCH 3/3] test(wrangler): use supported error matcher --- packages/wrangler/src/__tests__/deploy/email-routing.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/__tests__/deploy/email-routing.test.ts b/packages/wrangler/src/__tests__/deploy/email-routing.test.ts index beadebfc509..0aa765b09ab 100644 --- a/packages/wrangler/src/__tests__/deploy/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/deploy/email-routing.test.ts @@ -52,7 +52,7 @@ describe("deploy --dry-run (Email Routing addresses)", () => { }); fs.writeFileSync("index.js", "export default {};"); - await expect(runWrangler("deploy index.js --dry-run")).rejects.toThrowError( + await expect(runWrangler("deploy index.js --dry-run")).rejects.toThrow( /to be of type string/ ); });