diff --git a/.changeset/email-routing-deploy-apply.md b/.changeset/email-routing-deploy-apply.md new file mode 100644 index 00000000000..20ff82101bb --- /dev/null +++ b/.changeset/email-routing-deploy-apply.md @@ -0,0 +1,8 @@ +--- +"wrangler": minor +"@cloudflare/deploy-helpers": minor +--- + +Apply Email Routing `addresses` during Worker trigger deployment + +Worker trigger deployment now reconciles the Worker's Email Routing rules with the top-level `addresses` config. This runs for `wrangler deploy`, `wrangler triggers deploy`, and clients of `@cloudflare/deploy-helpers`. After the Worker uploads, or when `wrangler triggers deploy` runs after a version promotion, the deploy helper asks the Email Routing API for a plan, renders the changes grouped by zone (`+` added, `~` updated, `-` deleted, `!` conflict), prompts once for destructive changes in interactive mode, and applies accepted changes through the per-zone rule endpoints. Purely additive plans apply without a prompt, while non-interactive destructive plans fail without modifying rules. 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; } } 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..7fe08fe34a1 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, @@ -341,6 +342,20 @@ export async function triggersDeploy( .map((deployment) => deployment.error) .filter((error): error is Error => error !== undefined); + try { + await applyEmailRoutingAddresses({ + config, + accountId, + scriptName, + workerTag: props.workerTag, + }); + } catch (error) { + if (errors.length === 0) { + throw error; + } + errors.push(error instanceof Error ? error : new Error(String(error))); + } + if (errors.length > 0) { throw new UserError( `Some triggers failed to deploy for ${workerName}:\n` + diff --git a/packages/deploy-helpers/src/triggers/email-routing-plan.ts b/packages/deploy-helpers/src/triggers/email-routing-plan.ts new file mode 100644 index 00000000000..1d8f8672415 --- /dev/null +++ b/packages/deploy-helpers/src/triggers/email-routing-plan.ts @@ -0,0 +1,236 @@ +/** + * Pure plan logic for the `addresses` config field: build the plan request and + * render the plan response. No network access. + */ + +interface EmailRoutingAction { + type: string; + value?: string[]; +} + +interface EmailRoutingMatcher { + type: string; + field?: string; + value?: string; +} + +/** A `*@domain` catch-all target (e.g. `"*@example.com"`), not a literal recipient. */ +export function isCatchAllAddress(address: string): boolean { + return address.startsWith("*@") && address.length > "*@".length; +} + +export interface EmailRoutingPlanRule { + matchers: EmailRoutingMatcher[]; + actions: EmailRoutingAction[]; +} + +export interface EmailRoutingPlanCatchAll { + /** The `*@domain` target this catch-all rule applies to. */ + target: string; + rule: EmailRoutingPlanRule; +} + +export interface EmailRoutingPlanRequest { + owner_worker_tag: string; + rules: EmailRoutingPlanRule[]; + catch_all_rules: EmailRoutingPlanCatchAll[]; +} + +export type PlanChangeType = "added" | "updated" | "deleted" | "conflict"; + +export interface PlanRemoteRule { + id?: string; + matchers: EmailRoutingMatcher[]; + actions: EmailRoutingAction[]; + source?: "api" | "wrangler"; + owner_worker_name?: string; +} + +export interface EmailRoutingPlanChange { + type: PlanChangeType; + /** Recipient address or `*@domain` catch-all target this change applies to. */ + target: string; + /** Present for updates, deletes, and conflicts. */ + remote?: PlanRemoteRule; +} + +export interface EmailRoutingPlanZone { + zone_id: string; + zone_name?: string; + changes: EmailRoutingPlanChange[]; +} + +export interface EmailRoutingPlanResponse { + zones: EmailRoutingPlanZone[]; +} + +/** + * The matchers + actions routing one address to `workerName`. Single source of + * truth shared by the plan request and the apply bodies. + */ +export function ruleShapeForTarget( + target: string, + workerName: string +): EmailRoutingPlanRule { + const actions: EmailRoutingAction[] = [ + { type: "worker", value: [workerName] }, + ]; + if (isCatchAllAddress(target)) { + return { matchers: [{ type: "all" }], actions }; + } + return { + matchers: [{ type: "literal", field: "to", value: target }], + actions, + }; +} + +/** + * Compile the `addresses` config into a plan request: literal recipients become + * normal rules, `*@domain` entries become catch-all rules carrying the target. + */ +export function buildEmailRoutingPlanRequest( + addresses: string[], + workerName: string, + ownerWorkerTag: string +): EmailRoutingPlanRequest { + const rules: EmailRoutingPlanRule[] = []; + const catchAllRules: EmailRoutingPlanCatchAll[] = []; + + for (const address of addresses) { + const rule = ruleShapeForTarget(address, workerName); + if (isCatchAllAddress(address)) { + catchAllRules.push({ target: address, rule }); + } else { + rules.push(rule); + } + } + + return { + owner_worker_tag: ownerWorkerTag, + rules, + catch_all_rules: catchAllRules, + }; +} + +/** Changes that need user confirmation: deletes (incl. catch-all resets) and conflicts. */ +export function isDestructiveChange(change: EmailRoutingPlanChange): boolean { + return change.type === "deleted" || change.type === "conflict"; +} + +export function planHasChanges(plan: EmailRoutingPlanResponse): boolean { + return plan.zones.some((zone) => zone.changes.length > 0); +} + +export function planHasDestructiveChanges( + plan: EmailRoutingPlanResponse +): boolean { + return plan.zones.some((zone) => zone.changes.some(isDestructiveChange)); +} + +const CHANGE_MARKERS: Record = { + added: "+", + updated: "~", + deleted: "-", + conflict: "!", +}; + +/** Human description of what a remote rule currently does, for conflict lines. */ +function describeRemoteAction(remote: PlanRemoteRule): string { + const action = remote.actions[0]; + if (!action) { + return "no action"; + } + const value = action.value?.join(", "); + switch (action.type) { + case "forward": + return value ? `forward to ${value}` : "forward"; + case "worker": + return value ? `worker ${value}` : "worker"; + case "drop": + return "drop"; + default: + return action.type; + } +} + +/** Human description of who owns a conflicting remote rule. */ +function describeConflictOwner(remote: PlanRemoteRule): string { + if (remote.source === "wrangler") { + return remote.owner_worker_name + ? `owned by worker "${remote.owner_worker_name}"` + : "owned by another Worker"; + } + return "managed outside Wrangler"; +} + +/** + * Render the plan grouped by zone, one change per line (`+ ~ - !`) plus a + * summary. Returns lines so callers can log and tests can assert. + */ +export function renderEmailRoutingPlan( + plan: EmailRoutingPlanResponse, + workerName: string +): string[] { + const lines: string[] = []; + const counts: Record = { + 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; +} diff --git a/packages/deploy-helpers/src/triggers/email-routing.ts b/packages/deploy-helpers/src/triggers/email-routing.ts new file mode 100644 index 00000000000..5d4d36f5abc --- /dev/null +++ b/packages/deploy-helpers/src/triggers/email-routing.ts @@ -0,0 +1,341 @@ +import { spinner } from "@cloudflare/cli-shared-helpers/interactive"; +import { + APIError, + isNonInteractiveOrCI, + UserError, +} from "@cloudflare/workers-utils"; +import { isWorkerNotFoundError } from "../deploy/helpers/worker-not-found-error"; +import { confirm, fetchResult, logger } from "../shared/context"; +import { + buildEmailRoutingPlanRequest, + isCatchAllAddress, + planHasChanges, + planHasDestructiveChanges, + renderEmailRoutingPlan, + ruleShapeForTarget, +} from "./email-routing-plan"; +import type { + EmailRoutingPlanChange, + EmailRoutingPlanRequest, + EmailRoutingPlanResponse, +} 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 per-zone rule endpoints. + */ + +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 (!isNonInteractiveOrCI()) { + 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 { + 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 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)); + } + } +} + +/** + * 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. Re-run the deployment.", + { telemetryMessage: "email routing plan worker not found" } + ); + } + await new Promise((resolve) => setTimeout(resolve, PLAN_RETRY_DELAY_MS)); + } + } +} + +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, + 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 putJson( + config, + `/zones/${zoneId}/email/routing/rules/catch_all`, + change.type === "deleted" + ? RESET_CATCH_ALL_BODY + : desiredRuleBody(change.target, workerName, ownerWorkerTag) + ); + return; + } + + switch (change.type) { + case "added": + await fetchResult( + config, + `/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. + 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 the deployment.`, + { telemetryMessage: "email routing plan missing rule id" } + ); + } + await putJson( + config, + `/zones/${zoneId}/email/routing/rules/${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 the deployment.`, + { telemetryMessage: "email routing plan missing rule id" } + ); + } + await fetchResult(config, `/zones/${zoneId}/email/routing/rules/${id}`, { + method: "DELETE", + }); + return; + } + } +} + +/** + * 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, + accountId, + scriptName, + workerTag, +}: { + config: Config; + accountId: string; + scriptName: string; + workerTag?: string | null; +}): Promise { + const { addresses } = config; + if (addresses === undefined) { + return; + } + + 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 rules are up to date."); + return; + } + + logger.log( + ["Email Routing plan:", ...renderEmailRoutingPlan(plan, scriptName)].join( + "\n" + ) + ); + + if (planHasDestructiveChanges(plan)) { + if (isNonInteractiveOrCI()) { + throw new UserError( + "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", + } + ); + } + const accepted = await confirm( + "Apply these Email Routing changes (including the destructive ones above)?", + { defaultValue: false } + ); + if (!accepted) { + throw new UserError( + "The Worker is deployed, but the Email Routing changes were declined; no rules were modified.", + { telemetryMessage: "email routing changes declined" } + ); + } + } + + const failures: string[] = []; + 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) { + for (const failure of failures) { + logger.error(`Email Routing change failed: ${failure}`); + } + throw new UserError( + `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" } + ); + } + + logger.log("Email Routing addresses applied."); +} 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..39c6ecedcfa --- /dev/null +++ b/packages/deploy-helpers/tests/email-routing-apply.test.ts @@ -0,0 +1,395 @@ +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 { 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"; +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; + isNonInteractiveOrCI.mockImplementation(() => nonInteractive); + 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, + }); + }); + + 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/deploy-helpers/tests/email-routing-plan.test.ts b/packages/deploy-helpers/tests/email-routing-plan.test.ts new file mode 100644 index 00000000000..b850ed3bb07 --- /dev/null +++ b/packages/deploy-helpers/tests/email-routing-plan.test.ts @@ -0,0 +1,161 @@ +import { describe, it } from "vitest"; +import { + buildEmailRoutingPlanRequest, + isDestructiveChange, + planHasChanges, + planHasDestructiveChanges, + renderEmailRoutingPlan, +} 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", ({ + 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: 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)`, + "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/deploy-helpers/tests/triggers-email-routing.test.ts b/packages/deploy-helpers/tests/triggers-email-routing.test.ts new file mode 100644 index 00000000000..25634d233b9 --- /dev/null +++ b/packages/deploy-helpers/tests/triggers-email-routing.test.ts @@ -0,0 +1,125 @@ +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"; + +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 (path.endsWith("/schedules")) { + throw new Error("trigger deployment failed"); + } + 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, + }); + }); + + 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: [], + 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: [], + firstDeploy: false, + }); + + 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); + }); +});