Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/email-routing-addresses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"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"]
}
```
16 changes: 16 additions & 0 deletions packages/config/src/__tests__/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion packages/config/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ function convertExports(
}

// ═══════════════════════════════════════════════════════════════════════════
// TRIGGERS (scheduled + fetch + queue consumer)
// TRIGGERS (scheduled + fetch + queue consumer + email)
// ═══════════════════════════════════════════════════════════════════════════

function convertTriggers(
Expand All @@ -768,9 +768,14 @@ function convertTriggers(
const queueConsumers: NonNullable<
NonNullable<RawConfig["queues"]>["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;
Expand Down Expand Up @@ -812,6 +817,9 @@ function convertTriggers(
if (queueConsumers.length) {
result.queues = { ...(result.queues ?? {}), consumers: queueConsumers };
}
if (addresses.length) {
result.addresses = addresses;
}
}

// ═══════════════════════════════════════════════════════════════════════════
Expand Down
1 change: 1 addition & 0 deletions packages/config/src/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type {
export { bindings } from "./bindings";
export type {
Triggers,
EmailTrigger,
FetchTrigger,
QueueConsumerTrigger,
ScheduledTrigger,
Expand Down
6 changes: 4 additions & 2 deletions packages/config/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
32 changes: 29 additions & 3 deletions packages/config/src/triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
}

/**
Expand All @@ -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"] }),
* ],
* });
* ```
Expand All @@ -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 }),
};
12 changes: 9 additions & 3 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import type {
} from "./exports";
import type { WorkerModule } from "./inference";
import type {
EmailTrigger,
FetchTrigger,
QueueConsumerTrigger,
ScheduledTrigger,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down
15 changes: 15 additions & 0 deletions packages/workers-utils/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,20 @@ export interface ConfigFields<Dev extends RawDevConfig> {
* @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[];
Comment thread
DiogoSantoss marked this conversation as resolved.
}

// Pages-specific configuration fields
Expand Down Expand Up @@ -378,6 +392,7 @@ export const defaultWranglerConfig: Config = {
data_blobs: undefined,
keep_vars: undefined,
alias: undefined,
addresses: undefined,

/** INHERITABLE ENVIRONMENT FIELDS **/
account_id: undefined,
Expand Down
8 changes: 8 additions & 0 deletions packages/workers-utils/src/config/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,13 @@ export function normalizeAndValidateConfig(
"boolean"
);

validateOptionalTypedArray(
diagnostics,
"addresses",
rawConfig.addresses,
"string"
);

validateOptionalProperty(
diagnostics,
"",
Expand Down Expand Up @@ -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,
Comment thread
DiogoSantoss marked this conversation as resolved.
...activeEnv,
dev: normalizeAndValidateDev(diagnostics, rawConfig.dev ?? {}, args),
site: normalizeAndValidateSite(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
59 changes: 59 additions & 0 deletions packages/wrangler/src/__tests__/deploy/email-routing.test.ts
Original file line number Diff line number Diff line change
@@ -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.toThrow(
/to be of type string/
);
});
});
Loading
Loading