From ccfadc882b360241db05de50ce8285ea08b72ab9 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Mon, 29 Jun 2026 14:01:34 +0100 Subject: [PATCH 1/7] [wrangler] Apply Email Routing addresses during deploy EMAIL-1882: after the Worker uploads, `wrangler deploy` plans the Email Routing changes for the `addresses` config against the new account-level plan endpoint, renders them grouped by zone, prompts once for destructive changes (deletes/takeover conflicts) interactively and hard-fails in non-interactive mode, then applies accepted changes via the per-zone rule endpoints tagged source=wrangler + owner_worker_tag. Stacked on EMAIL-1880. --- .changeset/email-routing-deploy-apply.md | 7 + .../deploy/email-routing-apply.test.ts | 505 ++++++++++++++++++ .../src/__tests__/email-routing/plan.test.ts | 161 ++++++ packages/wrangler/src/deploy/index.ts | 10 + packages/wrangler/src/email-routing/apply.ts | 274 ++++++++++ packages/wrangler/src/email-routing/client.ts | 6 + packages/wrangler/src/email-routing/plan.ts | 227 ++++++++ 7 files changed, 1190 insertions(+) create mode 100644 .changeset/email-routing-deploy-apply.md create mode 100644 packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts create mode 100644 packages/wrangler/src/__tests__/email-routing/plan.test.ts create mode 100644 packages/wrangler/src/email-routing/apply.ts create mode 100644 packages/wrangler/src/email-routing/plan.ts diff --git a/.changeset/email-routing-deploy-apply.md b/.changeset/email-routing-deploy-apply.md new file mode 100644 index 00000000000..e1bf732e460 --- /dev/null +++ b/.changeset/email-routing-deploy-apply.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Apply Email Routing `addresses` during `wrangler deploy` + +`wrangler deploy` now reconciles the Worker's Email Routing rules with the top-level `addresses` config. After the Worker uploads, Wrangler asks the Email Routing API for a plan, renders the changes grouped by zone (`+` added, `~` updated, `-` deleted, `!` conflict), prompts once for any destructive changes (deletes or takeover conflicts) in interactive mode — and hard-fails in non-interactive/CI mode — then applies the accepted changes through the per-zone rule endpoints, tagging them as owned by the deploying Worker. Purely additive plans apply without a prompt, and `wrangler deploy --dry-run` still only validates and prints the desired set without any network calls. diff --git a/packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts b/packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts new file mode 100644 index 00000000000..1dfcd4dfa91 --- /dev/null +++ b/packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts @@ -0,0 +1,505 @@ +import { http, HttpResponse } from "msw"; +import { afterEach, beforeEach, describe, it } from "vitest"; +import { applyEmailRoutingAddresses } from "../../email-routing/apply"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import { clearDialogs, mockConfirm } from "../helpers/mock-dialogs"; +import { useMockIsTTY } from "../helpers/mock-istty"; +import { createFetchResult, msw } from "../helpers/msw"; +import type { EmailRoutingPlanResponse } from "../../email-routing/plan"; +import type { Config } from "@cloudflare/workers-utils"; + +/** + * Unit tests for the apply orchestrator, driven directly (not through + * `wrangler deploy`) so they stay decoupled from the deploy pipeline. The plan + * endpoint and the per-zone rule endpoints are mocked with MSW; `config` only + * needs `addresses` plus the (defaulted) compliance fields `fetchResult` reads. + */ + +const ACCOUNT_ID = "some-account-id"; +const WORKER_TAG = "a7e6fb77503c41d8a7f3113c6918f10c"; +const WORKER_NAME = "test-name"; + +function testConfig(addresses: string[]): Config { + return { addresses } as unknown as Config; +} + +interface RuleWrites { + posts: unknown[]; + puts: { id: string; body: unknown }[]; + deletes: string[]; + catchAlls: unknown[]; +} + +describe("applyEmailRoutingAddresses", () => { + mockAccountId(); + mockApiToken(); + const { setIsTTY } = useMockIsTTY(); + const std = mockConsoleMethods(); + + beforeEach(() => { + setIsTTY(true); + }); + + afterEach(() => { + clearDialogs(); + }); + + function mockPlan( + plan: EmailRoutingPlanResponse, + captured?: { body?: unknown } + ) { + msw.use( + http.post( + "*/accounts/:accountId/email/routing/rules/plan", + async ({ request }) => { + if (captured) { + captured.body = await request.json(); + } + return HttpResponse.json(createFetchResult(plan)); + } + ) + ); + } + + function mockRuleWrites(writes: RuleWrites, failTarget?: string) { + msw.use( + http.post("*/zones/:zoneId/email/routing/rules", async ({ request }) => { + const body = (await request.json()) as { + matchers: { value?: string }[]; + }; + writes.posts.push(body); + if (failTarget && body.matchers[0]?.value === failTarget) { + return HttpResponse.json( + createFetchResult(null, false, [ + { code: 2014, message: "duplicate rule" }, + ]), + { status: 409 } + ); + } + return HttpResponse.json(createFetchResult({ tag: "new-rule-id" })); + }), + http.put( + "*/zones/:zoneId/email/routing/rules/catch_all", + async ({ request }) => { + writes.catchAlls.push(await request.json()); + return HttpResponse.json(createFetchResult({})); + } + ), + http.put( + "*/zones/:zoneId/email/routing/rules/:ruleId", + async ({ request, params }) => { + writes.puts.push({ + id: String(params.ruleId), + body: await request.json(), + }); + return HttpResponse.json(createFetchResult({})); + } + ), + http.delete( + "*/zones/:zoneId/email/routing/rules/:ruleId", + async ({ params }) => { + writes.deletes.push(String(params.ruleId)); + return HttpResponse.json(createFetchResult({})); + } + ) + ); + } + + function emptyWrites(): RuleWrites { + return { posts: [], puts: [], deletes: [], catchAlls: [] }; + } + + function apply(addresses: string[], workerTag: string | null = WORKER_TAG) { + return applyEmailRoutingAddresses({ + config: testConfig(addresses), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + workerTag, + }); + } + + it("applies an additive plan without prompting", async ({ expect }) => { + const planBody: { body?: unknown } = {}; + mockPlan( + { + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [{ type: "added", target: "support@example.com" }], + }, + ], + }, + planBody + ); + const writes = emptyWrites(); + mockRuleWrites(writes); + + await apply(["support@example.com"]); + + // Plan request carried the deploying Worker's tag. + expect(planBody.body).toMatchObject({ owner_worker_tag: WORKER_TAG }); + expect(std.out).toContain("Email Routing plan:"); + expect(std.out).toContain("+ support@example.com -> worker (test-name)"); + expect(std.out).toContain("Email Routing addresses applied."); + expect(writes.posts).toHaveLength(1); + expect(writes.posts[0]).toMatchObject({ + source: "wrangler", + owner_worker_tag: WORKER_TAG, + matchers: [ + { type: "literal", field: "to", value: "support@example.com" }, + ], + actions: [{ type: "worker", value: [WORKER_NAME] }], + }); + expect(std.err).toBe(""); + }); + + it("resolves the Worker tag from the API when not provided", async ({ + expect, + }) => { + msw.use( + http.get("*/accounts/:accountId/workers/services/:scriptName", () => + HttpResponse.json( + createFetchResult({ + default_environment: { script: { tag: WORKER_TAG } }, + }) + ) + ) + ); + const planBody: { body?: unknown } = {}; + mockPlan({ zones: [] }, planBody); + + await apply(["support@example.com"], null); + + expect(planBody.body).toMatchObject({ owner_worker_tag: WORKER_TAG }); + expect(std.out).toContain("Email Routing addresses already up to date."); + }); + + it("reports when addresses are already up to date", async ({ expect }) => { + mockPlan({ zones: [] }); + + await apply(["support@example.com"]); + + expect(std.out).toContain("Email Routing addresses already up to date."); + }); + + it("applies a destructive plan after interactive confirmation", async ({ + expect, + }) => { + mockConfirm({ + text: "Apply these Email Routing changes (including the destructive ones above)?", + result: true, + }); + mockPlan({ + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [ + { + type: "conflict", + target: "support@example.com", + remote: { + id: "existing-rule-id", + source: "api", + matchers: [ + { + type: "literal", + field: "to", + value: "support@example.com", + }, + ], + actions: [{ type: "forward", value: ["a@b.com"] }], + }, + }, + ], + }, + ], + }); + const writes = emptyWrites(); + mockRuleWrites(writes); + + await apply(["support@example.com"]); + + // Takeover applied via PUT to the existing rule id, with ownership. + expect(writes.puts).toHaveLength(1); + expect(writes.puts[0].id).toBe("existing-rule-id"); + expect(writes.puts[0].body).toMatchObject({ + source: "wrangler", + owner_worker_tag: WORKER_TAG, + }); + expect(std.out).toContain("Email Routing addresses applied."); + }); + + it("aborts (non-zero) when a destructive plan is declined", async ({ + expect, + }) => { + mockConfirm({ + text: "Apply these Email Routing changes (including the destructive ones above)?", + result: false, + }); + mockPlan({ + zones: [ + { + zone_id: "zone1", + changes: [{ type: "deleted", target: "old@example.com" }], + }, + ], + }); + + await expect(apply(["support@example.com"])).rejects.toThrowError( + /Email Routing changes were declined/ + ); + }); + + it("hard-fails on destructive changes in non-interactive mode", async ({ + expect, + }) => { + setIsTTY(false); + mockPlan({ + zones: [ + { + zone_id: "zone1", + changes: [{ type: "deleted", target: "old@example.com" }], + }, + ], + }); + + await expect(apply(["support@example.com"])).rejects.toThrowError( + /destructive changes .* need confirmation/ + ); + }); + + it("reports partial success (non-zero) when a change fails to apply", async ({ + expect, + }) => { + mockPlan({ + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [ + { type: "added", target: "ok@example.com" }, + { type: "added", target: "bad@example.com" }, + ], + }, + ], + }); + const writes = emptyWrites(); + mockRuleWrites(writes, "bad@example.com"); + + await expect( + apply(["ok@example.com", "bad@example.com"]) + ).rejects.toThrowError( + /Email Routing was not fully applied \(1 change\(s\) failed\)/ + ); + }); + + it("applies an 'updated' change via PUT to the existing rule (no prompt)", async ({ + expect, + }) => { + mockPlan({ + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [ + { + type: "updated", + target: "support@example.com", + remote: { + id: "existing-id", + source: "wrangler", + matchers: [ + { + type: "literal", + field: "to", + value: "support@example.com", + }, + ], + actions: [{ type: "worker", value: [WORKER_NAME] }], + }, + }, + ], + }, + ], + }); + const writes = emptyWrites(); + mockRuleWrites(writes); + + await apply(["support@example.com"]); + + expect(writes.posts).toHaveLength(0); + expect(writes.puts).toHaveLength(1); + expect(writes.puts[0].id).toBe("existing-id"); + expect(writes.puts[0].body).toMatchObject({ + source: "wrangler", + owner_worker_tag: WORKER_TAG, + matchers: [ + { type: "literal", field: "to", value: "support@example.com" }, + ], + actions: [{ type: "worker", value: [WORKER_NAME] }], + }); + expect(std.out).toContain("Email Routing addresses applied."); + }); + + it("writes the catch-all via PUT with ownership when added", async ({ + expect, + }) => { + mockPlan({ + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [{ type: "added", target: "*@example.com" }], + }, + ], + }); + const writes = emptyWrites(); + mockRuleWrites(writes); + + await apply(["*@example.com"]); + + expect(writes.posts).toHaveLength(0); + expect(writes.catchAlls).toHaveLength(1); + expect(writes.catchAlls[0]).toMatchObject({ + source: "wrangler", + owner_worker_tag: WORKER_TAG, + enabled: true, + matchers: [{ type: "all" }], + actions: [{ type: "worker", value: [WORKER_NAME] }], + }); + }); + + it("resets the catch-all to the disabled-drop default on delete", async ({ + expect, + }) => { + mockConfirm({ + text: "Apply these Email Routing changes (including the destructive ones above)?", + result: true, + }); + mockPlan({ + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [{ type: "deleted", target: "*@example.com" }], + }, + ], + }); + const writes = emptyWrites(); + mockRuleWrites(writes); + + await apply(["support@example.com"]); + + // Catch-all has no DELETE endpoint: a delete resets to the generated + // default and clears ownership (source=api), never a DELETE call. + expect(writes.deletes).toHaveLength(0); + expect(writes.catchAlls).toHaveLength(1); + expect(writes.catchAlls[0]).toMatchObject({ + source: "api", + enabled: false, + name: "", + matchers: [{ type: "all" }], + actions: [{ type: "drop" }], + }); + }); + + it("reports failure when the plan omits the rule id for an update", async ({ + expect, + }) => { + mockPlan({ + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [ + { + type: "updated", + target: "support@example.com", + remote: { + matchers: [ + { + type: "literal", + field: "to", + value: "support@example.com", + }, + ], + actions: [{ type: "worker", value: [WORKER_NAME] }], + }, + }, + ], + }, + ], + }); + const writes = emptyWrites(); + mockRuleWrites(writes); + + await expect(apply(["support@example.com"])).rejects.toThrowError( + /Email Routing was not fully applied/ + ); + expect(std.err).toContain("missing the rule id"); + expect(writes.puts).toHaveLength(0); + }); + + it("applies a mixed multi-zone plan: add + catch-all + delete + update", async ({ + expect, + }) => { + mockConfirm({ + text: "Apply these Email Routing changes (including the destructive ones above)?", + result: true, + }); + mockPlan({ + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [ + { type: "added", target: "new@example.com" }, + { type: "added", target: "*@example.com" }, + { + type: "deleted", + target: "old@example.com", + remote: { + id: "old-1", + matchers: [ + { type: "literal", field: "to", value: "old@example.com" }, + ], + actions: [{ type: "worker", value: [WORKER_NAME] }], + }, + }, + ], + }, + { + zone_id: "zone2", + zone_name: "example.net", + changes: [ + { + type: "updated", + target: "keep@example.net", + remote: { + id: "keep-1", + source: "wrangler", + matchers: [ + { type: "literal", field: "to", value: "keep@example.net" }, + ], + actions: [{ type: "worker", value: [WORKER_NAME] }], + }, + }, + ], + }, + ], + }); + const writes = emptyWrites(); + mockRuleWrites(writes); + + await apply(["new@example.com", "*@example.com", "keep@example.net"]); + + expect(writes.posts).toHaveLength(1); // new@ literal create + expect(writes.catchAlls).toHaveLength(1); // *@ catch-all PUT + expect(writes.deletes).toEqual(["old-1"]); // literal delete by id + expect(writes.puts.map((p) => p.id)).toEqual(["keep-1"]); // update by id + expect(std.out).toContain("Email Routing addresses applied."); + }); +}); diff --git a/packages/wrangler/src/__tests__/email-routing/plan.test.ts b/packages/wrangler/src/__tests__/email-routing/plan.test.ts new file mode 100644 index 00000000000..ba8339f1407 --- /dev/null +++ b/packages/wrangler/src/__tests__/email-routing/plan.test.ts @@ -0,0 +1,161 @@ +import { describe, it } from "vitest"; +import { + buildEmailRoutingPlanRequest, + isDestructiveChange, + planHasChanges, + planHasDestructiveChanges, + renderEmailRoutingPlan, +} from "../../email-routing/plan"; +import type { EmailRoutingPlanResponse } from "../../email-routing/plan"; + +describe("buildEmailRoutingPlanRequest", () => { + it("compiles literal addresses into normal rules targeting the worker", ({ + expect, + }) => { + const req = buildEmailRoutingPlanRequest( + ["support@example.com"], + "my-worker", + "a7e6fb77503c41d8a7f3113c6918f10c" + ); + expect(req.owner_worker_tag).toBe("a7e6fb77503c41d8a7f3113c6918f10c"); + expect(req.catch_all_rules).toEqual([]); + expect(req.rules).toEqual([ + { + matchers: [ + { type: "literal", field: "to", value: "support@example.com" }, + ], + actions: [{ type: "worker", value: ["my-worker"] }], + }, + ]); + }); + + it("compiles *@domain entries into catch-all rules with a target", ({ + expect, + }) => { + const req = buildEmailRoutingPlanRequest( + ["*@example.com"], + "my-worker", + "tag" + ); + expect(req.rules).toEqual([]); + expect(req.catch_all_rules).toEqual([ + { + target: "*@example.com", + rule: { + matchers: [{ type: "all" }], + actions: [{ type: "worker", value: ["my-worker"] }], + }, + }, + ]); + }); +}); + +describe("renderEmailRoutingPlan", () => { + const plan: EmailRoutingPlanResponse = { + zones: [ + { + zone_id: "zonetag1", + zone_name: "example.com", + changes: [ + { type: "added", target: "support@example.com" }, + { + type: "conflict", + target: "billing@example.com", + remote: { + id: "r1", + source: "api", + matchers: [ + { type: "literal", field: "to", value: "billing@example.com" }, + ], + actions: [ + { type: "forward", value: ["billing-team@example.net"] }, + ], + }, + }, + ], + }, + { + zone_id: "zonetag2", + zone_name: "example.net", + changes: [ + { type: "deleted", target: "old@example.net" }, + { + type: "conflict", + target: "*@example.net", + remote: { + id: "r2", + source: "wrangler", + owner_worker_name: "other-worker", + matchers: [{ type: "all" }], + actions: [{ type: "worker", value: ["other-worker"] }], + }, + }, + ], + }, + ], + }; + + it("groups by zone with +/~/-/! markers and a summary line", ({ expect }) => { + const lines = renderEmailRoutingPlan(plan, "my-worker"); + expect(lines).toEqual([ + "example.com", + " + support@example.com -> worker (my-worker)", + ` ! billing@example.com -> conflict: owned by source=api (forward to billing-team@example.net)`, + "example.net", + " - old@example.net (removed from config)", + ` ! *@example.net -> conflict: owned by worker "other-worker" (worker other-worker)`, + "4 changes across 2 zones (1 added, 1 deleted, 2 conflict)", + ]); + }); + + it("omits zones with no changes", ({ expect }) => { + const lines = renderEmailRoutingPlan( + { + zones: [ + { zone_id: "z", zone_name: "noop.com", changes: [] }, + { + zone_id: "z2", + zone_name: "a.com", + changes: [{ type: "added", target: "x@a.com" }], + }, + ], + }, + "my-worker" + ); + expect(lines).toEqual([ + "a.com", + " + x@a.com -> worker (my-worker)", + "1 change across 1 zone (1 added)", + ]); + }); +}); + +describe("destructive-change helpers", () => { + it("treats deletes and conflicts as destructive; added/updated are not", ({ + expect, + }) => { + expect(isDestructiveChange({ type: "added", target: "a" })).toBe(false); + expect(isDestructiveChange({ type: "updated", target: "a" })).toBe(false); + expect(isDestructiveChange({ type: "deleted", target: "a" })).toBe(true); + expect(isDestructiveChange({ type: "conflict", target: "a" })).toBe(true); + }); + + it("detects presence of changes / destructive changes across zones", ({ + expect, + }) => { + const additive: EmailRoutingPlanResponse = { + zones: [{ zone_id: "z", changes: [{ type: "added", target: "a" }] }], + }; + expect(planHasChanges(additive)).toBe(true); + expect(planHasDestructiveChanges(additive)).toBe(false); + + const withConflict: EmailRoutingPlanResponse = { + zones: [{ zone_id: "z", changes: [{ type: "conflict", target: "a" }] }], + }; + expect(planHasDestructiveChanges(withConflict)).toBe(true); + + expect(planHasChanges({ zones: [{ zone_id: "z", changes: [] }] })).toBe( + false + ); + }); +}); diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index 5db3222d215..cb90790dced 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -14,6 +14,7 @@ import { cleanupDestination, mergeDeployConfigArgs, } from "../deployment-bundle/merge-config-args"; +import { applyEmailRoutingAddresses } from "../email-routing/apply"; import { experimentalNewConfigArg } from "../experimental-config/cli-flag"; import * as metrics from "../metrics"; import { writeOutput } from "../output"; @@ -184,6 +185,15 @@ export async function runDeployCommandHandler( analyseBundle, }); + if (!props.dryRun) { + await applyEmailRoutingAddresses({ + config, + accountId: props.accountId, + scriptName: props.name, + workerTag, + }); + } + writeOutput({ type: "deploy", version: 1, diff --git a/packages/wrangler/src/email-routing/apply.ts b/packages/wrangler/src/email-routing/apply.ts new file mode 100644 index 00000000000..bbfcde0e42f --- /dev/null +++ b/packages/wrangler/src/email-routing/apply.ts @@ -0,0 +1,274 @@ +import assert from "node:assert"; +import { APIError, UserError } from "@cloudflare/workers-utils"; +import { fetchResult } from "../cfetch"; +import { confirm } from "../dialogs"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; +import { + createEmailRoutingRule, + deleteEmailRoutingRule, + updateEmailRoutingCatchAll, + updateEmailRoutingRule, +} from "./client"; +import { + buildEmailRoutingPlanRequest, + isCatchAllAddress, + planHasChanges, + planHasDestructiveChanges, + renderEmailRoutingPlan, + ruleShapeForTarget, +} from "./plan"; +import type { + EmailRoutingPlanChange, + EmailRoutingPlanRequest, + EmailRoutingPlanResponse, +} from "./plan"; +import type { Config } from "@cloudflare/workers-utils"; + +/** + * Network side of the `addresses` config field: plan the change set via the + * account-level endpoint, then apply it through the rule client. Pure logic + * lives in `plan.ts`. + */ + +const PLAN_RETRY_WORKER_NOT_FOUND_CODE = 2016; +const PLAN_RETRY_TIMEOUT_MS = 30_000; +const PLAN_RETRY_DELAY_MS = 3_000; + +function postJson(body: unknown): RequestInit { + return { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +/** The desired rule body for a target owned by this Worker (create/update). */ +function desiredRuleBody( + target: string, + workerName: string, + ownerWorkerTag: string +) { + return { + ...ruleShapeForTarget(target, workerName), + enabled: true, + source: "wrangler", + owner_worker_tag: ownerWorkerTag, + }; +} + +/** Generated default catch-all (drop, disabled), with ownership cleared. */ +const RESET_CATCH_ALL_BODY = { + matchers: [{ type: "all" }], + actions: [{ type: "drop" }], + enabled: false, + name: "", + source: "api", +}; + +/** Resolve the deployed Worker's stable public tag (used as owner_worker_tag). */ +async function resolveOwnerWorkerTag( + config: Config, + accountId: string, + scriptName: string +): Promise { + const { default_environment } = await fetchResult<{ + default_environment: { script: { tag: string } }; + }>(config, `/accounts/${accountId}/workers/services/${scriptName}`); + return default_environment.script.tag; +} + +/** + * Call the account-level plan endpoint. The Worker was just uploaded, so retry + * on "worker not found" (2016) until it propagates or we hit the ~30s cap. + */ +async function fetchEmailRoutingPlan( + config: Config, + accountId: string, + request: EmailRoutingPlanRequest +): Promise { + const deadline = Date.now() + PLAN_RETRY_TIMEOUT_MS; + for (;;) { + try { + return await fetchResult( + config, + `/accounts/${accountId}/email/routing/rules/plan`, + postJson(request) + ); + } catch (e) { + const isWorkerNotFound = + e instanceof APIError && e.code === PLAN_RETRY_WORKER_NOT_FOUND_CODE; + if (!isWorkerNotFound) { + throw e; + } + if (Date.now() + PLAN_RETRY_DELAY_MS >= deadline) { + throw new UserError( + "Email Routing could not find the deployed Worker yet (it may still be propagating). Re-run `wrangler deploy`.", + { telemetryMessage: "email routing plan worker not found" } + ); + } + await new Promise((resolve) => setTimeout(resolve, PLAN_RETRY_DELAY_MS)); + } + } +} + +/** Apply a single plan change through the canonical per-zone rule client. */ +async function applyChange( + config: Config, + zoneId: string, + change: EmailRoutingPlanChange, + workerName: string, + ownerWorkerTag: string +): Promise { + if (isCatchAllAddress(change.target)) { + // Catch-all has no DELETE endpoint: a delete is a reset to the default. + await updateEmailRoutingCatchAll( + config, + zoneId, + change.type === "deleted" + ? RESET_CATCH_ALL_BODY + : desiredRuleBody(change.target, workerName, ownerWorkerTag) + ); + return; + } + + switch (change.type) { + case "added": + await createEmailRoutingRule( + config, + zoneId, + desiredRuleBody(change.target, workerName, ownerWorkerTag) + ); + return; + // `conflict` only reaches here once the user accepted the takeover; it is + // applied the same way as an update — overwrite the existing rule. + case "updated": + case "conflict": { + const id = change.remote?.id; + if (!id) { + throw new UserError( + `Email Routing plan was missing the rule id for "${change.target}"; re-run \`wrangler deploy\`.`, + { telemetryMessage: "email routing plan missing rule id" } + ); + } + await updateEmailRoutingRule( + config, + zoneId, + id, + desiredRuleBody(change.target, workerName, ownerWorkerTag) + ); + return; + } + case "deleted": { + const id = change.remote?.id; + if (!id) { + throw new UserError( + `Email Routing plan was missing the rule id for "${change.target}"; re-run \`wrangler deploy\`.`, + { telemetryMessage: "email routing plan missing rule id" } + ); + } + await deleteEmailRoutingRule(config, zoneId, id); + return; + } + } +} + +/** + * Reconcile the Worker's Email Routing rules with the `addresses` config during + * `wrangler deploy`. No-op when `addresses` is absent. Runs after upload, so a + * failure leaves the Worker deployed but reports partial success. Prompts once + * for destructive changes and hard-fails non-interactively. + */ +export async function applyEmailRoutingAddresses({ + config, + accountId, + scriptName, + workerTag, +}: { + config: Config; + accountId: string | undefined; + scriptName: string | undefined; + workerTag: string | null; +}): Promise { + const { addresses } = config; + if (addresses === undefined) { + return; + } + + assert(accountId, "Missing accountId"); + assert(scriptName, "Missing Worker name"); + + const ownerWorkerTag = + workerTag ?? (await resolveOwnerWorkerTag(config, accountId, scriptName)); + const request = buildEmailRoutingPlanRequest( + addresses, + scriptName, + ownerWorkerTag + ); + const plan = await fetchEmailRoutingPlan(config, accountId, request); + + if (!planHasChanges(plan)) { + logger.log("Email Routing addresses already up to date."); + return; + } + + logger.log( + ["Email Routing plan:", ...renderEmailRoutingPlan(plan, scriptName)].join( + "\n" + ) + ); + + if (planHasDestructiveChanges(plan)) { + if (isNonInteractiveOrCI()) { + throw new UserError( + "Worker deployed, but Email Routing has destructive changes (deletes or takeover conflicts) that need confirmation. " + + "Re-run `wrangler deploy` interactively, or remove the conflicting entries from `addresses`.", + { + telemetryMessage: + "email routing destructive changes need confirmation", + } + ); + } + const accepted = await confirm( + "Apply these Email Routing changes (including the destructive ones above)?", + { defaultValue: false } + ); + if (!accepted) { + throw new UserError( + "Worker deployed, but the Email Routing changes were declined; no rules were modified.", + { telemetryMessage: "email routing changes declined" } + ); + } + } + + const failures: string[] = []; + for (const zone of plan.zones) { + for (const change of zone.changes) { + try { + await applyChange( + config, + zone.zone_id, + change, + scriptName, + ownerWorkerTag + ); + } catch (e) { + failures.push( + `${change.target}: ${e instanceof Error ? e.message : String(e)}` + ); + } + } + } + + if (failures.length > 0) { + for (const failure of failures) { + logger.error(`Email Routing change failed — ${failure}`); + } + throw new UserError( + `Worker deployed, but Email Routing was not fully applied (${failures.length} change(s) failed). Re-run \`wrangler deploy\` to retry.`, + { telemetryMessage: "email routing apply partial failure" } + ); + } + + logger.log("Email Routing addresses applied."); +} diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index 50415d432d6..a3d31ad19ae 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -136,6 +136,8 @@ export async function createEmailRoutingRule( name?: string; enabled?: boolean; priority?: number; + source?: string; + owner_worker_tag?: string; } ): Promise { await requireAuth(config); @@ -160,6 +162,8 @@ export async function updateEmailRoutingRule( name?: string; enabled?: boolean; priority?: number; + source?: string; + owner_worker_tag?: string; } ): Promise { await requireAuth(config); @@ -208,6 +212,8 @@ export async function updateEmailRoutingCatchAll( matchers: { type: string }[]; enabled?: boolean; name?: string; + source?: string; + owner_worker_tag?: string; } ): Promise { await requireAuth(config); diff --git a/packages/wrangler/src/email-routing/plan.ts b/packages/wrangler/src/email-routing/plan.ts new file mode 100644 index 00000000000..91265e886af --- /dev/null +++ b/packages/wrangler/src/email-routing/plan.ts @@ -0,0 +1,227 @@ +import type { EmailRoutingAction, EmailRoutingMatcher } from "./index"; + +/** + * Pure plan logic for the `addresses` config field: build the plan request and + * render the plan response. No network access — see `apply.ts`. + */ + +/** 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 = { + 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 "owned by source=api"; +} + +/** + * 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 = { + 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; +} From 52c0889116a0648d7ffea02f83b5e3ff6b624772 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Tue, 14 Jul 2026 22:25:34 +0100 Subject: [PATCH 2/7] [wrangler] Preserve empty Email Routing addresses --- packages/config/src/__tests__/convert.test.ts | 9 +++++++++ packages/config/src/convert.ts | 6 ++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/config/src/__tests__/convert.test.ts b/packages/config/src/__tests__/convert.test.ts index 715a26c54c7..7a81337d030 100644 --- a/packages/config/src/__tests__/convert.test.ts +++ b/packages/config/src/__tests__/convert.test.ts @@ -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, @@ -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 }) => { diff --git a/packages/config/src/convert.ts b/packages/config/src/convert.ts index bedd29115b7..5862a10c9b5 100644 --- a/packages/config/src/convert.ts +++ b/packages/config/src/convert.ts @@ -768,11 +768,12 @@ function convertTriggers( const queueConsumers: NonNullable< NonNullable["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; } @@ -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; } } From dd03b772f9aa6aab0bad3b2ff82785e09d02f216 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Tue, 14 Jul 2026 22:25:44 +0100 Subject: [PATCH 3/7] [wrangler] Report Email Routing apply progress --- .../deploy/email-routing-apply.test.ts | 26 +++++- packages/wrangler/src/email-routing/apply.ts | 80 +++++++++++++++---- 2 files changed, 88 insertions(+), 18 deletions(-) diff --git a/packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts b/packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts index 1dfcd4dfa91..1758e1eb019 100644 --- a/packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts +++ b/packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts @@ -155,6 +155,28 @@ describe("applyEmailRoutingAddresses", () => { expect(std.err).toBe(""); }); + it("reports apply progress in non-interactive output", async ({ expect }) => { + setIsTTY(false); + mockPlan({ + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [ + { type: "added", target: "one@example.com" }, + { type: "added", target: "two@example.com" }, + ], + }, + ], + }); + mockRuleWrites(emptyWrites()); + + await apply(["one@example.com", "two@example.com"]); + + expect(std.out).toContain("Applying Email Routing changes (0/2, 0%)"); + expect(std.out).toContain("Applying Email Routing changes (2/2, 100%)"); + }); + it("resolves the Worker tag from the API when not provided", async ({ expect, }) => { @@ -173,7 +195,7 @@ describe("applyEmailRoutingAddresses", () => { await apply(["support@example.com"], null); expect(planBody.body).toMatchObject({ owner_worker_tag: WORKER_TAG }); - expect(std.out).toContain("Email Routing addresses already up to date."); + expect(std.out).toContain("Email Routing rules are up to date."); }); it("reports when addresses are already up to date", async ({ expect }) => { @@ -181,7 +203,7 @@ describe("applyEmailRoutingAddresses", () => { await apply(["support@example.com"]); - expect(std.out).toContain("Email Routing addresses already up to date."); + expect(std.out).toContain("Email Routing rules are up to date."); }); it("applies a destructive plan after interactive confirmation", async ({ diff --git a/packages/wrangler/src/email-routing/apply.ts b/packages/wrangler/src/email-routing/apply.ts index bbfcde0e42f..3220842dbe4 100644 --- a/packages/wrangler/src/email-routing/apply.ts +++ b/packages/wrangler/src/email-routing/apply.ts @@ -1,8 +1,9 @@ import assert from "node:assert"; +import { spinner } from "@cloudflare/cli-shared-helpers/interactive"; import { APIError, UserError } from "@cloudflare/workers-utils"; import { fetchResult } from "../cfetch"; import { confirm } from "../dialogs"; -import { isNonInteractiveOrCI } from "../is-interactive"; +import isInteractive, { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { createEmailRoutingRule, @@ -34,6 +35,39 @@ import type { Config } from "@cloudflare/workers-utils"; const PLAN_RETRY_WORKER_NOT_FOUND_CODE = 2016; const PLAN_RETRY_TIMEOUT_MS = 30_000; const PLAN_RETRY_DELAY_MS = 3_000; +const NON_INTERACTIVE_PROGRESS_INTERVAL = 10; + +function applyProgressMessage(done: number, total: number): string { + return `Applying Email Routing changes (${done}/${total}, ${Math.floor( + (done * 100) / total + )}%)`; +} + +function startApplyProgress(total: number): { + update(done: number): void; + stop(): void; +} { + if (isInteractive()) { + const progress = spinner(); + progress.start(applyProgressMessage(0, total)); + + return { + update: (done) => progress.update(applyProgressMessage(done, total)), + stop: () => progress.stop(), + }; + } + + logger.log(applyProgressMessage(0, total)); + + return { + update(done) { + if (done === total || done % NON_INTERACTIVE_PROGRESS_INTERVAL === 0) { + logger.log(applyProgressMessage(done, total)); + } + }, + stop() {}, + }; +} function postJson(body: unknown): RequestInit { return { @@ -208,7 +242,7 @@ export async function applyEmailRoutingAddresses({ const plan = await fetchEmailRoutingPlan(config, accountId, request); if (!planHasChanges(plan)) { - logger.log("Email Routing addresses already up to date."); + logger.log("Email Routing rules are up to date."); return; } @@ -242,22 +276,36 @@ export async function applyEmailRoutingAddresses({ } const failures: string[] = []; - for (const zone of plan.zones) { - for (const change of zone.changes) { - try { - await applyChange( - config, - zone.zone_id, - change, - scriptName, - ownerWorkerTag - ); - } catch (e) { - failures.push( - `${change.target}: ${e instanceof Error ? e.message : String(e)}` - ); + const totalChanges = plan.zones.reduce( + (total, zone) => total + zone.changes.length, + 0 + ); + const progress = startApplyProgress(totalChanges); + let completedChanges = 0; + + try { + for (const zone of plan.zones) { + for (const change of zone.changes) { + try { + await applyChange( + config, + zone.zone_id, + change, + scriptName, + ownerWorkerTag + ); + } catch (e) { + failures.push( + `${change.target}: ${e instanceof Error ? e.message : String(e)}` + ); + } finally { + completedChanges++; + progress.update(completedChanges); + } } } + } finally { + progress.stop(); } if (failures.length > 0) { From 81ac155177796498250029b5967f6b2d71d6a60d Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Tue, 14 Jul 2026 22:48:22 +0100 Subject: [PATCH 4/7] [wrangler] Move Email Routing apply to deploy helpers --- .changeset/email-routing-deploy-apply.md | 5 +- packages/deploy-helpers/src/deploy/deploy.ts | 1 + packages/deploy-helpers/src/shared/types.ts | 1 + .../deploy-helpers/src/triggers/deploy.ts | 8 + .../src/triggers/email-routing-plan.ts} | 15 +- .../src/triggers/email-routing.ts} | 116 ++-- .../tests/email-routing-apply.test.ts | 386 +++++++++++++ .../tests/email-routing-plan.test.ts} | 4 +- .../tests/triggers-email-routing.test.ts | 103 ++++ .../deploy/email-routing-apply.test.ts | 527 ------------------ packages/wrangler/src/deploy/index.ts | 10 - packages/wrangler/src/email-routing/client.ts | 6 - 12 files changed, 584 insertions(+), 598 deletions(-) rename packages/{wrangler/src/email-routing/plan.ts => deploy-helpers/src/triggers/email-routing-plan.ts} (96%) rename packages/{wrangler/src/email-routing/apply.ts => deploy-helpers/src/triggers/email-routing.ts} (70%) create mode 100644 packages/deploy-helpers/tests/email-routing-apply.test.ts rename packages/{wrangler/src/__tests__/email-routing/plan.test.ts => deploy-helpers/tests/email-routing-plan.test.ts} (97%) create mode 100644 packages/deploy-helpers/tests/triggers-email-routing.test.ts delete mode 100644 packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts diff --git a/.changeset/email-routing-deploy-apply.md b/.changeset/email-routing-deploy-apply.md index e1bf732e460..20ff82101bb 100644 --- a/.changeset/email-routing-deploy-apply.md +++ b/.changeset/email-routing-deploy-apply.md @@ -1,7 +1,8 @@ --- "wrangler": minor +"@cloudflare/deploy-helpers": minor --- -Apply Email Routing `addresses` during `wrangler deploy` +Apply Email Routing `addresses` during Worker trigger deployment -`wrangler deploy` now reconciles the Worker's Email Routing rules with the top-level `addresses` config. After the Worker uploads, Wrangler asks the Email Routing API for a plan, renders the changes grouped by zone (`+` added, `~` updated, `-` deleted, `!` conflict), prompts once for any destructive changes (deletes or takeover conflicts) in interactive mode — and hard-fails in non-interactive/CI mode — then applies the accepted changes through the per-zone rule endpoints, tagging them as owned by the deploying Worker. Purely additive plans apply without a prompt, and `wrangler deploy --dry-run` still only validates and prints the desired set without any network calls. +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. diff --git a/packages/deploy-helpers/src/deploy/deploy.ts b/packages/deploy-helpers/src/deploy/deploy.ts index c4a4851fbc9..9e53e890352 100644 --- a/packages/deploy-helpers/src/deploy/deploy.ts +++ b/packages/deploy-helpers/src/deploy/deploy.ts @@ -747,6 +747,7 @@ export default async function deploy( config, accountId, scriptName, + workerTag, env: props.env, crons: props.triggers, firstDeploy: !workerExists, diff --git a/packages/deploy-helpers/src/shared/types.ts b/packages/deploy-helpers/src/shared/types.ts index 679687188f9..e8fcd216841 100644 --- a/packages/deploy-helpers/src/shared/types.ts +++ b/packages/deploy-helpers/src/shared/types.ts @@ -161,6 +161,7 @@ export type TriggerProps = { config: Config; accountId: string; scriptName: string; + workerTag?: string | null; env: string | undefined; crons: string[] | undefined; routes: Route[]; diff --git a/packages/deploy-helpers/src/triggers/deploy.ts b/packages/deploy-helpers/src/triggers/deploy.ts index c7a98191b34..02181cd9e2f 100644 --- a/packages/deploy-helpers/src/triggers/deploy.ts +++ b/packages/deploy-helpers/src/triggers/deploy.ts @@ -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, @@ -357,6 +358,13 @@ export async function triggersDeploy( ); } + await applyEmailRoutingAddresses({ + config, + accountId, + scriptName, + workerTag: props.workerTag, + }); + return targets; } diff --git a/packages/wrangler/src/email-routing/plan.ts b/packages/deploy-helpers/src/triggers/email-routing-plan.ts similarity index 96% rename from packages/wrangler/src/email-routing/plan.ts rename to packages/deploy-helpers/src/triggers/email-routing-plan.ts index 91265e886af..63ac582ee51 100644 --- a/packages/wrangler/src/email-routing/plan.ts +++ b/packages/deploy-helpers/src/triggers/email-routing-plan.ts @@ -1,10 +1,19 @@ -import type { EmailRoutingAction, EmailRoutingMatcher } from "./index"; - /** * Pure plan logic for the `addresses` config field: build the plan request and - * render the plan response. No network access — see `apply.ts`. + * 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; diff --git a/packages/wrangler/src/email-routing/apply.ts b/packages/deploy-helpers/src/triggers/email-routing.ts similarity index 70% rename from packages/wrangler/src/email-routing/apply.ts rename to packages/deploy-helpers/src/triggers/email-routing.ts index 3220842dbe4..56bb4a831e4 100644 --- a/packages/wrangler/src/email-routing/apply.ts +++ b/packages/deploy-helpers/src/triggers/email-routing.ts @@ -1,16 +1,12 @@ -import assert from "node:assert"; import { spinner } from "@cloudflare/cli-shared-helpers/interactive"; import { APIError, UserError } from "@cloudflare/workers-utils"; -import { fetchResult } from "../cfetch"; -import { confirm } from "../dialogs"; -import isInteractive, { isNonInteractiveOrCI } from "../is-interactive"; -import { logger } from "../logger"; +import { isWorkerNotFoundError } from "../deploy/helpers/worker-not-found-error"; import { - createEmailRoutingRule, - deleteEmailRoutingRule, - updateEmailRoutingCatchAll, - updateEmailRoutingRule, -} from "./client"; + confirm, + fetchResult, + isNonInteractiveOrCI, + logger, +} from "../shared/context"; import { buildEmailRoutingPlanRequest, isCatchAllAddress, @@ -18,18 +14,17 @@ import { planHasDestructiveChanges, renderEmailRoutingPlan, ruleShapeForTarget, -} from "./plan"; +} from "./email-routing-plan"; import type { EmailRoutingPlanChange, EmailRoutingPlanRequest, EmailRoutingPlanResponse, -} from "./plan"; +} from "./email-routing-plan"; import type { Config } from "@cloudflare/workers-utils"; /** * Network side of the `addresses` config field: plan the change set via the - * account-level endpoint, then apply it through the rule client. Pure logic - * lives in `plan.ts`. + * account-level endpoint, then apply it through the per-zone rule endpoints. */ const PLAN_RETRY_WORKER_NOT_FOUND_CODE = 2016; @@ -47,7 +42,7 @@ function startApplyProgress(total: number): { update(done: number): void; stop(): void; } { - if (isInteractive()) { + if (!isNonInteractiveOrCI()) { const progress = spinner(); progress.start(applyProgressMessage(0, total)); @@ -106,10 +101,26 @@ async function resolveOwnerWorkerTag( accountId: string, scriptName: string ): Promise { - const { default_environment } = await fetchResult<{ - default_environment: { script: { tag: string } }; - }>(config, `/accounts/${accountId}/workers/services/${scriptName}`); - return default_environment.script.tag; + const deadline = Date.now() + PLAN_RETRY_TIMEOUT_MS; + for (;;) { + try { + const { default_environment } = await fetchResult<{ + default_environment: { script: { tag: string } }; + }>(config, `/accounts/${accountId}/workers/services/${scriptName}`); + return default_environment.script.tag; + } catch (error) { + if (!isWorkerNotFoundError(error)) { + throw error; + } + if (Date.now() + PLAN_RETRY_DELAY_MS >= deadline) { + throw new UserError( + "Email Routing could not find the deployed Worker yet. Re-run the deployment.", + { telemetryMessage: "email routing worker metadata not found" } + ); + } + await new Promise((resolve) => setTimeout(resolve, PLAN_RETRY_DELAY_MS)); + } + } } /** @@ -137,7 +148,7 @@ async function fetchEmailRoutingPlan( } if (Date.now() + PLAN_RETRY_DELAY_MS >= deadline) { throw new UserError( - "Email Routing could not find the deployed Worker yet (it may still be propagating). Re-run `wrangler deploy`.", + "Email Routing could not find the deployed Worker yet. Re-run the deployment.", { telemetryMessage: "email routing plan worker not found" } ); } @@ -146,7 +157,19 @@ async function fetchEmailRoutingPlan( } } -/** Apply a single plan change through the canonical per-zone rule client. */ +function putJson( + config: Config, + path: string, + body: unknown +): Promise { + return fetchResult(config, path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** Apply a single plan change through the per-zone rule endpoints. */ async function applyChange( config: Config, zoneId: string, @@ -156,9 +179,9 @@ async function applyChange( ): Promise { if (isCatchAllAddress(change.target)) { // Catch-all has no DELETE endpoint: a delete is a reset to the default. - await updateEmailRoutingCatchAll( + await putJson( config, - zoneId, + `/zones/${zoneId}/email/routing/rules/catch_all`, change.type === "deleted" ? RESET_CATCH_ALL_BODY : desiredRuleBody(change.target, workerName, ownerWorkerTag) @@ -168,27 +191,26 @@ async function applyChange( switch (change.type) { case "added": - await createEmailRoutingRule( + await fetchResult( config, - zoneId, - desiredRuleBody(change.target, workerName, ownerWorkerTag) + `/zones/${zoneId}/email/routing/rules`, + postJson(desiredRuleBody(change.target, workerName, ownerWorkerTag)) ); return; // `conflict` only reaches here once the user accepted the takeover; it is - // applied the same way as an update — overwrite the existing rule. + // applied the same way as an update: overwrite the existing rule. case "updated": case "conflict": { const id = change.remote?.id; if (!id) { throw new UserError( - `Email Routing plan was missing the rule id for "${change.target}"; re-run \`wrangler deploy\`.`, + `Email Routing plan was missing the rule id for "${change.target}"; re-run the deployment.`, { telemetryMessage: "email routing plan missing rule id" } ); } - await updateEmailRoutingRule( + await putJson( config, - zoneId, - id, + `/zones/${zoneId}/email/routing/rules/${id}`, desiredRuleBody(change.target, workerName, ownerWorkerTag) ); return; @@ -197,21 +219,22 @@ async function applyChange( const id = change.remote?.id; if (!id) { throw new UserError( - `Email Routing plan was missing the rule id for "${change.target}"; re-run \`wrangler deploy\`.`, + `Email Routing plan was missing the rule id for "${change.target}"; re-run the deployment.`, { telemetryMessage: "email routing plan missing rule id" } ); } - await deleteEmailRoutingRule(config, zoneId, id); + await fetchResult(config, `/zones/${zoneId}/email/routing/rules/${id}`, { + method: "DELETE", + }); return; } } } /** - * Reconcile the Worker's Email Routing rules with the `addresses` config during - * `wrangler deploy`. No-op when `addresses` is absent. Runs after upload, so a - * failure leaves the Worker deployed but reports partial success. Prompts once - * for destructive changes and hard-fails non-interactively. + * Reconcile the Worker's Email Routing rules with the `addresses` config after + * its triggers deploy. No-op when `addresses` is absent. Prompts once for + * destructive changes and hard-fails non-interactively. */ export async function applyEmailRoutingAddresses({ config, @@ -220,18 +243,15 @@ export async function applyEmailRoutingAddresses({ workerTag, }: { config: Config; - accountId: string | undefined; - scriptName: string | undefined; - workerTag: string | null; + accountId: string; + scriptName: string; + workerTag?: string | null; }): Promise { const { addresses } = config; if (addresses === undefined) { return; } - assert(accountId, "Missing accountId"); - assert(scriptName, "Missing Worker name"); - const ownerWorkerTag = workerTag ?? (await resolveOwnerWorkerTag(config, accountId, scriptName)); const request = buildEmailRoutingPlanRequest( @@ -255,8 +275,8 @@ export async function applyEmailRoutingAddresses({ if (planHasDestructiveChanges(plan)) { if (isNonInteractiveOrCI()) { throw new UserError( - "Worker deployed, but Email Routing has destructive changes (deletes or takeover conflicts) that need confirmation. " + - "Re-run `wrangler deploy` interactively, or remove the conflicting entries from `addresses`.", + "The Worker is deployed, but Email Routing has destructive changes (deletes or takeover conflicts) that need confirmation. " + + "Re-run the deployment interactively, or remove the conflicting entries from `addresses`.", { telemetryMessage: "email routing destructive changes need confirmation", @@ -269,7 +289,7 @@ export async function applyEmailRoutingAddresses({ ); if (!accepted) { throw new UserError( - "Worker deployed, but the Email Routing changes were declined; no rules were modified.", + "The Worker is deployed, but the Email Routing changes were declined; no rules were modified.", { telemetryMessage: "email routing changes declined" } ); } @@ -310,10 +330,10 @@ export async function applyEmailRoutingAddresses({ if (failures.length > 0) { for (const failure of failures) { - logger.error(`Email Routing change failed — ${failure}`); + logger.error(`Email Routing change failed: ${failure}`); } throw new UserError( - `Worker deployed, but Email Routing was not fully applied (${failures.length} change(s) failed). Re-run \`wrangler deploy\` to retry.`, + `The Worker is deployed, but Email Routing was not fully applied (${failures.length} change(s) failed). Re-run the deployment to retry.`, { telemetryMessage: "email routing apply partial failure" } ); } diff --git a/packages/deploy-helpers/tests/email-routing-apply.test.ts b/packages/deploy-helpers/tests/email-routing-apply.test.ts new file mode 100644 index 00000000000..4025a08a686 --- /dev/null +++ b/packages/deploy-helpers/tests/email-routing-apply.test.ts @@ -0,0 +1,386 @@ +import { APIError } from "@cloudflare/workers-utils"; +import { beforeEach, describe, it, vi } from "vitest"; +import { initDeployHelpersContext } from "../src/shared/context"; +import { applyEmailRoutingAddresses } from "../src/triggers/email-routing"; +import type { EmailRoutingPlanResponse } from "../src/triggers/email-routing-plan"; +import type { Config } from "@cloudflare/workers-utils"; + +const ACCOUNT_ID = "some-account-id"; +const WORKER_TAG = "a7e6fb77503c41d8a7f3113c6918f10c"; +const WORKER_NAME = "test-name"; +const PLAN_RETRY_DELAY_MS = 3_000; + +interface RuleWrites { + posts: unknown[]; + puts: { id: string; body: unknown }[]; + deletes: string[]; + catchAlls: unknown[]; +} + +function testConfig(addresses: string[]): Config { + return { addresses } as unknown as Config; +} + +describe("applyEmailRoutingAddresses", () => { + let plan: EmailRoutingPlanResponse; + let writes: RuleWrites; + let logs: string[]; + let errors: string[]; + let confirmResult: boolean; + let confirmRequests: number; + let nonInteractive: boolean; + let failTarget: string | undefined; + let metadataRequests: number; + let metadataFailures: number[]; + let planFailures: APIError[]; + let planBody: unknown; + + beforeEach(() => { + plan = { zones: [] }; + writes = { posts: [], puts: [], deletes: [], catchAlls: [] }; + logs = []; + errors = []; + confirmResult = true; + confirmRequests = 0; + nonInteractive = true; + failTarget = undefined; + metadataRequests = 0; + metadataFailures = []; + planFailures = []; + planBody = undefined; + + initDeployHelpersContext({ + logger: { + debug() {}, + info() {}, + warn() {}, + log: (...args) => logs.push(args.join(" ")), + error: (...args) => errors.push(args.join(" ")), + }, + fetchResult: fetchResult as never, + fetchListResult: (() => {}) as never, + fetchPagedListResult: (() => {}) as never, + fetchKVGetValue: (() => {}) as never, + confirm: async () => { + confirmRequests++; + return confirmResult; + }, + prompt: (() => {}) as never, + select: (() => {}) as never, + isNonInteractiveOrCI: () => nonInteractive, + }); + }); + + async function fetchResult( + _config: Config, + path: string, + init?: RequestInit + ): Promise { + const body = + typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + + if (path.endsWith(`/workers/services/${WORKER_NAME}`)) { + metadataRequests++; + const code = metadataFailures.shift(); + if (code !== undefined) { + throw { code }; + } + return { default_environment: { script: { tag: WORKER_TAG } } }; + } + if (path.endsWith("/email/routing/rules/plan")) { + const error = planFailures.shift(); + if (error) { + throw error; + } + planBody = body; + return plan; + } + if (path.endsWith("/email/routing/rules/catch_all")) { + writes.catchAlls.push(body); + return {}; + } + if (init?.method === "POST" && path.endsWith("/email/routing/rules")) { + const target = (body as { matchers: { value?: string }[] }).matchers[0] + ?.value; + if (target === failTarget) { + throw new Error("duplicate rule"); + } + writes.posts.push(body); + return {}; + } + const ruleId = path.split("/").at(-1); + if (init?.method === "PUT" && ruleId) { + writes.puts.push({ id: ruleId, body }); + return {}; + } + if (init?.method === "DELETE" && ruleId) { + writes.deletes.push(ruleId); + return {}; + } + throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${path}`); + } + + function apply(addresses: string[], workerTag: string | null = WORKER_TAG) { + return applyEmailRoutingAddresses({ + config: testConfig(addresses), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + workerTag, + }); + } + + it("skips reconciliation when addresses are absent", async ({ expect }) => { + await applyEmailRoutingAddresses({ + config: {} as Config, + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + }); + + expect(planBody).toBeUndefined(); + }); + + it("applies an additive plan with ownership and progress", async ({ + expect, + }) => { + plan = { + zones: [ + { + zone_id: "zone1", + zone_name: "example.com", + changes: [{ type: "added", target: "support@example.com" }], + }, + ], + }; + + await apply(["support@example.com"]); + + expect(planBody).toMatchObject({ owner_worker_tag: WORKER_TAG }); + expect(writes.posts).toHaveLength(1); + expect(writes.posts[0]).toMatchObject({ + source: "wrangler", + owner_worker_tag: WORKER_TAG, + matchers: [ + { type: "literal", field: "to", value: "support@example.com" }, + ], + actions: [{ type: "worker", value: [WORKER_NAME] }], + }); + expect(logs.join("\n")).toContain( + "Applying Email Routing changes (1/1, 100%)" + ); + expect(logs.join("\n")).toContain("Email Routing addresses applied."); + expect(confirmRequests).toBe(0); + }); + + it("resolves the Worker tag when standalone trigger deployment omits it", async ({ + expect, + }) => { + await apply(["support@example.com"], null); + + expect(metadataRequests).toBe(1); + expect(planBody).toMatchObject({ owner_worker_tag: WORKER_TAG }); + expect(logs.join("\n")).toContain("Email Routing rules are up to date."); + }); + + it("retries Worker metadata while a new Worker propagates", async ({ + expect, + }) => { + vi.useFakeTimers(); + metadataFailures = [10007]; + + try { + const applying = apply(["support@example.com"], null); + await vi.advanceTimersByTimeAsync(PLAN_RETRY_DELAY_MS); + await applying; + expect(metadataRequests).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("retries the plan while the Worker propagates to Email Routing", async ({ + expect, + }) => { + vi.useFakeTimers(); + const error = new APIError({ + status: 404, + text: "worker not found", + telemetryMessage: false, + }); + error.code = 2016; + planFailures = [error]; + + try { + const applying = apply(["support@example.com"]); + await vi.advanceTimersByTimeAsync(PLAN_RETRY_DELAY_MS); + await applying; + expect(planBody).toMatchObject({ owner_worker_tag: WORKER_TAG }); + } finally { + vi.useRealTimers(); + } + }); + + it("applies a destructive takeover after confirmation", async ({ + expect, + }) => { + nonInteractive = false; + plan = { + zones: [ + { + zone_id: "zone1", + changes: [ + { + type: "conflict", + target: "support@example.com", + remote: { + id: "existing-rule-id", + matchers: [], + actions: [{ type: "forward", value: ["a@b.com"] }], + }, + }, + ], + }, + { + zone_id: "zone2", + changes: [ + { + type: "deleted", + target: "old@example.net", + remote: { id: "old-rule-id", matchers: [], actions: [] }, + }, + ], + }, + ], + }; + + await apply(["support@example.com"]); + + expect(writes.puts).toEqual([ + expect.objectContaining({ + id: "existing-rule-id", + body: expect.objectContaining({ owner_worker_tag: WORKER_TAG }), + }), + ]); + expect(writes.deletes).toEqual(["old-rule-id"]); + expect(confirmRequests).toBe(1); + }); + + it("does not write rules when destructive changes are declined", async ({ + expect, + }) => { + nonInteractive = false; + confirmResult = false; + plan = { + zones: [ + { + zone_id: "zone1", + changes: [ + { + type: "deleted", + target: "old@example.com", + remote: { id: "old-rule-id", matchers: [], actions: [] }, + }, + ], + }, + ], + }; + + await expect(apply([])).rejects.toThrow(/changes were declined/); + expect(confirmRequests).toBe(1); + expect(writes).toEqual({ + posts: [], + puts: [], + deletes: [], + catchAlls: [], + }); + }); + + it("rejects destructive changes non-interactively", async ({ expect }) => { + plan = { + zones: [ + { + zone_id: "zone1", + changes: [{ type: "deleted", target: "old@example.com" }], + }, + ], + }; + + await expect(apply([])).rejects.toThrow( + /destructive changes .* need confirmation/ + ); + expect(writes.deletes).toEqual([]); + expect(confirmRequests).toBe(0); + }); + + it("resets a deleted catch-all to the disabled default", async ({ + expect, + }) => { + nonInteractive = false; + plan = { + zones: [ + { + zone_id: "zone1", + changes: [{ type: "deleted", target: "*@example.com" }], + }, + ], + }; + + await apply([]); + + expect(writes.catchAlls).toEqual([ + expect.objectContaining({ + source: "api", + enabled: false, + matchers: [{ type: "all" }], + actions: [{ type: "drop" }], + }), + ]); + expect(writes.deletes).toEqual([]); + }); + + it("continues applying changes and reports partial failure", async ({ + expect, + }) => { + failTarget = "bad@example.com"; + plan = { + zones: [ + { + zone_id: "zone1", + changes: [ + { type: "added", target: "bad@example.com" }, + { type: "added", target: "ok@example.com" }, + ], + }, + ], + }; + + await expect(apply(["bad@example.com", "ok@example.com"])).rejects.toThrow( + /Email Routing was not fully applied/ + ); + expect(writes.posts).toHaveLength(1); + expect(errors.join("\n")).toContain("bad@example.com: duplicate rule"); + }); + + it("reports a missing remote rule id as an apply failure", async ({ + expect, + }) => { + plan = { + zones: [ + { + zone_id: "zone1", + changes: [ + { + type: "updated", + target: "support@example.com", + remote: { matchers: [], actions: [] }, + }, + ], + }, + ], + }; + + await expect(apply(["support@example.com"])).rejects.toThrow( + /Email Routing was not fully applied/ + ); + expect(errors.join("\n")).toContain("missing the rule id"); + }); +}); diff --git a/packages/wrangler/src/__tests__/email-routing/plan.test.ts b/packages/deploy-helpers/tests/email-routing-plan.test.ts similarity index 97% rename from packages/wrangler/src/__tests__/email-routing/plan.test.ts rename to packages/deploy-helpers/tests/email-routing-plan.test.ts index ba8339f1407..c44395c086f 100644 --- a/packages/wrangler/src/__tests__/email-routing/plan.test.ts +++ b/packages/deploy-helpers/tests/email-routing-plan.test.ts @@ -5,8 +5,8 @@ import { planHasChanges, planHasDestructiveChanges, renderEmailRoutingPlan, -} from "../../email-routing/plan"; -import type { EmailRoutingPlanResponse } from "../../email-routing/plan"; +} from "../src/triggers/email-routing-plan"; +import type { EmailRoutingPlanResponse } from "../src/triggers/email-routing-plan"; describe("buildEmailRoutingPlanRequest", () => { it("compiles literal addresses into normal rules targeting the worker", ({ diff --git a/packages/deploy-helpers/tests/triggers-email-routing.test.ts b/packages/deploy-helpers/tests/triggers-email-routing.test.ts new file mode 100644 index 00000000000..c9533c82bcf --- /dev/null +++ b/packages/deploy-helpers/tests/triggers-email-routing.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, it } from "vitest"; +import { initDeployHelpersContext } from "../src/shared/context"; +import { triggersDeploy } from "../src/triggers/deploy"; +import type { Config } from "@cloudflare/workers-utils"; + +const ACCOUNT_ID = "some-account-id"; +const WORKER_NAME = "test-name"; +const WORKER_TAG = "a7e6fb77503c41d8a7f3113c6918f10c"; + +describe("triggersDeploy Email Routing integration", () => { + let metadataRequests: number; + let planRequests: number; + + beforeEach(() => { + metadataRequests = 0; + planRequests = 0; + + initDeployHelpersContext({ + logger: { + debug() {}, + info() {}, + warn() {}, + log() {}, + error() {}, + }, + fetchResult: (async ( + _config: Config, + path: string, + init?: RequestInit + ) => { + if (path.endsWith("/subdomain")) { + return { enabled: false, previews_enabled: false }; + } + if (path.endsWith(`/workers/services/${WORKER_NAME}`)) { + metadataRequests++; + return { default_environment: { script: { tag: WORKER_TAG } } }; + } + if ( + init?.method === "POST" && + path.endsWith("/email/routing/rules/plan") + ) { + planRequests++; + return { zones: [] }; + } + throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${path}`); + }) as never, + fetchListResult: (() => {}) as never, + fetchPagedListResult: (() => {}) as never, + fetchKVGetValue: (() => {}) as never, + confirm: (() => {}) as never, + prompt: (() => {}) as never, + select: (() => {}) as never, + isNonInteractiveOrCI: () => true, + }); + }); + + function config(): Config { + return { + addresses: ["support@example.com"], + workers_dev: false, + preview_urls: false, + queues: { producers: [], consumers: [] }, + workflows: [], + } as unknown as Config; + } + + it("reconciles once with the tag supplied by normal deploy", async ({ + expect, + }) => { + await triggersDeploy({ + config: config(), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + workerTag: WORKER_TAG, + env: undefined, + crons: undefined, + routes: [], + useServiceEnvironments: false, + firstDeploy: false, + }); + + expect(planRequests).toBe(1); + expect(metadataRequests).toBe(0); + }); + + it("reconciles once and resolves the tag for standalone trigger deploy", async ({ + expect, + }) => { + await triggersDeploy({ + config: config(), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + env: undefined, + crons: undefined, + routes: [], + useServiceEnvironments: false, + firstDeploy: false, + }); + + expect(planRequests).toBe(1); + expect(metadataRequests).toBe(1); + }); +}); diff --git a/packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts b/packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts deleted file mode 100644 index 1758e1eb019..00000000000 --- a/packages/wrangler/src/__tests__/deploy/email-routing-apply.test.ts +++ /dev/null @@ -1,527 +0,0 @@ -import { http, HttpResponse } from "msw"; -import { afterEach, beforeEach, describe, it } from "vitest"; -import { applyEmailRoutingAddresses } from "../../email-routing/apply"; -import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; -import { mockConsoleMethods } from "../helpers/mock-console"; -import { clearDialogs, mockConfirm } from "../helpers/mock-dialogs"; -import { useMockIsTTY } from "../helpers/mock-istty"; -import { createFetchResult, msw } from "../helpers/msw"; -import type { EmailRoutingPlanResponse } from "../../email-routing/plan"; -import type { Config } from "@cloudflare/workers-utils"; - -/** - * Unit tests for the apply orchestrator, driven directly (not through - * `wrangler deploy`) so they stay decoupled from the deploy pipeline. The plan - * endpoint and the per-zone rule endpoints are mocked with MSW; `config` only - * needs `addresses` plus the (defaulted) compliance fields `fetchResult` reads. - */ - -const ACCOUNT_ID = "some-account-id"; -const WORKER_TAG = "a7e6fb77503c41d8a7f3113c6918f10c"; -const WORKER_NAME = "test-name"; - -function testConfig(addresses: string[]): Config { - return { addresses } as unknown as Config; -} - -interface RuleWrites { - posts: unknown[]; - puts: { id: string; body: unknown }[]; - deletes: string[]; - catchAlls: unknown[]; -} - -describe("applyEmailRoutingAddresses", () => { - mockAccountId(); - mockApiToken(); - const { setIsTTY } = useMockIsTTY(); - const std = mockConsoleMethods(); - - beforeEach(() => { - setIsTTY(true); - }); - - afterEach(() => { - clearDialogs(); - }); - - function mockPlan( - plan: EmailRoutingPlanResponse, - captured?: { body?: unknown } - ) { - msw.use( - http.post( - "*/accounts/:accountId/email/routing/rules/plan", - async ({ request }) => { - if (captured) { - captured.body = await request.json(); - } - return HttpResponse.json(createFetchResult(plan)); - } - ) - ); - } - - function mockRuleWrites(writes: RuleWrites, failTarget?: string) { - msw.use( - http.post("*/zones/:zoneId/email/routing/rules", async ({ request }) => { - const body = (await request.json()) as { - matchers: { value?: string }[]; - }; - writes.posts.push(body); - if (failTarget && body.matchers[0]?.value === failTarget) { - return HttpResponse.json( - createFetchResult(null, false, [ - { code: 2014, message: "duplicate rule" }, - ]), - { status: 409 } - ); - } - return HttpResponse.json(createFetchResult({ tag: "new-rule-id" })); - }), - http.put( - "*/zones/:zoneId/email/routing/rules/catch_all", - async ({ request }) => { - writes.catchAlls.push(await request.json()); - return HttpResponse.json(createFetchResult({})); - } - ), - http.put( - "*/zones/:zoneId/email/routing/rules/:ruleId", - async ({ request, params }) => { - writes.puts.push({ - id: String(params.ruleId), - body: await request.json(), - }); - return HttpResponse.json(createFetchResult({})); - } - ), - http.delete( - "*/zones/:zoneId/email/routing/rules/:ruleId", - async ({ params }) => { - writes.deletes.push(String(params.ruleId)); - return HttpResponse.json(createFetchResult({})); - } - ) - ); - } - - function emptyWrites(): RuleWrites { - return { posts: [], puts: [], deletes: [], catchAlls: [] }; - } - - function apply(addresses: string[], workerTag: string | null = WORKER_TAG) { - return applyEmailRoutingAddresses({ - config: testConfig(addresses), - accountId: ACCOUNT_ID, - scriptName: WORKER_NAME, - workerTag, - }); - } - - it("applies an additive plan without prompting", async ({ expect }) => { - const planBody: { body?: unknown } = {}; - mockPlan( - { - zones: [ - { - zone_id: "zone1", - zone_name: "example.com", - changes: [{ type: "added", target: "support@example.com" }], - }, - ], - }, - planBody - ); - const writes = emptyWrites(); - mockRuleWrites(writes); - - await apply(["support@example.com"]); - - // Plan request carried the deploying Worker's tag. - expect(planBody.body).toMatchObject({ owner_worker_tag: WORKER_TAG }); - expect(std.out).toContain("Email Routing plan:"); - expect(std.out).toContain("+ support@example.com -> worker (test-name)"); - expect(std.out).toContain("Email Routing addresses applied."); - expect(writes.posts).toHaveLength(1); - expect(writes.posts[0]).toMatchObject({ - source: "wrangler", - owner_worker_tag: WORKER_TAG, - matchers: [ - { type: "literal", field: "to", value: "support@example.com" }, - ], - actions: [{ type: "worker", value: [WORKER_NAME] }], - }); - expect(std.err).toBe(""); - }); - - it("reports apply progress in non-interactive output", async ({ expect }) => { - setIsTTY(false); - mockPlan({ - zones: [ - { - zone_id: "zone1", - zone_name: "example.com", - changes: [ - { type: "added", target: "one@example.com" }, - { type: "added", target: "two@example.com" }, - ], - }, - ], - }); - mockRuleWrites(emptyWrites()); - - await apply(["one@example.com", "two@example.com"]); - - expect(std.out).toContain("Applying Email Routing changes (0/2, 0%)"); - expect(std.out).toContain("Applying Email Routing changes (2/2, 100%)"); - }); - - it("resolves the Worker tag from the API when not provided", async ({ - expect, - }) => { - msw.use( - http.get("*/accounts/:accountId/workers/services/:scriptName", () => - HttpResponse.json( - createFetchResult({ - default_environment: { script: { tag: WORKER_TAG } }, - }) - ) - ) - ); - const planBody: { body?: unknown } = {}; - mockPlan({ zones: [] }, planBody); - - await apply(["support@example.com"], null); - - expect(planBody.body).toMatchObject({ owner_worker_tag: WORKER_TAG }); - expect(std.out).toContain("Email Routing rules are up to date."); - }); - - it("reports when addresses are already up to date", async ({ expect }) => { - mockPlan({ zones: [] }); - - await apply(["support@example.com"]); - - expect(std.out).toContain("Email Routing rules are up to date."); - }); - - it("applies a destructive plan after interactive confirmation", async ({ - expect, - }) => { - mockConfirm({ - text: "Apply these Email Routing changes (including the destructive ones above)?", - result: true, - }); - mockPlan({ - zones: [ - { - zone_id: "zone1", - zone_name: "example.com", - changes: [ - { - type: "conflict", - target: "support@example.com", - remote: { - id: "existing-rule-id", - source: "api", - matchers: [ - { - type: "literal", - field: "to", - value: "support@example.com", - }, - ], - actions: [{ type: "forward", value: ["a@b.com"] }], - }, - }, - ], - }, - ], - }); - const writes = emptyWrites(); - mockRuleWrites(writes); - - await apply(["support@example.com"]); - - // Takeover applied via PUT to the existing rule id, with ownership. - expect(writes.puts).toHaveLength(1); - expect(writes.puts[0].id).toBe("existing-rule-id"); - expect(writes.puts[0].body).toMatchObject({ - source: "wrangler", - owner_worker_tag: WORKER_TAG, - }); - expect(std.out).toContain("Email Routing addresses applied."); - }); - - it("aborts (non-zero) when a destructive plan is declined", async ({ - expect, - }) => { - mockConfirm({ - text: "Apply these Email Routing changes (including the destructive ones above)?", - result: false, - }); - mockPlan({ - zones: [ - { - zone_id: "zone1", - changes: [{ type: "deleted", target: "old@example.com" }], - }, - ], - }); - - await expect(apply(["support@example.com"])).rejects.toThrowError( - /Email Routing changes were declined/ - ); - }); - - it("hard-fails on destructive changes in non-interactive mode", async ({ - expect, - }) => { - setIsTTY(false); - mockPlan({ - zones: [ - { - zone_id: "zone1", - changes: [{ type: "deleted", target: "old@example.com" }], - }, - ], - }); - - await expect(apply(["support@example.com"])).rejects.toThrowError( - /destructive changes .* need confirmation/ - ); - }); - - it("reports partial success (non-zero) when a change fails to apply", async ({ - expect, - }) => { - mockPlan({ - zones: [ - { - zone_id: "zone1", - zone_name: "example.com", - changes: [ - { type: "added", target: "ok@example.com" }, - { type: "added", target: "bad@example.com" }, - ], - }, - ], - }); - const writes = emptyWrites(); - mockRuleWrites(writes, "bad@example.com"); - - await expect( - apply(["ok@example.com", "bad@example.com"]) - ).rejects.toThrowError( - /Email Routing was not fully applied \(1 change\(s\) failed\)/ - ); - }); - - it("applies an 'updated' change via PUT to the existing rule (no prompt)", async ({ - expect, - }) => { - mockPlan({ - zones: [ - { - zone_id: "zone1", - zone_name: "example.com", - changes: [ - { - type: "updated", - target: "support@example.com", - remote: { - id: "existing-id", - source: "wrangler", - matchers: [ - { - type: "literal", - field: "to", - value: "support@example.com", - }, - ], - actions: [{ type: "worker", value: [WORKER_NAME] }], - }, - }, - ], - }, - ], - }); - const writes = emptyWrites(); - mockRuleWrites(writes); - - await apply(["support@example.com"]); - - expect(writes.posts).toHaveLength(0); - expect(writes.puts).toHaveLength(1); - expect(writes.puts[0].id).toBe("existing-id"); - expect(writes.puts[0].body).toMatchObject({ - source: "wrangler", - owner_worker_tag: WORKER_TAG, - matchers: [ - { type: "literal", field: "to", value: "support@example.com" }, - ], - actions: [{ type: "worker", value: [WORKER_NAME] }], - }); - expect(std.out).toContain("Email Routing addresses applied."); - }); - - it("writes the catch-all via PUT with ownership when added", async ({ - expect, - }) => { - mockPlan({ - zones: [ - { - zone_id: "zone1", - zone_name: "example.com", - changes: [{ type: "added", target: "*@example.com" }], - }, - ], - }); - const writes = emptyWrites(); - mockRuleWrites(writes); - - await apply(["*@example.com"]); - - expect(writes.posts).toHaveLength(0); - expect(writes.catchAlls).toHaveLength(1); - expect(writes.catchAlls[0]).toMatchObject({ - source: "wrangler", - owner_worker_tag: WORKER_TAG, - enabled: true, - matchers: [{ type: "all" }], - actions: [{ type: "worker", value: [WORKER_NAME] }], - }); - }); - - it("resets the catch-all to the disabled-drop default on delete", async ({ - expect, - }) => { - mockConfirm({ - text: "Apply these Email Routing changes (including the destructive ones above)?", - result: true, - }); - mockPlan({ - zones: [ - { - zone_id: "zone1", - zone_name: "example.com", - changes: [{ type: "deleted", target: "*@example.com" }], - }, - ], - }); - const writes = emptyWrites(); - mockRuleWrites(writes); - - await apply(["support@example.com"]); - - // Catch-all has no DELETE endpoint: a delete resets to the generated - // default and clears ownership (source=api), never a DELETE call. - expect(writes.deletes).toHaveLength(0); - expect(writes.catchAlls).toHaveLength(1); - expect(writes.catchAlls[0]).toMatchObject({ - source: "api", - enabled: false, - name: "", - matchers: [{ type: "all" }], - actions: [{ type: "drop" }], - }); - }); - - it("reports failure when the plan omits the rule id for an update", async ({ - expect, - }) => { - mockPlan({ - zones: [ - { - zone_id: "zone1", - zone_name: "example.com", - changes: [ - { - type: "updated", - target: "support@example.com", - remote: { - matchers: [ - { - type: "literal", - field: "to", - value: "support@example.com", - }, - ], - actions: [{ type: "worker", value: [WORKER_NAME] }], - }, - }, - ], - }, - ], - }); - const writes = emptyWrites(); - mockRuleWrites(writes); - - await expect(apply(["support@example.com"])).rejects.toThrowError( - /Email Routing was not fully applied/ - ); - expect(std.err).toContain("missing the rule id"); - expect(writes.puts).toHaveLength(0); - }); - - it("applies a mixed multi-zone plan: add + catch-all + delete + update", async ({ - expect, - }) => { - mockConfirm({ - text: "Apply these Email Routing changes (including the destructive ones above)?", - result: true, - }); - mockPlan({ - zones: [ - { - zone_id: "zone1", - zone_name: "example.com", - changes: [ - { type: "added", target: "new@example.com" }, - { type: "added", target: "*@example.com" }, - { - type: "deleted", - target: "old@example.com", - remote: { - id: "old-1", - matchers: [ - { type: "literal", field: "to", value: "old@example.com" }, - ], - actions: [{ type: "worker", value: [WORKER_NAME] }], - }, - }, - ], - }, - { - zone_id: "zone2", - zone_name: "example.net", - changes: [ - { - type: "updated", - target: "keep@example.net", - remote: { - id: "keep-1", - source: "wrangler", - matchers: [ - { type: "literal", field: "to", value: "keep@example.net" }, - ], - actions: [{ type: "worker", value: [WORKER_NAME] }], - }, - }, - ], - }, - ], - }); - const writes = emptyWrites(); - mockRuleWrites(writes); - - await apply(["new@example.com", "*@example.com", "keep@example.net"]); - - expect(writes.posts).toHaveLength(1); // new@ literal create - expect(writes.catchAlls).toHaveLength(1); // *@ catch-all PUT - expect(writes.deletes).toEqual(["old-1"]); // literal delete by id - expect(writes.puts.map((p) => p.id)).toEqual(["keep-1"]); // update by id - expect(std.out).toContain("Email Routing addresses applied."); - }); -}); diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index cb90790dced..5db3222d215 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -14,7 +14,6 @@ import { cleanupDestination, mergeDeployConfigArgs, } from "../deployment-bundle/merge-config-args"; -import { applyEmailRoutingAddresses } from "../email-routing/apply"; import { experimentalNewConfigArg } from "../experimental-config/cli-flag"; import * as metrics from "../metrics"; import { writeOutput } from "../output"; @@ -185,15 +184,6 @@ export async function runDeployCommandHandler( analyseBundle, }); - if (!props.dryRun) { - await applyEmailRoutingAddresses({ - config, - accountId: props.accountId, - scriptName: props.name, - workerTag, - }); - } - writeOutput({ type: "deploy", version: 1, diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index a3d31ad19ae..50415d432d6 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -136,8 +136,6 @@ export async function createEmailRoutingRule( name?: string; enabled?: boolean; priority?: number; - source?: string; - owner_worker_tag?: string; } ): Promise { await requireAuth(config); @@ -162,8 +160,6 @@ export async function updateEmailRoutingRule( name?: string; enabled?: boolean; priority?: number; - source?: string; - owner_worker_tag?: string; } ): Promise { await requireAuth(config); @@ -212,8 +208,6 @@ export async function updateEmailRoutingCatchAll( matchers: { type: string }[]; enabled?: boolean; name?: string; - source?: string; - owner_worker_tag?: string; } ): Promise { await requireAuth(config); From 4df155ffd06aa7cce83bea57cb9fc5b937d6dfcd Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Wed, 15 Jul 2026 10:43:28 +0100 Subject: [PATCH 5/7] [wrangler] Adapt Email Routing deploy to current helpers --- packages/deploy-helpers/src/triggers/email-routing.ts | 11 +++++------ .../deploy-helpers/tests/email-routing-apply.test.ts | 11 ++++++++++- .../tests/triggers-email-routing.test.ts | 10 ++++++---- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/deploy-helpers/src/triggers/email-routing.ts b/packages/deploy-helpers/src/triggers/email-routing.ts index 56bb4a831e4..5d4d36f5abc 100644 --- a/packages/deploy-helpers/src/triggers/email-routing.ts +++ b/packages/deploy-helpers/src/triggers/email-routing.ts @@ -1,12 +1,11 @@ import { spinner } from "@cloudflare/cli-shared-helpers/interactive"; -import { APIError, UserError } from "@cloudflare/workers-utils"; -import { isWorkerNotFoundError } from "../deploy/helpers/worker-not-found-error"; import { - confirm, - fetchResult, + APIError, isNonInteractiveOrCI, - logger, -} from "../shared/context"; + UserError, +} from "@cloudflare/workers-utils"; +import { isWorkerNotFoundError } from "../deploy/helpers/worker-not-found-error"; +import { confirm, fetchResult, logger } from "../shared/context"; import { buildEmailRoutingPlanRequest, isCatchAllAddress, diff --git a/packages/deploy-helpers/tests/email-routing-apply.test.ts b/packages/deploy-helpers/tests/email-routing-apply.test.ts index 4025a08a686..39c6ecedcfa 100644 --- a/packages/deploy-helpers/tests/email-routing-apply.test.ts +++ b/packages/deploy-helpers/tests/email-routing-apply.test.ts @@ -5,6 +5,15 @@ import { applyEmailRoutingAddresses } from "../src/triggers/email-routing"; import type { EmailRoutingPlanResponse } from "../src/triggers/email-routing-plan"; import type { Config } from "@cloudflare/workers-utils"; +const { isNonInteractiveOrCI } = vi.hoisted(() => ({ + isNonInteractiveOrCI: vi.fn(() => true), +})); + +vi.mock("@cloudflare/workers-utils", async (importOriginal) => ({ + ...(await importOriginal()), + isNonInteractiveOrCI, +})); + const ACCOUNT_ID = "some-account-id"; const WORKER_TAG = "a7e6fb77503c41d8a7f3113c6918f10c"; const WORKER_NAME = "test-name"; @@ -43,6 +52,7 @@ describe("applyEmailRoutingAddresses", () => { confirmResult = true; confirmRequests = 0; nonInteractive = true; + isNonInteractiveOrCI.mockImplementation(() => nonInteractive); failTarget = undefined; metadataRequests = 0; metadataFailures = []; @@ -67,7 +77,6 @@ describe("applyEmailRoutingAddresses", () => { }, prompt: (() => {}) as never, select: (() => {}) as never, - isNonInteractiveOrCI: () => nonInteractive, }); }); diff --git a/packages/deploy-helpers/tests/triggers-email-routing.test.ts b/packages/deploy-helpers/tests/triggers-email-routing.test.ts index c9533c82bcf..e8696f54808 100644 --- a/packages/deploy-helpers/tests/triggers-email-routing.test.ts +++ b/packages/deploy-helpers/tests/triggers-email-routing.test.ts @@ -1,8 +1,13 @@ -import { beforeEach, describe, it } from "vitest"; +import { beforeEach, describe, it, vi } from "vitest"; import { initDeployHelpersContext } from "../src/shared/context"; import { triggersDeploy } from "../src/triggers/deploy"; import type { Config } from "@cloudflare/workers-utils"; +vi.mock("@cloudflare/workers-utils", async (importOriginal) => ({ + ...(await importOriginal()), + isNonInteractiveOrCI: () => true, +})); + const ACCOUNT_ID = "some-account-id"; const WORKER_NAME = "test-name"; const WORKER_TAG = "a7e6fb77503c41d8a7f3113c6918f10c"; @@ -50,7 +55,6 @@ describe("triggersDeploy Email Routing integration", () => { confirm: (() => {}) as never, prompt: (() => {}) as never, select: (() => {}) as never, - isNonInteractiveOrCI: () => true, }); }); @@ -75,7 +79,6 @@ describe("triggersDeploy Email Routing integration", () => { env: undefined, crons: undefined, routes: [], - useServiceEnvironments: false, firstDeploy: false, }); @@ -93,7 +96,6 @@ describe("triggersDeploy Email Routing integration", () => { env: undefined, crons: undefined, routes: [], - useServiceEnvironments: false, firstDeploy: false, }); From 5b8245aca8c8eb13cf558f5610fbb382e94d499a Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Wed, 15 Jul 2026 13:44:30 +0100 Subject: [PATCH 6/7] [wrangler] Reconcile Email Routing after trigger failures --- .../deploy-helpers/src/triggers/deploy.ts | 21 ++++++++++++------- .../tests/triggers-email-routing.test.ts | 20 ++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/deploy-helpers/src/triggers/deploy.ts b/packages/deploy-helpers/src/triggers/deploy.ts index 02181cd9e2f..7fe08fe34a1 100644 --- a/packages/deploy-helpers/src/triggers/deploy.ts +++ b/packages/deploy-helpers/src/triggers/deploy.ts @@ -342,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` + @@ -358,13 +372,6 @@ export async function triggersDeploy( ); } - await applyEmailRoutingAddresses({ - config, - accountId, - scriptName, - workerTag: props.workerTag, - }); - return targets; } diff --git a/packages/deploy-helpers/tests/triggers-email-routing.test.ts b/packages/deploy-helpers/tests/triggers-email-routing.test.ts index e8696f54808..25634d233b9 100644 --- a/packages/deploy-helpers/tests/triggers-email-routing.test.ts +++ b/packages/deploy-helpers/tests/triggers-email-routing.test.ts @@ -40,6 +40,9 @@ describe("triggersDeploy Email Routing integration", () => { metadataRequests++; return { default_environment: { script: { tag: WORKER_TAG } } }; } + if (path.endsWith("/schedules")) { + throw new Error("trigger deployment failed"); + } if ( init?.method === "POST" && path.endsWith("/email/routing/rules/plan") @@ -102,4 +105,21 @@ describe("triggersDeploy Email Routing integration", () => { expect(planRequests).toBe(1); expect(metadataRequests).toBe(1); }); + + it("reconciles when another trigger deployment fails", async ({ expect }) => { + await expect( + triggersDeploy({ + config: config(), + accountId: ACCOUNT_ID, + scriptName: WORKER_NAME, + workerTag: WORKER_TAG, + env: undefined, + crons: ["* * * * *"], + routes: [], + firstDeploy: false, + }) + ).rejects.toThrow("trigger deployment failed"); + + expect(planRequests).toBe(1); + }); }); From 7297d7ba52649345afc741f13013edd0e7ad5ad8 Mon Sep 17 00:00:00 2001 From: Diogo Santos Date: Thu, 16 Jul 2026 16:11:42 +0100 Subject: [PATCH 7/7] [wrangler] Clarify Email Routing conflict ownership --- packages/deploy-helpers/src/triggers/email-routing-plan.ts | 2 +- packages/deploy-helpers/tests/email-routing-plan.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/deploy-helpers/src/triggers/email-routing-plan.ts b/packages/deploy-helpers/src/triggers/email-routing-plan.ts index 63ac582ee51..1d8f8672415 100644 --- a/packages/deploy-helpers/src/triggers/email-routing-plan.ts +++ b/packages/deploy-helpers/src/triggers/email-routing-plan.ts @@ -160,7 +160,7 @@ function describeConflictOwner(remote: PlanRemoteRule): string { ? `owned by worker "${remote.owner_worker_name}"` : "owned by another Worker"; } - return "owned by source=api"; + return "managed outside Wrangler"; } /** diff --git a/packages/deploy-helpers/tests/email-routing-plan.test.ts b/packages/deploy-helpers/tests/email-routing-plan.test.ts index c44395c086f..b850ed3bb07 100644 --- a/packages/deploy-helpers/tests/email-routing-plan.test.ts +++ b/packages/deploy-helpers/tests/email-routing-plan.test.ts @@ -100,7 +100,7 @@ describe("renderEmailRoutingPlan", () => { expect(lines).toEqual([ "example.com", " + support@example.com -> worker (my-worker)", - ` ! billing@example.com -> conflict: owned by source=api (forward to billing-team@example.net)`, + ` ! billing@example.com -> conflict: managed outside Wrangler (forward to billing-team@example.net)`, "example.net", " - old@example.net (removed from config)", ` ! *@example.net -> conflict: owned by worker "other-worker" (worker other-worker)`,