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
8 changes: 8 additions & 0 deletions .changeset/email-routing-deploy-apply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"wrangler": minor
"@cloudflare/deploy-helpers": minor
---

Apply Email Routing `addresses` during Worker trigger deployment

Worker trigger deployment now reconciles the Worker's Email Routing rules with the top-level `addresses` config. This runs for `wrangler deploy`, `wrangler triggers deploy`, and clients of `@cloudflare/deploy-helpers`. After the Worker uploads, or when `wrangler triggers deploy` runs after a version promotion, the deploy helper asks the Email Routing API for a plan, renders the changes grouped by zone (`+` added, `~` updated, `-` deleted, `!` conflict), prompts once for destructive changes in interactive mode, and applies accepted changes through the per-zone rule endpoints. Purely additive plans apply without a prompt, while non-interactive destructive plans fail without modifying rules.
9 changes: 9 additions & 0 deletions packages/config/src/__tests__/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,14 @@ describe("convertToWranglerConfig", () => {
]);
});

it("preserves empty email trigger addresses", ({ expect }) => {
const result = convertToWranglerConfig({
...baseConfig,
triggers: [{ type: "email", addresses: [] }],
});
expect(result.addresses).toEqual([]);
});

it("maps scheduled triggers to triggers.crons", ({ expect }) => {
const result = convertToWranglerConfig({
...baseConfig,
Expand All @@ -871,6 +879,7 @@ describe("convertToWranglerConfig", () => {
expect(result.triggers).toEqual({
crons: ["0 * * * *", "*/5 * * * *"],
});
expect(result.addresses).toBeUndefined();
});

it("maps fetch trigger with dot-zone to zone_name", ({ expect }) => {
Expand Down
6 changes: 4 additions & 2 deletions packages/config/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,11 +768,12 @@ function convertTriggers(
const queueConsumers: NonNullable<
NonNullable<RawConfig["queues"]>["consumers"]
> = result.queues?.consumers ? [...result.queues.consumers] : [];
const addresses: string[] = result.addresses ? [...result.addresses] : [];
let addresses: string[] | undefined;

for (const trigger of triggers) {
switch (trigger.type) {
case "email": {
addresses ??= [];
addresses.push(...trigger.addresses);
break;
}
Expand Down Expand Up @@ -817,7 +818,8 @@ function convertTriggers(
if (queueConsumers.length) {
result.queues = { ...(result.queues ?? {}), consumers: queueConsumers };
}
if (addresses.length) {
// An empty array removes managed addresses; undefined means no email trigger.
if (addresses !== undefined) {
result.addresses = addresses;
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/deploy-helpers/src/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@ export default async function deploy(
config,
accountId,
scriptName,
workerTag,
env: props.env,
crons: props.triggers,
firstDeploy: !workerExists,
Expand Down
1 change: 1 addition & 0 deletions packages/deploy-helpers/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export type TriggerProps = {
config: Config;
accountId: string;
scriptName: string;
workerTag?: string | null;
env: string | undefined;
crons: string[] | undefined;
routes: Route[];
Expand Down
15 changes: 15 additions & 0 deletions packages/deploy-helpers/src/triggers/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import chalk from "chalk";
import PQueue from "p-queue";
import { WORKFLOW_CRON_REQUIRES_PAID_PLAN_CODE } from "../deploy/helpers/error-codes";
import { fetchListResult, fetchResult, logger } from "../shared/context";
import { applyEmailRoutingAddresses } from "./email-routing";
import {
publishCustomDomains,
publishRoutes,
Expand Down Expand Up @@ -341,6 +342,20 @@ export async function triggersDeploy(
.map((deployment) => deployment.error)
.filter((error): error is Error => error !== undefined);

try {
await applyEmailRoutingAddresses({
config,
accountId,
scriptName,
workerTag: props.workerTag,
});
} catch (error) {
if (errors.length === 0) {
throw error;
}
errors.push(error instanceof Error ? error : new Error(String(error)));
}

if (errors.length > 0) {
throw new UserError(
`Some triggers failed to deploy for ${workerName}:\n` +
Expand Down
236 changes: 236 additions & 0 deletions packages/deploy-helpers/src/triggers/email-routing-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/**
* Pure plan logic for the `addresses` config field: build the plan request and
* render the plan response. No network access.
*/

interface EmailRoutingAction {
type: string;
value?: string[];
}

interface EmailRoutingMatcher {
type: string;
field?: string;
value?: string;
}

/** A `*@domain` catch-all target (e.g. `"*@example.com"`), not a literal recipient. */
export function isCatchAllAddress(address: string): boolean {
return address.startsWith("*@") && address.length > "*@".length;
}

export interface EmailRoutingPlanRule {
matchers: EmailRoutingMatcher[];
actions: EmailRoutingAction[];
}

export interface EmailRoutingPlanCatchAll {
/** The `*@domain` target this catch-all rule applies to. */
target: string;
rule: EmailRoutingPlanRule;
}

export interface EmailRoutingPlanRequest {
owner_worker_tag: string;
rules: EmailRoutingPlanRule[];
catch_all_rules: EmailRoutingPlanCatchAll[];
}

export type PlanChangeType = "added" | "updated" | "deleted" | "conflict";

export interface PlanRemoteRule {
id?: string;
matchers: EmailRoutingMatcher[];
actions: EmailRoutingAction[];
source?: "api" | "wrangler";
owner_worker_name?: string;
}

export interface EmailRoutingPlanChange {
type: PlanChangeType;
/** Recipient address or `*@domain` catch-all target this change applies to. */
target: string;
/** Present for updates, deletes, and conflicts. */
remote?: PlanRemoteRule;
}

export interface EmailRoutingPlanZone {
zone_id: string;
zone_name?: string;
changes: EmailRoutingPlanChange[];
}

export interface EmailRoutingPlanResponse {
zones: EmailRoutingPlanZone[];
}

/**
* The matchers + actions routing one address to `workerName`. Single source of
* truth shared by the plan request and the apply bodies.
*/
export function ruleShapeForTarget(
target: string,
workerName: string
): EmailRoutingPlanRule {
const actions: EmailRoutingAction[] = [
{ type: "worker", value: [workerName] },
];
if (isCatchAllAddress(target)) {
return { matchers: [{ type: "all" }], actions };
}
return {
matchers: [{ type: "literal", field: "to", value: target }],
actions,
};
}

/**
* Compile the `addresses` config into a plan request: literal recipients become
* normal rules, `*@domain` entries become catch-all rules carrying the target.
*/
export function buildEmailRoutingPlanRequest(
addresses: string[],
workerName: string,
ownerWorkerTag: string
): EmailRoutingPlanRequest {
const rules: EmailRoutingPlanRule[] = [];
const catchAllRules: EmailRoutingPlanCatchAll[] = [];

for (const address of addresses) {
const rule = ruleShapeForTarget(address, workerName);
if (isCatchAllAddress(address)) {
catchAllRules.push({ target: address, rule });
} else {
rules.push(rule);
}
}

return {
owner_worker_tag: ownerWorkerTag,
rules,
catch_all_rules: catchAllRules,
};
}

/** Changes that need user confirmation: deletes (incl. catch-all resets) and conflicts. */
export function isDestructiveChange(change: EmailRoutingPlanChange): boolean {
return change.type === "deleted" || change.type === "conflict";
}

export function planHasChanges(plan: EmailRoutingPlanResponse): boolean {
return plan.zones.some((zone) => zone.changes.length > 0);
}

export function planHasDestructiveChanges(
plan: EmailRoutingPlanResponse
): boolean {
return plan.zones.some((zone) => zone.changes.some(isDestructiveChange));
}

const CHANGE_MARKERS: Record<PlanChangeType, string> = {
added: "+",
updated: "~",
deleted: "-",
conflict: "!",
};

/** Human description of what a remote rule currently does, for conflict lines. */
function describeRemoteAction(remote: PlanRemoteRule): string {
const action = remote.actions[0];
if (!action) {
return "no action";
}
const value = action.value?.join(", ");
switch (action.type) {
case "forward":
return value ? `forward to ${value}` : "forward";
case "worker":
return value ? `worker ${value}` : "worker";
case "drop":
return "drop";
default:
return action.type;
}
}

/** Human description of who owns a conflicting remote rule. */
function describeConflictOwner(remote: PlanRemoteRule): string {
if (remote.source === "wrangler") {
return remote.owner_worker_name
? `owned by worker "${remote.owner_worker_name}"`
: "owned by another Worker";
}
return "managed outside Wrangler";
}

/**
* Render the plan grouped by zone, one change per line (`+ ~ - !`) plus a
* summary. Returns lines so callers can log and tests can assert.
*/
export function renderEmailRoutingPlan(
plan: EmailRoutingPlanResponse,
workerName: string
): string[] {
const lines: string[] = [];
const counts: Record<PlanChangeType, number> = {
added: 0,
updated: 0,
deleted: 0,
conflict: 0,
};
let zonesWithChanges = 0;

for (const zone of plan.zones) {
if (zone.changes.length === 0) {
continue;
}
zonesWithChanges++;
lines.push(zone.zone_name ?? zone.zone_id);

for (const change of zone.changes) {
counts[change.type]++;
const marker = CHANGE_MARKERS[change.type];
switch (change.type) {
case "added":
case "updated":
lines.push(` ${marker} ${change.target} -> worker (${workerName})`);
break;
case "deleted":
lines.push(` ${marker} ${change.target} (removed from config)`);
break;
case "conflict": {
const remote = change.remote;
const detail = remote
? `conflict: ${describeConflictOwner(remote)} (${describeRemoteAction(remote)})`
: "conflict";
lines.push(` ${marker} ${change.target} -> ${detail}`);
break;
}
}
}
}

const total =
counts.added + counts.updated + counts.deleted + counts.conflict;
const parts: string[] = [];
if (counts.added) {
parts.push(`${counts.added} added`);
}
if (counts.updated) {
parts.push(`${counts.updated} updated`);
}
if (counts.deleted) {
parts.push(`${counts.deleted} deleted`);
}
if (counts.conflict) {
parts.push(`${counts.conflict} conflict`);
}

lines.push(
`${total} ${total === 1 ? "change" : "changes"} across ${zonesWithChanges} ${
zonesWithChanges === 1 ? "zone" : "zones"
}${parts.length ? ` (${parts.join(", ")})` : ""}`
);

return lines;
}
Loading
Loading