From 29cc079fca6ea63b2b444cfda5b38791cf8366f0 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Mon, 16 Mar 2026 19:48:34 -0400 Subject: [PATCH 01/32] feat: add wrangler email routing commands Add CLI commands wrapping the Cloudflare Email Routing REST API: - wrangler email routing list - list zones with email routing status - wrangler email routing settings/enable/disable - manage zone settings - wrangler email routing dns get/unlock - DNS record management - wrangler email routing rules list/get/create/update/delete - routing rules CRUD - wrangler email routing rules catch-all get/update - catch-all rule management - wrangler email routing addresses list/get/create/delete - destination addresses Zone-scoped commands support --zone (domain) and --zone-id flags. Address commands are account-scoped. Also adds email_routing:write OAuth scope to DefaultScopes so wrangler login grants the necessary permissions. --- .../src/__tests__/email-routing.test.ts | 812 ++++++++++++++++++ packages/wrangler/src/core/teams.d.ts | 3 +- .../src/email-routing/addresses/create.ts | 28 + .../src/email-routing/addresses/delete.ts | 24 + .../src/email-routing/addresses/get.ts | 28 + .../src/email-routing/addresses/list.ts | 29 + packages/wrangler/src/email-routing/client.ts | 270 ++++++ .../wrangler/src/email-routing/disable.ts | 22 + .../wrangler/src/email-routing/dns-get.ts | 35 + .../wrangler/src/email-routing/dns-unlock.ts | 24 + packages/wrangler/src/email-routing/enable.ts | 24 + packages/wrangler/src/email-routing/index.ts | 143 +++ packages/wrangler/src/email-routing/list.ts | 95 ++ .../src/email-routing/rules/catch-all-get.ts | 31 + .../email-routing/rules/catch-all-update.ts | 67 ++ .../src/email-routing/rules/create.ts | 87 ++ .../src/email-routing/rules/delete.ts | 28 + .../wrangler/src/email-routing/rules/get.ts | 47 + .../wrangler/src/email-routing/rules/list.ts | 40 + .../src/email-routing/rules/update.ts | 92 ++ .../wrangler/src/email-routing/settings.ts | 27 + packages/wrangler/src/email-routing/utils.ts | 61 ++ packages/wrangler/src/index.ts | 116 +++ packages/wrangler/src/user/user.ts | 4 +- 24 files changed, 2135 insertions(+), 2 deletions(-) create mode 100644 packages/wrangler/src/__tests__/email-routing.test.ts create mode 100644 packages/wrangler/src/email-routing/addresses/create.ts create mode 100644 packages/wrangler/src/email-routing/addresses/delete.ts create mode 100644 packages/wrangler/src/email-routing/addresses/get.ts create mode 100644 packages/wrangler/src/email-routing/addresses/list.ts create mode 100644 packages/wrangler/src/email-routing/client.ts create mode 100644 packages/wrangler/src/email-routing/disable.ts create mode 100644 packages/wrangler/src/email-routing/dns-get.ts create mode 100644 packages/wrangler/src/email-routing/dns-unlock.ts create mode 100644 packages/wrangler/src/email-routing/enable.ts create mode 100644 packages/wrangler/src/email-routing/index.ts create mode 100644 packages/wrangler/src/email-routing/list.ts create mode 100644 packages/wrangler/src/email-routing/rules/catch-all-get.ts create mode 100644 packages/wrangler/src/email-routing/rules/catch-all-update.ts create mode 100644 packages/wrangler/src/email-routing/rules/create.ts create mode 100644 packages/wrangler/src/email-routing/rules/delete.ts create mode 100644 packages/wrangler/src/email-routing/rules/get.ts create mode 100644 packages/wrangler/src/email-routing/rules/list.ts create mode 100644 packages/wrangler/src/email-routing/rules/update.ts create mode 100644 packages/wrangler/src/email-routing/settings.ts create mode 100644 packages/wrangler/src/email-routing/utils.ts diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts new file mode 100644 index 0000000000..d8bd4b3905 --- /dev/null +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -0,0 +1,812 @@ +import { http, HttpResponse } from "msw"; +import { vi } from "vitest"; +import { endEventLoop } from "./helpers/end-event-loop"; +import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { clearDialogs } from "./helpers/mock-dialogs"; +import { useMockIsTTY } from "./helpers/mock-istty"; +import { createFetchResult, msw } from "./helpers/msw"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import { runWrangler } from "./helpers/run-wrangler"; + +// --- Mock data --- + +const mockZone = { + id: "zone-id-1", + name: "example.com", + status: "active", + account: { id: "some-account-id", name: "Test Account" }, +}; + +const mockSettings = { + id: "75610dab9e69410a82cf7e400a09ecec", + enabled: true, + name: "example.com", + created: "2024-01-01T00:00:00Z", + modified: "2024-01-02T00:00:00Z", + skip_wizard: true, + status: "ready", + tag: "75610dab9e69410a82cf7e400a09ecec", +}; + +const mockDnsRecords = [ + { + content: "route1.mx.cloudflare.net", + name: "example.com", + priority: 40, + ttl: 1, + type: "MX", + }, + { + content: "route2.mx.cloudflare.net", + name: "example.com", + priority: 13, + ttl: 1, + type: "MX", + }, +]; + +const mockRule = { + id: "rule-id-1", + actions: [{ type: "forward", value: ["dest@example.com"] }], + enabled: true, + matchers: [{ type: "literal", field: "to", value: "user@example.com" }], + name: "My Rule", + priority: 0, + tag: "rule-tag-1", +}; + +const mockCatchAll = { + id: "catch-all-id", + actions: [{ type: "forward", value: ["catchall@example.com"] }], + enabled: true, + matchers: [{ type: "all" }], + name: "catch-all", + tag: "catch-all-tag", +}; + +const mockAddress = { + id: "addr-id-1", + created: "2024-01-01T00:00:00Z", + email: "dest@example.com", + modified: "2024-01-02T00:00:00Z", + tag: "addr-tag-1", + verified: "2024-01-01T12:00:00Z", +}; + +// --- Help text tests --- + +describe("email routing help", () => { + const std = mockConsoleMethods(); + runInTempDir(); + + it("should show help text for email routing", async () => { + await runWrangler("email routing"); + await endEventLoop(); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toContain("Manage Email Routing"); + }); + + it("should show help text for email routing rules", async () => { + await runWrangler("email routing rules"); + await endEventLoop(); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toContain("Manage Email Routing rules"); + }); + + it("should show help text for email routing addresses", async () => { + await runWrangler("email routing addresses"); + await endEventLoop(); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toContain("Manage Email Routing destination addresses"); + }); +}); + +// --- Command tests --- + +describe("email routing commands", () => { + mockAccountId(); + mockApiToken(); + runInTempDir(); + const { setIsTTY } = useMockIsTTY(); + const std = mockConsoleMethods(); + + beforeEach(() => { + // @ts-expect-error we're using a very simple setTimeout mock here + vi.spyOn(global, "setTimeout").mockImplementation((fn, _period) => { + setImmediate(fn); + }); + setIsTTY(true); + }); + + afterEach(() => { + clearDialogs(); + }); + + // --- list --- + + describe("list", () => { + it("should list zones with email routing status", async () => { + mockListZones([mockZone]); + mockGetSettings(mockZone.id, mockSettings); + + await runWrangler("email routing list"); + + expect(std.out).toContain("example.com"); + expect(std.out).toContain("yes"); + }); + + it("should handle no zones", async () => { + mockListZones([]); + + await runWrangler("email routing list"); + + expect(std.out).toContain("No zones found in this account."); + }); + + it("should show 'not configured' for zones where email routing is not set up", async () => { + mockListZones([mockZone]); + msw.use( + http.get( + "*/zones/:zoneId/email/routing", + () => { + return HttpResponse.json( + createFetchResult(null, false, [ + { + code: 1000, + message: "not found", + }, + ]) + ); + }, + { once: true } + ) + ); + + await runWrangler("email routing list"); + + expect(std.out).toContain("example.com"); + expect(std.out).toContain("no"); + expect(std.out).toContain("not configured"); + }); + + it("should show 'error' and warn for real API failures", async () => { + mockListZones([mockZone]); + msw.use( + http.get( + "*/zones/:zoneId/email/routing", + () => { + return HttpResponse.json( + createFetchResult(null, false, [ + { + code: 10000, + message: "Authentication error", + }, + ]) + ); + }, + { once: true } + ) + ); + + await runWrangler("email routing list"); + + expect(std.out).toContain("example.com"); + expect(std.out).toContain("error"); + expect(std.warn).toContain("Failed to fetch email routing settings"); + }); + }); + + // --- zone validation --- + + describe("zone validation", () => { + it("should error when neither --zone nor --zone-id is provided", async () => { + await expect(runWrangler("email routing settings")).rejects.toThrow( + "You must provide either --zone (domain name) or --zone-id (zone ID)." + ); + }); + + it("should error when both --zone and --zone-id are provided", async () => { + await expect( + runWrangler( + "email routing settings --zone example.com --zone-id zone-id-1" + ) + ).rejects.toThrow(); + }); + + it("should error when --zone domain is not found", async () => { + // Return empty zones list for the domain lookup + msw.use( + http.get( + "*/zones", + () => { + return HttpResponse.json(createFetchResult([], true)); + }, + { once: true } + ) + ); + + await expect( + runWrangler("email routing settings --zone notfound.com") + ).rejects.toThrow("Could not find zone for `notfound.com`"); + }); + }); + + // --- settings --- + + describe("settings", () => { + it("should get settings with --zone-id", async () => { + mockGetSettings("zone-id-1", mockSettings); + + await runWrangler("email routing settings --zone-id zone-id-1"); + + expect(std.out).toContain("Email Routing for example.com"); + expect(std.out).toContain("Enabled: true"); + expect(std.out).toContain("Status: ready"); + }); + + it("should get settings with --zone (domain resolution)", async () => { + mockZoneLookup("example.com", "zone-id-1"); + mockGetSettings("zone-id-1", mockSettings); + + await runWrangler("email routing settings --zone example.com"); + + expect(std.out).toContain("Email Routing for example.com"); + }); + }); + + // --- enable --- + + describe("enable", () => { + it("should enable email routing", async () => { + mockEnableEmailRouting("zone-id-1", mockSettings); + + await runWrangler("email routing enable --zone-id zone-id-1"); + + expect(std.out).toContain("Email Routing enabled for example.com"); + }); + }); + + // --- disable --- + + describe("disable", () => { + it("should disable email routing", async () => { + mockDisableEmailRouting("zone-id-1"); + + await runWrangler("email routing disable --zone-id zone-id-1"); + + expect(std.out).toContain("Email Routing disabled for zone zone-id-1"); + }); + }); + + // --- dns get --- + + describe("dns get", () => { + it("should show dns records", async () => { + mockGetDns("zone-id-1", mockDnsRecords); + + await runWrangler("email routing dns get --zone-id zone-id-1"); + + expect(std.out).toContain("MX"); + expect(std.out).toContain("route1.mx.cloudflare.net"); + }); + }); + + // --- dns unlock --- + + describe("dns unlock", () => { + it("should unlock dns records", async () => { + mockUnlockDns("zone-id-1", mockSettings); + + await runWrangler("email routing dns unlock --zone-id zone-id-1"); + + expect(std.out).toContain("MX records unlocked for example.com"); + }); + }); + + // --- rules list --- + + describe("rules list", () => { + it("should list routing rules", async () => { + mockListRules("zone-id-1", [mockRule]); + + await runWrangler("email routing rules list --zone-id zone-id-1"); + + expect(std.out).toContain("rule-id-1"); + expect(std.out).toContain("My Rule"); + }); + + it("should handle no rules", async () => { + mockListRules("zone-id-1", []); + + await runWrangler("email routing rules list --zone-id zone-id-1"); + + expect(std.out).toContain("No routing rules found."); + }); + }); + + // --- rules get --- + + describe("rules get", () => { + it("should get a specific rule", async () => { + mockGetRule("zone-id-1", "rule-id-1", mockRule); + + await runWrangler( + "email routing rules get rule-id-1 --zone-id zone-id-1" + ); + + expect(std.out).toContain("Rule: rule-id-1"); + expect(std.out).toContain("Name: My Rule"); + expect(std.out).toContain("Enabled: true"); + }); + }); + + // --- rules create --- + + describe("rules create", () => { + it("should create a forwarding rule", async () => { + const reqProm = mockCreateRule("zone-id-1"); + + await runWrangler( + "email routing rules create --zone-id zone-id-1 --match-type literal --match-field to --match-value user@example.com --action-type forward --action-value dest@example.com --name 'My Rule'" + ); + + await expect(reqProm).resolves.toMatchObject({ + matchers: [{ type: "literal", field: "to", value: "user@example.com" }], + actions: [{ type: "forward", value: ["dest@example.com"] }], + name: "My Rule", + }); + + expect(std.out).toContain("Created routing rule:"); + }); + + it("should create a drop rule without --action-value", async () => { + const reqProm = mockCreateRule("zone-id-1"); + + await runWrangler( + "email routing rules create --zone-id zone-id-1 --match-type literal --match-field to --match-value spam@example.com --action-type drop" + ); + + await expect(reqProm).resolves.toMatchObject({ + matchers: [{ type: "literal", field: "to", value: "spam@example.com" }], + actions: [{ type: "drop" }], + }); + + expect(std.out).toContain("Created routing rule:"); + }); + + it("should error when forward is used without --action-value", async () => { + await expect( + runWrangler( + "email routing rules create --zone-id zone-id-1 --match-type literal --match-field to --match-value user@example.com --action-type forward" + ) + ).rejects.toThrow( + "--action-value is required when --action-type is not 'drop'" + ); + }); + }); + + // --- rules update --- + + describe("rules update", () => { + it("should update a routing rule", async () => { + const reqProm = mockUpdateRule("zone-id-1", "rule-id-1"); + + await runWrangler( + "email routing rules update rule-id-1 --zone-id zone-id-1 --match-type literal --match-field to --match-value updated@example.com --action-type forward --action-value newdest@example.com" + ); + + await expect(reqProm).resolves.toMatchObject({ + matchers: [ + { type: "literal", field: "to", value: "updated@example.com" }, + ], + actions: [{ type: "forward", value: ["newdest@example.com"] }], + }); + + expect(std.out).toContain("Updated routing rule:"); + }); + }); + + // --- rules delete --- + + describe("rules delete", () => { + it("should delete a routing rule", async () => { + mockDeleteRule("zone-id-1", "rule-id-1"); + + await runWrangler( + "email routing rules delete rule-id-1 --zone-id zone-id-1" + ); + + expect(std.out).toContain("Deleted routing rule: rule-id-1"); + }); + }); + + // --- catch-all get --- + + describe("rules catch-all get", () => { + it("should get the catch-all rule", async () => { + mockGetCatchAll("zone-id-1", mockCatchAll); + + await runWrangler( + "email routing rules catch-all get --zone-id zone-id-1" + ); + + expect(std.out).toContain("Catch-all rule:"); + expect(std.out).toContain("Enabled: true"); + expect(std.out).toContain("forward: catchall@example.com"); + }); + }); + + // --- catch-all update --- + + describe("rules catch-all update", () => { + it("should update the catch-all rule to drop", async () => { + const reqProm = mockUpdateCatchAll("zone-id-1"); + + await runWrangler( + "email routing rules catch-all update --zone-id zone-id-1 --action-type drop --enabled true" + ); + + await expect(reqProm).resolves.toMatchObject({ + actions: [{ type: "drop" }], + matchers: [{ type: "all" }], + enabled: true, + }); + + expect(std.out).toContain("Updated catch-all rule:"); + }); + + it("should update the catch-all rule to forward", async () => { + const reqProm = mockUpdateCatchAll("zone-id-1"); + + await runWrangler( + "email routing rules catch-all update --zone-id zone-id-1 --action-type forward --action-value catchall@example.com" + ); + + await expect(reqProm).resolves.toMatchObject({ + actions: [{ type: "forward", value: ["catchall@example.com"] }], + matchers: [{ type: "all" }], + }); + + expect(std.out).toContain("Updated catch-all rule:"); + }); + + it("should error when forward is used without --action-value", async () => { + await expect( + runWrangler( + "email routing rules catch-all update --zone-id zone-id-1 --action-type forward" + ) + ).rejects.toThrow( + "--action-value is required when --action-type is 'forward'" + ); + }); + }); + + // --- addresses list --- + + describe("addresses list", () => { + it("should list destination addresses", async () => { + mockListAddresses([mockAddress]); + + await runWrangler("email routing addresses list"); + + expect(std.out).toContain("dest@example.com"); + expect(std.out).toContain("addr-id-1"); + }); + + it("should handle no addresses", async () => { + mockListAddresses([]); + + await runWrangler("email routing addresses list"); + + expect(std.out).toContain("No destination addresses found."); + }); + }); + + // --- addresses get --- + + describe("addresses get", () => { + it("should get a destination address", async () => { + mockGetAddress("addr-id-1", mockAddress); + + await runWrangler("email routing addresses get addr-id-1"); + + expect(std.out).toContain("Destination address: dest@example.com"); + expect(std.out).toContain("ID: addr-id-1"); + }); + }); + + // --- addresses create --- + + describe("addresses create", () => { + it("should create a destination address", async () => { + mockCreateAddress(); + + await runWrangler("email routing addresses create newdest@example.com"); + + expect(std.out).toContain( + "Created destination address: newdest@example.com" + ); + expect(std.out).toContain("verification email has been sent"); + }); + }); + + // --- addresses delete --- + + describe("addresses delete", () => { + it("should delete a destination address", async () => { + mockDeleteAddress("addr-id-1"); + + await runWrangler("email routing addresses delete addr-id-1"); + + expect(std.out).toContain("Deleted destination address: addr-id-1"); + }); + }); +}); + +// --- Mock API handlers --- + +function mockListZones( + zones: Array<{ + id: string; + name: string; + status: string; + account: { id: string; name: string }; + }> +) { + msw.use( + http.get( + "*/zones", + () => { + return HttpResponse.json(createFetchResult(zones, true)); + }, + { once: true } + ) + ); +} + +function mockZoneLookup(domain: string, zoneId: string) { + msw.use( + http.get( + "*/zones", + ({ request }) => { + const url = new URL(request.url); + const name = url.searchParams.get("name"); + if (name === domain) { + return HttpResponse.json(createFetchResult([{ id: zoneId }], true)); + } + return HttpResponse.json(createFetchResult([], true)); + }, + { once: true } + ) + ); +} + +function mockGetSettings(_zoneId: string, settings: typeof mockSettings) { + msw.use( + http.get( + "*/zones/:zoneId/email/routing", + () => { + return HttpResponse.json(createFetchResult(settings, true)); + }, + { once: true } + ) + ); +} + +function mockEnableEmailRouting( + _zoneId: string, + settings: typeof mockSettings +) { + msw.use( + http.post( + "*/zones/:zoneId/email/routing/dns", + () => { + return HttpResponse.json(createFetchResult(settings, true)); + }, + { once: true } + ) + ); +} + +function mockDisableEmailRouting(_zoneId: string) { + msw.use( + http.delete( + "*/zones/:zoneId/email/routing/dns", + () => { + return HttpResponse.json(createFetchResult([], true)); + }, + { once: true } + ) + ); +} + +function mockGetDns(_zoneId: string, records: typeof mockDnsRecords) { + msw.use( + http.get( + "*/zones/:zoneId/email/routing/dns", + () => { + return HttpResponse.json(createFetchResult(records, true)); + }, + { once: true } + ) + ); +} + +function mockUnlockDns(_zoneId: string, settings: typeof mockSettings) { + msw.use( + http.patch( + "*/zones/:zoneId/email/routing/dns", + () => { + return HttpResponse.json(createFetchResult(settings, true)); + }, + { once: true } + ) + ); +} + +function mockListRules(_zoneId: string, rules: (typeof mockRule)[]) { + msw.use( + http.get( + "*/zones/:zoneId/email/routing/rules", + () => { + return HttpResponse.json(createFetchResult(rules, true)); + }, + { once: true } + ) + ); +} + +function mockGetRule(_zoneId: string, _ruleId: string, rule: typeof mockRule) { + msw.use( + http.get( + "*/zones/:zoneId/email/routing/rules/:ruleId", + () => { + return HttpResponse.json(createFetchResult(rule, true)); + }, + { once: true } + ) + ); +} + +function mockCreateRule(_zoneId: string): Promise { + return new Promise((resolve) => { + msw.use( + http.post( + "*/zones/:zoneId/email/routing/rules", + async ({ request }) => { + const reqBody = await request.json(); + resolve(reqBody); + return HttpResponse.json( + createFetchResult({ id: "new-rule-id", ...reqBody }, true) + ); + }, + { once: true } + ) + ); + }); +} + +function mockUpdateRule(_zoneId: string, _ruleId: string): Promise { + return new Promise((resolve) => { + msw.use( + http.put( + "*/zones/:zoneId/email/routing/rules/:ruleId", + async ({ request }) => { + const reqBody = await request.json(); + resolve(reqBody); + return HttpResponse.json( + createFetchResult({ id: "rule-id-1", ...reqBody }, true) + ); + }, + { once: true } + ) + ); + }); +} + +function mockDeleteRule(_zoneId: string, _ruleId: string) { + msw.use( + http.delete( + "*/zones/:zoneId/email/routing/rules/:ruleId", + () => { + return HttpResponse.json(createFetchResult(mockRule, true)); + }, + { once: true } + ) + ); +} + +function mockGetCatchAll(_zoneId: string, catchAll: typeof mockCatchAll) { + msw.use( + http.get( + "*/zones/:zoneId/email/routing/rules/catch_all", + () => { + return HttpResponse.json(createFetchResult(catchAll, true)); + }, + { once: true } + ) + ); +} + +function mockUpdateCatchAll(_zoneId: string): Promise { + return new Promise((resolve) => { + msw.use( + http.put( + "*/zones/:zoneId/email/routing/rules/catch_all", + async ({ request }) => { + const reqBody = await request.json(); + resolve(reqBody); + return HttpResponse.json( + createFetchResult({ id: "catch-all-id", ...reqBody }, true) + ); + }, + { once: true } + ) + ); + }); +} + +function mockListAddresses(addresses: (typeof mockAddress)[]) { + msw.use( + http.get( + "*/accounts/:accountId/email/routing/addresses", + () => { + return HttpResponse.json(createFetchResult(addresses, true)); + }, + { once: true } + ) + ); +} + +function mockGetAddress(_addressId: string, address: typeof mockAddress) { + msw.use( + http.get( + "*/accounts/:accountId/email/routing/addresses/:addressId", + () => { + return HttpResponse.json(createFetchResult(address, true)); + }, + { once: true } + ) + ); +} + +function mockCreateAddress() { + msw.use( + http.post( + "*/accounts/:accountId/email/routing/addresses", + async ({ request }) => { + const reqBody = (await request.json()) as { email: string }; + return HttpResponse.json( + createFetchResult( + { + id: "new-addr-id", + email: reqBody.email, + created: "2024-01-01T00:00:00Z", + modified: "2024-01-01T00:00:00Z", + tag: "new-tag", + verified: "", + }, + true + ) + ); + }, + { once: true } + ) + ); +} + +function mockDeleteAddress(_addressId: string) { + msw.use( + http.delete( + "*/accounts/:accountId/email/routing/addresses/:addressId", + () => { + return HttpResponse.json(createFetchResult(mockAddress, true)); + }, + { once: true } + ) + ); +} diff --git a/packages/wrangler/src/core/teams.d.ts b/packages/wrangler/src/core/teams.d.ts index fe14280a52..603ab3bde2 100644 --- a/packages/wrangler/src/core/teams.d.ts +++ b/packages/wrangler/src/core/teams.d.ts @@ -21,4 +21,5 @@ export type Teams = | "Product: Cloudchamber" | "Product: SSL" | "Product: WVPC" - | "Product: Tunnels"; + | "Product: Tunnels" + | "Product: Email Routing"; diff --git a/packages/wrangler/src/email-routing/addresses/create.ts b/packages/wrangler/src/email-routing/addresses/create.ts new file mode 100644 index 0000000000..af8aa868a5 --- /dev/null +++ b/packages/wrangler/src/email-routing/addresses/create.ts @@ -0,0 +1,28 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { createEmailRoutingAddress } from "../client"; + +export const emailRoutingAddressesCreateCommand = createCommand({ + metadata: { + description: "Create an Email Routing destination address", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + email: { + type: "string", + demandOption: true, + description: "Destination email address", + }, + }, + positionalArgs: ["email"], + async handler(args, { config }) { + const address = await createEmailRoutingAddress(config, args.email); + + logger.log(`Created destination address: ${address.email}`); + logger.log(` ID: ${address.id}`); + logger.log( + ` A verification email has been sent. The address must be verified before it can be used.` + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/addresses/delete.ts b/packages/wrangler/src/email-routing/addresses/delete.ts new file mode 100644 index 0000000000..98c7519a3e --- /dev/null +++ b/packages/wrangler/src/email-routing/addresses/delete.ts @@ -0,0 +1,24 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { deleteEmailRoutingAddress } from "../client"; + +export const emailRoutingAddressesDeleteCommand = createCommand({ + metadata: { + description: "Delete an Email Routing destination address", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + "address-id": { + type: "string", + demandOption: true, + description: "The ID of the destination address to delete", + }, + }, + positionalArgs: ["address-id"], + async handler(args, { config }) { + await deleteEmailRoutingAddress(config, args.addressId); + + logger.log(`Deleted destination address: ${args.addressId}`); + }, +}); diff --git a/packages/wrangler/src/email-routing/addresses/get.ts b/packages/wrangler/src/email-routing/addresses/get.ts new file mode 100644 index 0000000000..e6e52f80dc --- /dev/null +++ b/packages/wrangler/src/email-routing/addresses/get.ts @@ -0,0 +1,28 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { getEmailRoutingAddress } from "../client"; + +export const emailRoutingAddressesGetCommand = createCommand({ + metadata: { + description: "Get a specific Email Routing destination address", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + "address-id": { + type: "string", + demandOption: true, + description: "The ID of the destination address", + }, + }, + positionalArgs: ["address-id"], + async handler(args, { config }) { + const address = await getEmailRoutingAddress(config, args.addressId); + + logger.log(`Destination address: ${address.email}`); + logger.log(` ID: ${address.id}`); + logger.log(` Verified: ${address.verified || "pending"}`); + logger.log(` Created: ${address.created}`); + logger.log(` Modified: ${address.modified}`); + }, +}); diff --git a/packages/wrangler/src/email-routing/addresses/list.ts b/packages/wrangler/src/email-routing/addresses/list.ts new file mode 100644 index 0000000000..57cbd43e5a --- /dev/null +++ b/packages/wrangler/src/email-routing/addresses/list.ts @@ -0,0 +1,29 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { listEmailRoutingAddresses } from "../client"; + +export const emailRoutingAddressesListCommand = createCommand({ + metadata: { + description: "List Email Routing destination addresses", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: {}, + async handler(_args, { config }) { + const addresses = await listEmailRoutingAddresses(config); + + if (addresses.length === 0) { + logger.log("No destination addresses found."); + return; + } + + logger.table( + addresses.map((a) => ({ + id: a.id, + email: a.email, + verified: a.verified || "pending", + created: a.created, + })) + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts new file mode 100644 index 0000000000..0120c700c1 --- /dev/null +++ b/packages/wrangler/src/email-routing/client.ts @@ -0,0 +1,270 @@ +import { fetchPagedListResult, fetchResult } from "../cfetch"; +import { requireAuth } from "../user"; +import type { + CloudflareZone, + EmailRoutingAddress, + EmailRoutingCatchAllRule, + EmailRoutingDnsRecord, + EmailRoutingRule, + EmailRoutingSettings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +// --- Zones --- + +export async function listZones(config: Config): Promise { + const accountId = await requireAuth(config); + return await fetchPagedListResult( + config, + `/zones`, + {}, + new URLSearchParams({ "account.id": accountId }) + ); +} + +// --- Settings --- + +export async function getEmailRoutingSettings( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing` + ); +} + +// --- DNS (enable/disable/get/unlock) --- + +export async function enableEmailRouting( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/dns`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ); +} + +export async function disableEmailRouting( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/dns`, + { + method: "DELETE", + } + ); +} + +export async function getEmailRoutingDns( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/dns` + ); +} + +export async function unlockEmailRoutingDns( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/dns`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ); +} + +// --- Rules --- + +export async function listEmailRoutingRules( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchPagedListResult( + config, + `/zones/${zoneId}/email/routing/rules` + ); +} + +export async function getEmailRoutingRule( + config: Config, + zoneId: string, + ruleId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/rules/${ruleId}` + ); +} + +export async function createEmailRoutingRule( + config: Config, + zoneId: string, + body: { + actions: { type: string; value?: string[] }[]; + matchers: { type: string; field?: string; value?: string }[]; + name?: string; + enabled?: boolean; + priority?: number; + } +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/rules`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +export async function updateEmailRoutingRule( + config: Config, + zoneId: string, + ruleId: string, + body: { + actions: { type: string; value?: string[] }[]; + matchers: { type: string; field?: string; value?: string }[]; + name?: string; + enabled?: boolean; + priority?: number; + } +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/rules/${ruleId}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +export async function deleteEmailRoutingRule( + config: Config, + zoneId: string, + ruleId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/rules/${ruleId}`, + { + method: "DELETE", + } + ); +} + +// --- Catch-All --- + +export async function getEmailRoutingCatchAll( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/rules/catch_all` + ); +} + +export async function updateEmailRoutingCatchAll( + config: Config, + zoneId: string, + body: { + actions: { type: string; value?: string[] }[]; + matchers: { type: string }[]; + enabled?: boolean; + name?: string; + } +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/rules/catch_all`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +// --- Addresses --- + +export async function listEmailRoutingAddresses( + config: Config +): Promise { + const accountId = await requireAuth(config); + return await fetchPagedListResult( + config, + `/accounts/${accountId}/email/routing/addresses` + ); +} + +export async function getEmailRoutingAddress( + config: Config, + addressId: string +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/email/routing/addresses/${addressId}` + ); +} + +export async function createEmailRoutingAddress( + config: Config, + email: string +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/email/routing/addresses`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + } + ); +} + +export async function deleteEmailRoutingAddress( + config: Config, + addressId: string +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/email/routing/addresses/${addressId}`, + { + method: "DELETE", + } + ); +} diff --git a/packages/wrangler/src/email-routing/disable.ts b/packages/wrangler/src/email-routing/disable.ts new file mode 100644 index 0000000000..420eac2ee7 --- /dev/null +++ b/packages/wrangler/src/email-routing/disable.ts @@ -0,0 +1,22 @@ +import { createCommand } from "../core/create-command"; +import { logger } from "../logger"; +import { disableEmailRouting } from "./client"; +import { zoneArgs } from "./index"; +import { resolveZoneId } from "./utils"; + +export const emailRoutingDisableCommand = createCommand({ + metadata: { + description: "Disable Email Routing for a zone", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + await disableEmailRouting(config, zoneId); + + logger.log(`Email Routing disabled for zone ${zoneId}.`); + }, +}); diff --git a/packages/wrangler/src/email-routing/dns-get.ts b/packages/wrangler/src/email-routing/dns-get.ts new file mode 100644 index 0000000000..223619d8a0 --- /dev/null +++ b/packages/wrangler/src/email-routing/dns-get.ts @@ -0,0 +1,35 @@ +import { createCommand } from "../core/create-command"; +import { logger } from "../logger"; +import { getEmailRoutingDns } from "./client"; +import { zoneArgs } from "./index"; +import { resolveZoneId } from "./utils"; + +export const emailRoutingDnsGetCommand = createCommand({ + metadata: { + description: "Show DNS records required for Email Routing", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const records = await getEmailRoutingDns(config, zoneId); + + if (records.length === 0) { + logger.log("No DNS records found."); + return; + } + + logger.table( + records.map((r) => ({ + type: r.type, + name: r.name, + content: r.content, + priority: r.priority !== undefined ? String(r.priority) : "", + ttl: String(r.ttl), + })) + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/dns-unlock.ts b/packages/wrangler/src/email-routing/dns-unlock.ts new file mode 100644 index 0000000000..eefc4c48b2 --- /dev/null +++ b/packages/wrangler/src/email-routing/dns-unlock.ts @@ -0,0 +1,24 @@ +import { createCommand } from "../core/create-command"; +import { logger } from "../logger"; +import { unlockEmailRoutingDns } from "./client"; +import { zoneArgs } from "./index"; +import { resolveZoneId } from "./utils"; + +export const emailRoutingDnsUnlockCommand = createCommand({ + metadata: { + description: "Unlock MX records for Email Routing", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const settings = await unlockEmailRoutingDns(config, zoneId); + + logger.log( + `MX records unlocked for ${settings.name} (status: ${settings.status})` + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/enable.ts b/packages/wrangler/src/email-routing/enable.ts new file mode 100644 index 0000000000..62aa6e83fb --- /dev/null +++ b/packages/wrangler/src/email-routing/enable.ts @@ -0,0 +1,24 @@ +import { createCommand } from "../core/create-command"; +import { logger } from "../logger"; +import { enableEmailRouting } from "./client"; +import { zoneArgs } from "./index"; +import { resolveZoneId } from "./utils"; + +export const emailRoutingEnableCommand = createCommand({ + metadata: { + description: "Enable Email Routing for a zone", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const settings = await enableEmailRouting(config, zoneId); + + logger.log( + `Email Routing enabled for ${settings.name} (status: ${settings.status})` + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/index.ts b/packages/wrangler/src/email-routing/index.ts new file mode 100644 index 0000000000..15078e84bf --- /dev/null +++ b/packages/wrangler/src/email-routing/index.ts @@ -0,0 +1,143 @@ +import { createNamespace } from "../core/create-command"; + +export const emailNamespace = createNamespace({ + metadata: { + description: "Manage Cloudflare Email services", + status: "open-beta", + owner: "Product: Email Routing", + }, +}); + +export const emailRoutingNamespace = createNamespace({ + metadata: { + description: "Manage Email Routing", + status: "open-beta", + owner: "Product: Email Routing", + }, +}); + +export const emailRoutingDnsNamespace = createNamespace({ + metadata: { + description: "Manage Email Routing DNS settings", + status: "open-beta", + owner: "Product: Email Routing", + }, +}); + +export const emailRoutingRulesNamespace = createNamespace({ + metadata: { + description: "Manage Email Routing rules", + status: "open-beta", + owner: "Product: Email Routing", + }, +}); + +export const emailRoutingCatchAllNamespace = createNamespace({ + metadata: { + description: "Manage Email Routing catch-all rule", + status: "open-beta", + owner: "Product: Email Routing", + }, +}); + +export const emailRoutingAddressesNamespace = createNamespace({ + metadata: { + description: "Manage Email Routing destination addresses", + status: "open-beta", + owner: "Product: Email Routing", + }, +}); + +// --- Shared arg definitions --- + +export const zoneArgs = { + zone: { + type: "string", + description: "Domain name of the zone (e.g. example.com)", + conflicts: ["zone-id"], + }, + "zone-id": { + type: "string", + description: "Zone ID", + conflicts: ["zone"], + }, +} as const; + +// --- Types --- + +export interface EmailRoutingSettings { + id: string; + enabled: boolean; + name: string; + created: string; + modified: string; + skip_wizard: boolean; + status: string; + tag: string; +} + +export interface EmailRoutingDnsRecord { + content: string; + name: string; + priority?: number; + ttl: number; + type: string; +} + +export interface EmailRoutingRule { + id: string; + actions: EmailRoutingAction[]; + enabled: boolean; + matchers: EmailRoutingMatcher[]; + name: string; + priority: number; + tag: string; +} + +export interface EmailRoutingAction { + type: string; + value?: string[]; +} + +export interface EmailRoutingMatcher { + type: string; + field?: string; + value?: string; +} + +export interface EmailRoutingCatchAllRule { + id: string; + actions: EmailRoutingCatchAllAction[]; + enabled: boolean; + matchers: EmailRoutingCatchAllMatcher[]; + name: string; + tag: string; +} + +export interface EmailRoutingCatchAllAction { + type: string; + value?: string[]; +} + +export interface EmailRoutingCatchAllMatcher { + type: string; +} + +export interface EmailRoutingAddress { + id: string; + created: string; + email: string; + modified: string; + tag: string; + verified: string; +} + +export interface CloudflareZone { + id: string; + name: string; + status: string; + account: { + id: string; + name: string; + }; +} diff --git a/packages/wrangler/src/email-routing/list.ts b/packages/wrangler/src/email-routing/list.ts new file mode 100644 index 0000000000..5d1b7e7107 --- /dev/null +++ b/packages/wrangler/src/email-routing/list.ts @@ -0,0 +1,95 @@ +import { APIError } from "@cloudflare/workers-utils"; +import { createCommand } from "../core/create-command"; +import { logger } from "../logger"; +import { getEmailRoutingSettings, listZones } from "./client"; + +const CONCURRENCY_LIMIT = 5; + +// Error codes that indicate email routing is not configured for this zone +// rather than a real API failure. +const NOT_CONFIGURED_CODES = new Set([ + 1000, // not found + 1001, // unknown zone +]); + +export const emailRoutingListCommand = createCommand({ + metadata: { + description: "List zones with Email Routing", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: {}, + async handler(_args, { config }) { + const zones = await listZones(config); + + if (zones.length === 0) { + logger.log("No zones found in this account."); + return; + } + + // Fetch settings concurrently with a concurrency limit to avoid rate limiting + const results: { + zone: string; + "zone id": string; + enabled: string; + status: string; + }[] = []; + + let firstError: unknown = null; + + for (let i = 0; i < zones.length; i += CONCURRENCY_LIMIT) { + const batch = zones.slice(i, i + CONCURRENCY_LIMIT); + const batchResults = await Promise.all( + batch.map(async (zone) => { + try { + const settings = await getEmailRoutingSettings(config, zone.id); + return { + zone: zone.name, + "zone id": zone.id, + enabled: settings.enabled ? "yes" : "no", + status: settings.status, + }; + } catch (e) { + // Distinguish "not configured" from real API errors + if ( + e instanceof APIError && + e.code !== undefined && + NOT_CONFIGURED_CODES.has(e.code) + ) { + return { + zone: zone.name, + "zone id": zone.id, + enabled: "no", + status: "not configured", + }; + } + // Real error — log it and mark this zone as errored + logger.debug( + `Failed to get email routing settings for zone ${zone.name}: ${e}` + ); + if (!firstError) { + firstError = e; + } + return { + zone: zone.name, + "zone id": zone.id, + enabled: "error", + status: + e instanceof APIError ? `API error (code ${e.code})` : "error", + }; + } + }) + ); + results.push(...batchResults); + } + + logger.table(results); + + if (firstError) { + logger.warn( + `\nFailed to fetch email routing settings for some zones. This may be a permissions issue — ensure your API token has the "Email Routing" read permission.` + ); + logger.debug(`First error: ${firstError}`); + } + }, +}); diff --git a/packages/wrangler/src/email-routing/rules/catch-all-get.ts b/packages/wrangler/src/email-routing/rules/catch-all-get.ts new file mode 100644 index 0000000000..c1f6756fb1 --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/catch-all-get.ts @@ -0,0 +1,31 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { getEmailRoutingCatchAll } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailRoutingCatchAllGetCommand = createCommand({ + metadata: { + description: "Get the Email Routing catch-all rule", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const rule = await getEmailRoutingCatchAll(config, zoneId); + + logger.log(`Catch-all rule:`); + logger.log(` Enabled: ${rule.enabled}`); + logger.log(` Actions:`); + for (const a of rule.actions) { + if (a.value && a.value.length > 0) { + logger.log(` - ${a.type}: ${a.value.join(", ")}`); + } else { + logger.log(` - ${a.type}`); + } + } + }, +}); diff --git a/packages/wrangler/src/email-routing/rules/catch-all-update.ts b/packages/wrangler/src/email-routing/rules/catch-all-update.ts new file mode 100644 index 0000000000..97e505d5f7 --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/catch-all-update.ts @@ -0,0 +1,67 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { updateEmailRoutingCatchAll } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailRoutingCatchAllUpdateCommand = createCommand({ + metadata: { + description: "Update the Email Routing catch-all rule", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + enabled: { + type: "boolean", + description: "Whether the catch-all rule is enabled", + }, + "action-type": { + type: "string", + demandOption: true, + description: "Action type (forward or drop)", + choices: ["forward", "drop"], + }, + "action-value": { + type: "string", + array: true, + description: + "Destination address(es) to forward to (required if action-type is forward)", + }, + }, + validateArgs: (args) => { + if ( + args.actionType === "forward" && + (!args.actionValue || args.actionValue.length === 0) + ) { + throw new UserError( + "--action-value is required when --action-type is 'forward'" + ); + } + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const rule = await updateEmailRoutingCatchAll(config, zoneId, { + actions: [ + { + type: args.actionType, + value: args.actionValue, + }, + ], + matchers: [{ type: "all" }], + enabled: args.enabled, + }); + + logger.log(`Updated catch-all rule:`); + logger.log(` Enabled: ${rule.enabled}`); + logger.log(` Actions:`); + for (const a of rule.actions) { + if (a.value && a.value.length > 0) { + logger.log(` - ${a.type}: ${a.value.join(", ")}`); + } else { + logger.log(` - ${a.type}`); + } + } + }, +}); diff --git a/packages/wrangler/src/email-routing/rules/create.ts b/packages/wrangler/src/email-routing/rules/create.ts new file mode 100644 index 0000000000..0411f224ae --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/create.ts @@ -0,0 +1,87 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { createEmailRoutingRule } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailRoutingRulesCreateCommand = createCommand({ + metadata: { + description: "Create an Email Routing rule", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + name: { + type: "string", + description: "Rule name", + }, + enabled: { + type: "boolean", + description: "Whether the rule is enabled", + default: true, + }, + "match-type": { + type: "string", + demandOption: true, + description: "Matcher type (e.g. literal)", + }, + "match-field": { + type: "string", + demandOption: true, + description: "Matcher field (e.g. to)", + }, + "match-value": { + type: "string", + demandOption: true, + description: "Matcher value (e.g. user@example.com)", + }, + "action-type": { + type: "string", + demandOption: true, + description: "Action type (forward, drop, or worker)", + choices: ["forward", "drop", "worker"], + }, + "action-value": { + type: "string", + array: true, + description: + "Action value(s) (e.g. destination email address). Required for forward/worker actions.", + }, + priority: { + type: "number", + description: "Rule priority", + }, + }, + validateArgs: (args) => { + if ( + args.actionType !== "drop" && + (!args.actionValue || args.actionValue.length === 0) + ) { + throw new UserError( + "--action-value is required when --action-type is not 'drop'" + ); + } + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const rule = await createEmailRoutingRule(config, zoneId, { + actions: [{ type: args.actionType, value: args.actionValue }], + matchers: [ + { + type: args.matchType, + field: args.matchField, + value: args.matchValue, + }, + ], + name: args.name, + enabled: args.enabled, + priority: args.priority, + }); + + logger.log(`Created routing rule: ${rule.id}`); + logger.log(` Name: ${rule.name || "(none)"}`); + logger.log(` Enabled: ${rule.enabled}`); + }, +}); diff --git a/packages/wrangler/src/email-routing/rules/delete.ts b/packages/wrangler/src/email-routing/rules/delete.ts new file mode 100644 index 0000000000..7b9c2f0330 --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/delete.ts @@ -0,0 +1,28 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { deleteEmailRoutingRule } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailRoutingRulesDeleteCommand = createCommand({ + metadata: { + description: "Delete an Email Routing rule", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + "rule-id": { + type: "string", + demandOption: true, + description: "The ID of the routing rule to delete", + }, + }, + positionalArgs: ["rule-id"], + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + await deleteEmailRoutingRule(config, zoneId, args.ruleId); + + logger.log(`Deleted routing rule: ${args.ruleId}`); + }, +}); diff --git a/packages/wrangler/src/email-routing/rules/get.ts b/packages/wrangler/src/email-routing/rules/get.ts new file mode 100644 index 0000000000..26cf38fe9e --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/get.ts @@ -0,0 +1,47 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { getEmailRoutingRule } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailRoutingRulesGetCommand = createCommand({ + metadata: { + description: "Get a specific Email Routing rule", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + "rule-id": { + type: "string", + demandOption: true, + description: "The ID of the routing rule", + }, + }, + positionalArgs: ["rule-id"], + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const rule = await getEmailRoutingRule(config, zoneId, args.ruleId); + + logger.log(`Rule: ${rule.id}`); + logger.log(` Name: ${rule.name || "(none)"}`); + logger.log(` Enabled: ${rule.enabled}`); + logger.log(` Priority: ${rule.priority}`); + logger.log(` Matchers:`); + for (const m of rule.matchers) { + if (m.field && m.value) { + logger.log(` - ${m.type} ${m.field} = ${m.value}`); + } else { + logger.log(` - ${m.type}`); + } + } + logger.log(` Actions:`); + for (const a of rule.actions) { + if (a.value && a.value.length > 0) { + logger.log(` - ${a.type}: ${a.value.join(", ")}`); + } else { + logger.log(` - ${a.type}`); + } + } + }, +}); diff --git a/packages/wrangler/src/email-routing/rules/list.ts b/packages/wrangler/src/email-routing/rules/list.ts new file mode 100644 index 0000000000..04eb48baa6 --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/list.ts @@ -0,0 +1,40 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { listEmailRoutingRules } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailRoutingRulesListCommand = createCommand({ + metadata: { + description: "List Email Routing rules", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const rules = await listEmailRoutingRules(config, zoneId); + + if (rules.length === 0) { + logger.log("No routing rules found."); + return; + } + + logger.table( + rules.map((r) => ({ + id: r.id, + name: r.name || "", + enabled: r.enabled ? "yes" : "no", + matchers: r.matchers + .map((m) => (m.field && m.value ? `${m.field}:${m.value}` : m.type)) + .join(", "), + actions: r.actions + .map((a) => (a.value ? `${a.type}:${a.value.join(",")}` : a.type)) + .join(", "), + priority: String(r.priority), + })) + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/rules/update.ts b/packages/wrangler/src/email-routing/rules/update.ts new file mode 100644 index 0000000000..10d15b98e5 --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/update.ts @@ -0,0 +1,92 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { updateEmailRoutingRule } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailRoutingRulesUpdateCommand = createCommand({ + metadata: { + description: "Update an Email Routing rule", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + "rule-id": { + type: "string", + demandOption: true, + description: "The ID of the routing rule to update", + }, + name: { + type: "string", + description: "Rule name", + }, + enabled: { + type: "boolean", + description: "Whether the rule is enabled", + }, + "match-type": { + type: "string", + demandOption: true, + description: "Matcher type (e.g. literal)", + }, + "match-field": { + type: "string", + demandOption: true, + description: "Matcher field (e.g. to)", + }, + "match-value": { + type: "string", + demandOption: true, + description: "Matcher value (e.g. user@example.com)", + }, + "action-type": { + type: "string", + demandOption: true, + description: "Action type (forward, drop, or worker)", + choices: ["forward", "drop", "worker"], + }, + "action-value": { + type: "string", + array: true, + description: + "Action value(s) (e.g. destination email address). Required for forward/worker actions.", + }, + priority: { + type: "number", + description: "Rule priority", + }, + }, + positionalArgs: ["rule-id"], + validateArgs: (args) => { + if ( + args.actionType !== "drop" && + (!args.actionValue || args.actionValue.length === 0) + ) { + throw new UserError( + "--action-value is required when --action-type is not 'drop'" + ); + } + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const rule = await updateEmailRoutingRule(config, zoneId, args.ruleId, { + actions: [{ type: args.actionType, value: args.actionValue }], + matchers: [ + { + type: args.matchType, + field: args.matchField, + value: args.matchValue, + }, + ], + name: args.name, + enabled: args.enabled, + priority: args.priority, + }); + + logger.log(`Updated routing rule: ${rule.id}`); + logger.log(` Name: ${rule.name || "(none)"}`); + logger.log(` Enabled: ${rule.enabled}`); + }, +}); diff --git a/packages/wrangler/src/email-routing/settings.ts b/packages/wrangler/src/email-routing/settings.ts new file mode 100644 index 0000000000..2e5959ee58 --- /dev/null +++ b/packages/wrangler/src/email-routing/settings.ts @@ -0,0 +1,27 @@ +import { createCommand } from "../core/create-command"; +import { logger } from "../logger"; +import { getEmailRoutingSettings } from "./client"; +import { zoneArgs } from "./index"; +import { resolveZoneId } from "./utils"; + +export const emailRoutingSettingsCommand = createCommand({ + metadata: { + description: "Get Email Routing settings for a zone", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const settings = await getEmailRoutingSettings(config, zoneId); + + logger.log(`Email Routing for ${settings.name}:`); + logger.log(` Enabled: ${settings.enabled}`); + logger.log(` Status: ${settings.status}`); + logger.log(` Created: ${settings.created}`); + logger.log(` Modified: ${settings.modified}`); + logger.log(` Tag: ${settings.tag}`); + }, +}); diff --git a/packages/wrangler/src/email-routing/utils.ts b/packages/wrangler/src/email-routing/utils.ts new file mode 100644 index 0000000000..c9a3974b7e --- /dev/null +++ b/packages/wrangler/src/email-routing/utils.ts @@ -0,0 +1,61 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { fetchListResult } from "../cfetch"; +import { requireAuth } from "../user"; +import { retryOnAPIFailure } from "../utils/retry"; +import type { ComplianceConfig } from "../environment-variables/misc-variables"; +import type { Config } from "@cloudflare/workers-utils"; + +/** + * Resolve a zone ID from either --zone (domain name) or --zone-id (direct ID). + * At least one must be provided. + */ +export async function resolveZoneId( + config: Config, + args: { zone?: string; zoneId?: string } +): Promise { + if (args.zoneId) { + return args.zoneId; + } + + if (args.zone) { + const accountId = await requireAuth(config); + return await getZoneIdByDomain(config, args.zone, accountId); + } + + throw new UserError( + "You must provide either --zone (domain name) or --zone-id (zone ID)." + ); +} + +/** + * Look up a zone ID by domain name, using the same approach as zones.ts getZoneIdFromHost. + * Uses fetchListResult (cursor-based) rather than fetchPagedListResult (page-based) to match + * the existing pattern in zones.ts. This is safe because the `name` query parameter filters + * to an exact domain match, so the result is always 0 or 1 items — pagination is never needed. + */ +async function getZoneIdByDomain( + complianceConfig: ComplianceConfig, + domain: string, + accountId: string +): Promise { + const zones = await retryOnAPIFailure(() => + fetchListResult<{ id: string }>( + complianceConfig, + `/zones`, + {}, + new URLSearchParams({ + name: domain, + "account.id": accountId, + }) + ) + ); + + const zoneId = zones[0]?.id; + if (!zoneId) { + throw new UserError( + `Could not find zone for \`${domain}\`. Make sure the domain exists in your account.` + ); + } + + return zoneId; +} diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 06aec79ee6..767a428d6f 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -97,6 +97,32 @@ import { dispatchNamespaceRenameCommand, } from "./dispatch-namespace"; import { docs } from "./docs"; +import { emailRoutingAddressesCreateCommand } from "./email-routing/addresses/create"; +import { emailRoutingAddressesDeleteCommand } from "./email-routing/addresses/delete"; +import { emailRoutingAddressesGetCommand } from "./email-routing/addresses/get"; +import { emailRoutingAddressesListCommand } from "./email-routing/addresses/list"; +import { emailRoutingDisableCommand } from "./email-routing/disable"; +import { emailRoutingDnsGetCommand } from "./email-routing/dns-get"; +import { emailRoutingDnsUnlockCommand } from "./email-routing/dns-unlock"; +import { emailRoutingEnableCommand } from "./email-routing/enable"; +import { + emailNamespace, + emailRoutingAddressesNamespace, + emailRoutingCatchAllNamespace, + emailRoutingDnsNamespace, + emailRoutingNamespace, + emailRoutingRulesNamespace, +} from "./email-routing/index"; +import { emailRoutingListCommand } from "./email-routing/list"; +import { emailRoutingCatchAllGetCommand } from "./email-routing/rules/catch-all-get"; +import { emailRoutingCatchAllUpdateCommand } from "./email-routing/rules/catch-all-update"; +import { emailRoutingRulesCreateCommand } from "./email-routing/rules/create"; +import { emailRoutingRulesDeleteCommand } from "./email-routing/rules/delete"; +import { emailRoutingRulesGetCommand } from "./email-routing/rules/get"; +import { emailRoutingRulesListCommand } from "./email-routing/rules/list"; +import { emailRoutingRulesUpdateCommand } from "./email-routing/rules/update"; +import { emailRoutingSettingsCommand } from "./email-routing/settings"; +import { getEnvironmentVariableFactory } from "./environment-variables/factory"; import { helloWorldGetCommand, helloWorldNamespace, @@ -1841,6 +1867,96 @@ export function createCLIParser(argv: string[]) { ]); registry.registerNamespace("vpc"); + registry.define([ + { command: "wrangler email", definition: emailNamespace }, + { command: "wrangler email routing", definition: emailRoutingNamespace }, + { + command: "wrangler email routing list", + definition: emailRoutingListCommand, + }, + { + command: "wrangler email routing settings", + definition: emailRoutingSettingsCommand, + }, + { + command: "wrangler email routing enable", + definition: emailRoutingEnableCommand, + }, + { + command: "wrangler email routing disable", + definition: emailRoutingDisableCommand, + }, + { + command: "wrangler email routing dns", + definition: emailRoutingDnsNamespace, + }, + { + command: "wrangler email routing dns get", + definition: emailRoutingDnsGetCommand, + }, + { + command: "wrangler email routing dns unlock", + definition: emailRoutingDnsUnlockCommand, + }, + { + command: "wrangler email routing rules", + definition: emailRoutingRulesNamespace, + }, + { + command: "wrangler email routing rules list", + definition: emailRoutingRulesListCommand, + }, + { + command: "wrangler email routing rules get", + definition: emailRoutingRulesGetCommand, + }, + { + command: "wrangler email routing rules create", + definition: emailRoutingRulesCreateCommand, + }, + { + command: "wrangler email routing rules update", + definition: emailRoutingRulesUpdateCommand, + }, + { + command: "wrangler email routing rules delete", + definition: emailRoutingRulesDeleteCommand, + }, + { + command: "wrangler email routing rules catch-all", + definition: emailRoutingCatchAllNamespace, + }, + { + command: "wrangler email routing rules catch-all get", + definition: emailRoutingCatchAllGetCommand, + }, + { + command: "wrangler email routing rules catch-all update", + definition: emailRoutingCatchAllUpdateCommand, + }, + { + command: "wrangler email routing addresses", + definition: emailRoutingAddressesNamespace, + }, + { + command: "wrangler email routing addresses list", + definition: emailRoutingAddressesListCommand, + }, + { + command: "wrangler email routing addresses get", + definition: emailRoutingAddressesGetCommand, + }, + { + command: "wrangler email routing addresses create", + definition: emailRoutingAddressesCreateCommand, + }, + { + command: "wrangler email routing addresses delete", + definition: emailRoutingAddressesDeleteCommand, + }, + ]); + registry.registerNamespace("email"); + registry.define([ { command: "wrangler hello-world", definition: helloWorldNamespace }, { diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index 3337da9508..e5e74a86ec 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -377,7 +377,9 @@ const DefaultScopes = { "containers:write": "Manage Workers Containers", "cloudchamber:write": "Manage Cloudchamber", "connectivity:admin": - " See, change, and bind to Connectivity Directory services, including creating services targeting Cloudflare Tunnel.", + "See, change, and bind to Connectivity Directory services, including creating services targeting Cloudflare Tunnel.", + "email_routing:write": + "See and change Email Routing settings, rules, and destination addresses.", } as const; /** From b8d101307998331d89c9981657dc490954c109b8 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Tue, 17 Mar 2026 10:18:26 -0400 Subject: [PATCH 02/32] Add changeset for email routing commands (minor) --- .changeset/email-routing-commands.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/email-routing-commands.md diff --git a/.changeset/email-routing-commands.md b/.changeset/email-routing-commands.md new file mode 100644 index 0000000000..fe5bd6f71c --- /dev/null +++ b/.changeset/email-routing-commands.md @@ -0,0 +1,15 @@ +--- +"wrangler": minor +--- + +feat: add `wrangler email routing` commands for managing Email Routing via the CLI + +New commands: + +- `wrangler email routing list` - list zones with email routing status +- `wrangler email routing settings` - get email routing settings for a zone +- `wrangler email routing enable/disable` - enable or disable email routing +- `wrangler email routing dns get/unlock` - manage DNS records +- `wrangler email routing rules list/get/create/update/delete` - manage routing rules +- `wrangler email routing rules catch-all get/update` - manage catch-all rule +- `wrangler email routing addresses list/get/create/delete` - manage destination addresses From 135bbce4da25d7c3a9a466b9112a74d7a3f7187a Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 26 Mar 2026 10:03:16 -0400 Subject: [PATCH 03/32] feat: address PR feedback and add email sending commands - Merge catch-all into rules commands (rules get/update catch-all) - Remove separate catch-all sub-namespace per EMAIL team feedback - Add email sending control plane: subdomains list/get/create/delete, dns get - Add email sending data plane: send (builder) and send-raw (MIME) - Add 19 new tests for email sending commands (52 total) - Update changeset to include sending commands --- .changeset/email-routing-commands.md | 14 +- .../src/__tests__/email-routing.test.ts | 535 ++++++++++++++++-- packages/wrangler/src/email-routing/client.ts | 129 +++++ packages/wrangler/src/email-routing/index.ts | 51 +- .../src/email-routing/rules/catch-all-get.ts | 31 - .../email-routing/rules/catch-all-update.ts | 67 --- .../wrangler/src/email-routing/rules/get.ts | 25 +- .../src/email-routing/rules/update.ts | 97 +++- .../src/email-routing/sending/dns-get.ts | 45 ++ .../src/email-routing/sending/send-raw.ts | 72 +++ .../src/email-routing/sending/send.ts | 193 +++++++ .../sending/subdomains/create.ts | 43 ++ .../sending/subdomains/delete.ts | 28 + .../email-routing/sending/subdomains/get.ts | 44 ++ .../email-routing/sending/subdomains/list.ts | 36 ++ packages/wrangler/src/index.ts | 63 ++- 16 files changed, 1290 insertions(+), 183 deletions(-) delete mode 100644 packages/wrangler/src/email-routing/rules/catch-all-get.ts delete mode 100644 packages/wrangler/src/email-routing/rules/catch-all-update.ts create mode 100644 packages/wrangler/src/email-routing/sending/dns-get.ts create mode 100644 packages/wrangler/src/email-routing/sending/send-raw.ts create mode 100644 packages/wrangler/src/email-routing/sending/send.ts create mode 100644 packages/wrangler/src/email-routing/sending/subdomains/create.ts create mode 100644 packages/wrangler/src/email-routing/sending/subdomains/delete.ts create mode 100644 packages/wrangler/src/email-routing/sending/subdomains/get.ts create mode 100644 packages/wrangler/src/email-routing/sending/subdomains/list.ts diff --git a/.changeset/email-routing-commands.md b/.changeset/email-routing-commands.md index fe5bd6f71c..20ef9e0589 100644 --- a/.changeset/email-routing-commands.md +++ b/.changeset/email-routing-commands.md @@ -2,14 +2,20 @@ "wrangler": minor --- -feat: add `wrangler email routing` commands for managing Email Routing via the CLI +feat: add `wrangler email routing` and `wrangler email sending` commands -New commands: +Email Routing commands: - `wrangler email routing list` - list zones with email routing status - `wrangler email routing settings` - get email routing settings for a zone - `wrangler email routing enable/disable` - enable or disable email routing - `wrangler email routing dns get/unlock` - manage DNS records -- `wrangler email routing rules list/get/create/update/delete` - manage routing rules -- `wrangler email routing rules catch-all get/update` - manage catch-all rule +- `wrangler email routing rules list/get/create/update/delete` - manage routing rules (use `catch-all` as the rule ID for the catch-all rule) - `wrangler email routing addresses list/get/create/delete` - manage destination addresses + +Email Sending commands: + +- `wrangler email sending send` - send an email using the builder API +- `wrangler email sending send-raw` - send a raw MIME email message +- `wrangler email sending subdomains list/get/create/delete` - manage sending subdomains +- `wrangler email sending dns get` - get DNS records for a sending subdomain diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index d8bd4b3905..98d9780e46 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -74,6 +74,38 @@ const mockAddress = { verified: "2024-01-01T12:00:00Z", }; +const mockSubdomain = { + email_sending_enabled: true, + name: "sub.example.com", + tag: "aabbccdd11223344aabbccdd11223344", + created: "2024-01-01T00:00:00Z", + email_sending_dkim_selector: "cf-bounce", + email_sending_return_path_domain: "cf-bounce.sub.example.com", + enabled: true, + modified: "2024-01-02T00:00:00Z", +}; + +const mockSendingDnsRecords = [ + { + content: "v=spf1 include:_spf.mx.cloudflare.net ~all", + name: "sub.example.com", + ttl: 1, + type: "TXT", + }, + { + content: "cf-bounce._domainkey.sub.example.com", + name: "cf-bounce._domainkey.sub.example.com", + ttl: 1, + type: "CNAME", + }, +]; + +const mockSendResult = { + delivered: ["recipient@example.com"], + permanent_bounces: [], + queued: [], +}; + // --- Help text tests --- describe("email routing help", () => { @@ -103,9 +135,25 @@ describe("email routing help", () => { expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toContain("Manage Email Routing destination addresses"); }); + + it("should show help text for email sending", async () => { + await runWrangler("email sending"); + await endEventLoop(); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toContain("Manage Email Sending"); + }); + + it("should show help text for email sending subdomains", async () => { + await runWrangler("email sending subdomains"); + await endEventLoop(); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toContain("Manage Email Sending subdomains"); + }); }); -// --- Command tests --- +// --- Email Routing Command tests --- describe("email routing commands", () => { mockAccountId(); @@ -342,6 +390,18 @@ describe("email routing commands", () => { expect(std.out).toContain("Name: My Rule"); expect(std.out).toContain("Enabled: true"); }); + + it("should get the catch-all rule when rule-id is 'catch-all'", async () => { + mockGetCatchAll("zone-id-1", mockCatchAll); + + await runWrangler( + "email routing rules get catch-all --zone-id zone-id-1" + ); + + expect(std.out).toContain("Catch-all rule:"); + expect(std.out).toContain("Enabled: true"); + expect(std.out).toContain("forward: catchall@example.com"); + }); }); // --- rules create --- @@ -408,46 +468,12 @@ describe("email routing commands", () => { expect(std.out).toContain("Updated routing rule:"); }); - }); - // --- rules delete --- - - describe("rules delete", () => { - it("should delete a routing rule", async () => { - mockDeleteRule("zone-id-1", "rule-id-1"); - - await runWrangler( - "email routing rules delete rule-id-1 --zone-id zone-id-1" - ); - - expect(std.out).toContain("Deleted routing rule: rule-id-1"); - }); - }); - - // --- catch-all get --- - - describe("rules catch-all get", () => { - it("should get the catch-all rule", async () => { - mockGetCatchAll("zone-id-1", mockCatchAll); - - await runWrangler( - "email routing rules catch-all get --zone-id zone-id-1" - ); - - expect(std.out).toContain("Catch-all rule:"); - expect(std.out).toContain("Enabled: true"); - expect(std.out).toContain("forward: catchall@example.com"); - }); - }); - - // --- catch-all update --- - - describe("rules catch-all update", () => { it("should update the catch-all rule to drop", async () => { const reqProm = mockUpdateCatchAll("zone-id-1"); await runWrangler( - "email routing rules catch-all update --zone-id zone-id-1 --action-type drop --enabled true" + "email routing rules update catch-all --zone-id zone-id-1 --action-type drop --enabled true" ); await expect(reqProm).resolves.toMatchObject({ @@ -463,7 +489,7 @@ describe("email routing commands", () => { const reqProm = mockUpdateCatchAll("zone-id-1"); await runWrangler( - "email routing rules catch-all update --zone-id zone-id-1 --action-type forward --action-value catchall@example.com" + "email routing rules update catch-all --zone-id zone-id-1 --action-type forward --action-value catchall@example.com" ); await expect(reqProm).resolves.toMatchObject({ @@ -474,15 +500,39 @@ describe("email routing commands", () => { expect(std.out).toContain("Updated catch-all rule:"); }); - it("should error when forward is used without --action-value", async () => { + it("should error when catch-all forward is used without --action-value", async () => { await expect( runWrangler( - "email routing rules catch-all update --zone-id zone-id-1 --action-type forward" + "email routing rules update catch-all --zone-id zone-id-1 --action-type forward" ) ).rejects.toThrow( "--action-value is required when --action-type is 'forward'" ); }); + + it("should error when regular rule update is missing --match-type", async () => { + await expect( + runWrangler( + "email routing rules update rule-id-1 --zone-id zone-id-1 --action-type forward --action-value dest@example.com" + ) + ).rejects.toThrow( + "--match-type is required when updating a regular rule" + ); + }); + }); + + // --- rules delete --- + + describe("rules delete", () => { + it("should delete a routing rule", async () => { + mockDeleteRule("zone-id-1", "rule-id-1"); + + await runWrangler( + "email routing rules delete rule-id-1 --zone-id zone-id-1" + ); + + expect(std.out).toContain("Deleted routing rule: rule-id-1"); + }); }); // --- addresses list --- @@ -547,7 +597,278 @@ describe("email routing commands", () => { }); }); -// --- Mock API handlers --- +// --- Email Sending Command tests --- + +describe("email sending commands", () => { + mockAccountId(); + mockApiToken(); + runInTempDir(); + const { setIsTTY } = useMockIsTTY(); + const std = mockConsoleMethods(); + + beforeEach(() => { + // @ts-expect-error we're using a very simple setTimeout mock here + vi.spyOn(global, "setTimeout").mockImplementation((fn, _period) => { + setImmediate(fn); + }); + setIsTTY(true); + }); + + afterEach(() => { + clearDialogs(); + }); + + // --- subdomains list --- + + describe("subdomains list", () => { + it("should list sending subdomains", async () => { + mockListSendingSubdomains("zone-id-1", [mockSubdomain]); + + await runWrangler( + "email sending subdomains list --zone-id zone-id-1" + ); + + expect(std.out).toContain("sub.example.com"); + expect(std.out).toContain("yes"); + }); + + it("should handle no sending subdomains", async () => { + mockListSendingSubdomains("zone-id-1", []); + + await runWrangler( + "email sending subdomains list --zone-id zone-id-1" + ); + + expect(std.out).toContain("No sending subdomains found."); + }); + }); + + // --- subdomains get --- + + describe("subdomains get", () => { + it("should get a sending subdomain", async () => { + mockGetSendingSubdomain( + "zone-id-1", + "aabbccdd11223344aabbccdd11223344", + mockSubdomain + ); + + await runWrangler( + "email sending subdomains get aabbccdd11223344aabbccdd11223344 --zone-id zone-id-1" + ); + + expect(std.out).toContain("Sending subdomain: sub.example.com"); + expect(std.out).toContain("Tag: aabbccdd11223344aabbccdd11223344"); + expect(std.out).toContain("Sending enabled: true"); + expect(std.out).toContain("DKIM selector: cf-bounce"); + }); + }); + + // --- subdomains create --- + + describe("subdomains create", () => { + it("should create a sending subdomain", async () => { + const reqProm = mockCreateSendingSubdomain("zone-id-1"); + + await runWrangler( + "email sending subdomains create sub.example.com --zone-id zone-id-1" + ); + + await expect(reqProm).resolves.toMatchObject({ + name: "sub.example.com", + }); + + expect(std.out).toContain("Created sending subdomain: sub.example.com"); + }); + }); + + // --- subdomains delete --- + + describe("subdomains delete", () => { + it("should delete a sending subdomain", async () => { + mockDeleteSendingSubdomain( + "zone-id-1", + "aabbccdd11223344aabbccdd11223344" + ); + + await runWrangler( + "email sending subdomains delete aabbccdd11223344aabbccdd11223344 --zone-id zone-id-1" + ); + + expect(std.out).toContain( + "Deleted sending subdomain: aabbccdd11223344aabbccdd11223344" + ); + }); + }); + + // --- dns get --- + + describe("dns get", () => { + it("should show sending subdomain dns records", async () => { + mockGetSendingDns( + "zone-id-1", + "aabbccdd11223344aabbccdd11223344", + mockSendingDnsRecords + ); + + await runWrangler( + "email sending dns get aabbccdd11223344aabbccdd11223344 --zone-id zone-id-1" + ); + + expect(std.out).toContain("TXT"); + expect(std.out).toContain("v=spf1"); + }); + + it("should handle no dns records", async () => { + mockGetSendingDns( + "zone-id-1", + "aabbccdd11223344aabbccdd11223344", + [] + ); + + await runWrangler( + "email sending dns get aabbccdd11223344aabbccdd11223344 --zone-id zone-id-1" + ); + + expect(std.out).toContain( + "No DNS records found for this sending subdomain." + ); + }); + }); + + // --- send --- + + describe("send", () => { + it("should send an email with text body", async () => { + const reqProm = mockSendEmail(); + + await runWrangler( + "email sending send --from sender@example.com --to recipient@example.com --subject 'Test Email' --text 'Hello World'" + ); + + await expect(reqProm).resolves.toMatchObject({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Test Email", + text: "Hello World", + }); + + expect(std.out).toContain("Delivered to: recipient@example.com"); + }); + + it("should send an email with html body", async () => { + const reqProm = mockSendEmail(); + + await runWrangler( + "email sending send --from sender@example.com --to recipient@example.com --subject 'Test' --html '

Hello

'" + ); + + await expect(reqProm).resolves.toMatchObject({ + from: "sender@example.com", + subject: "Test", + html: "

Hello

", + }); + + expect(std.out).toContain("Delivered to:"); + }); + + it("should send with from-name", async () => { + const reqProm = mockSendEmail(); + + await runWrangler( + "email sending send --from sender@example.com --from-name 'John Doe' --to recipient@example.com --subject 'Test' --text 'Hi'" + ); + + await expect(reqProm).resolves.toMatchObject({ + from: { address: "sender@example.com", name: "John Doe" }, + }); + }); + + it("should send with cc and bcc", async () => { + const reqProm = mockSendEmail(); + + await runWrangler( + "email sending send --from sender@example.com --to recipient@example.com --cc cc@example.com --bcc bcc@example.com --subject 'Test' --text 'Hi'" + ); + + await expect(reqProm).resolves.toMatchObject({ + cc: ["cc@example.com"], + bcc: ["bcc@example.com"], + }); + }); + + it("should send with custom headers", async () => { + const reqProm = mockSendEmail(); + + await runWrangler( + "email sending send --from sender@example.com --to recipient@example.com --subject 'Test' --text 'Hi' --header 'X-Custom:value'" + ); + + await expect(reqProm).resolves.toMatchObject({ + headers: { "X-Custom": "value" }, + }); + }); + + it("should error when neither --text nor --html is provided", async () => { + await expect( + runWrangler( + "email sending send --from sender@example.com --to recipient@example.com --subject 'Test'" + ) + ).rejects.toThrow( + "At least one of --text or --html must be provided" + ); + }); + + it("should display queued and bounced recipients", async () => { + mockSendEmailWithResult({ + delivered: [], + queued: ["queued@example.com"], + permanent_bounces: ["bounced@example.com"], + }); + + await runWrangler( + "email sending send --from sender@example.com --to recipient@example.com --subject 'Test' --text 'Hi'" + ); + + expect(std.out).toContain("Queued for: queued@example.com"); + expect(std.warn).toContain("Permanently bounced: bounced@example.com"); + }); + }); + + // --- send-raw --- + + describe("send-raw", () => { + it("should send a raw MIME email", async () => { + const reqProm = mockSendRawEmail(); + const mimeMessage = + "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Hello\r\n\r\nHello, World!"; + + await runWrangler( + `email sending send-raw --from sender@example.com --to recipient@example.com --mime '${mimeMessage}'` + ); + + await expect(reqProm).resolves.toMatchObject({ + from: "sender@example.com", + recipients: ["recipient@example.com"], + mime_message: mimeMessage, + }); + + expect(std.out).toContain("Delivered to: recipient@example.com"); + }); + + it("should error when neither --mime nor --mime-file is provided", async () => { + await expect( + runWrangler( + "email sending send-raw --from sender@example.com --to recipient@example.com" + ) + ).rejects.toThrow( + "You must provide either --mime (inline MIME message) or --mime-file (path to MIME file)" + ); + }); + }); +}); + +// --- Mock API handlers: Email Routing --- function mockListZones( zones: Array<{ @@ -810,3 +1131,137 @@ function mockDeleteAddress(_addressId: string) { ) ); } + +// --- Mock API handlers: Email Sending --- + +function mockListSendingSubdomains( + _zoneId: string, + subdomains: (typeof mockSubdomain)[] +) { + msw.use( + http.get( + "*/zones/:zoneId/email/sending/subdomains", + () => { + return HttpResponse.json(createFetchResult(subdomains, true)); + }, + { once: true } + ) + ); +} + +function mockGetSendingSubdomain( + _zoneId: string, + _subdomainId: string, + subdomain: typeof mockSubdomain +) { + msw.use( + http.get( + "*/zones/:zoneId/email/sending/subdomains/:subdomainId", + () => { + return HttpResponse.json(createFetchResult(subdomain, true)); + }, + { once: true } + ) + ); +} + +function mockCreateSendingSubdomain(_zoneId: string): Promise { + return new Promise((resolve) => { + msw.use( + http.post( + "*/zones/:zoneId/email/sending/subdomains", + async ({ request }) => { + const reqBody = await request.json(); + resolve(reqBody); + return HttpResponse.json( + createFetchResult({ ...mockSubdomain, ...reqBody }, true) + ); + }, + { once: true } + ) + ); + }); +} + +function mockDeleteSendingSubdomain( + _zoneId: string, + _subdomainId: string +) { + msw.use( + http.delete( + "*/zones/:zoneId/email/sending/subdomains/:subdomainId", + () => { + return HttpResponse.json(createFetchResult(null, true)); + }, + { once: true } + ) + ); +} + +function mockGetSendingDns( + _zoneId: string, + _subdomainId: string, + records: typeof mockSendingDnsRecords +) { + msw.use( + http.get( + "*/zones/:zoneId/email/sending/subdomains/:subdomainId/dns", + () => { + return HttpResponse.json(createFetchResult(records, true)); + }, + { once: true } + ) + ); +} + +function mockSendEmail(): Promise { + return new Promise((resolve) => { + msw.use( + http.post( + "*/accounts/:accountId/email/sending/send", + async ({ request }) => { + const reqBody = await request.json(); + resolve(reqBody); + return HttpResponse.json( + createFetchResult(mockSendResult, true) + ); + }, + { once: true } + ) + ); + }); +} + +function mockSendEmailWithResult(result: { + delivered: string[]; + queued: string[]; + permanent_bounces: string[]; +}) { + msw.use( + http.post( + "*/accounts/:accountId/email/sending/send", + async () => { + return HttpResponse.json(createFetchResult(result, true)); + }, + { once: true } + ) + ); +} + +function mockSendRawEmail(): Promise { + return new Promise((resolve) => { + msw.use( + http.post( + "*/accounts/:accountId/email/sending/send_raw", + async ({ request }) => { + const reqBody = await request.json(); + resolve(reqBody); + return HttpResponse.json( + createFetchResult(mockSendResult, true) + ); + }, + { once: true } + ) + ); + }); +} diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index 0120c700c1..3c5de98d3e 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -7,6 +7,9 @@ import type { EmailRoutingDnsRecord, EmailRoutingRule, EmailRoutingSettings, + EmailSendingDnsRecord, + EmailSendingSendResponse, + EmailSendingSubdomain, } from "./index"; import type { Config } from "@cloudflare/workers-utils"; @@ -268,3 +271,129 @@ export async function deleteEmailRoutingAddress( } ); } + +// --- Email Sending: Subdomains --- + +export async function listEmailSendingSubdomains( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending/subdomains` + ); +} + +export async function getEmailSendingSubdomain( + config: Config, + zoneId: string, + subdomainId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending/subdomains/${subdomainId}` + ); +} + +export async function createEmailSendingSubdomain( + config: Config, + zoneId: string, + name: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending/subdomains`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + } + ); +} + +export async function deleteEmailSendingSubdomain( + config: Config, + zoneId: string, + subdomainId: string +): Promise { + await requireAuth(config); + await fetchResult( + config, + `/zones/${zoneId}/email/sending/subdomains/${subdomainId}`, + { + method: "DELETE", + } + ); +} + +// --- Email Sending: DNS --- + +export async function getEmailSendingSubdomainDns( + config: Config, + zoneId: string, + subdomainId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending/subdomains/${subdomainId}/dns` + ); +} + +// --- Email Sending: Send --- + +export async function sendEmail( + config: Config, + body: { + from: string | { address: string; name: string }; + subject: string; + to: string | string[]; + text?: string; + html?: string; + cc?: string | string[]; + bcc?: string | string[]; + reply_to?: string | { address: string; name: string }; + headers?: Record; + attachments?: Array<{ + content: string; + filename: string; + type: string; + disposition: "attachment" | "inline"; + content_id?: string; + }>; + } +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/email/sending/send`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); +} + +export async function sendRawEmail( + config: Config, + body: { + from: string; + recipients: string[]; + mime_message: string; + } +): Promise { + const accountId = await requireAuth(config); + return await fetchResult( + config, + `/accounts/${accountId}/email/sending/send_raw`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); +} diff --git a/packages/wrangler/src/email-routing/index.ts b/packages/wrangler/src/email-routing/index.ts index 15078e84bf..92f2a6418b 100644 --- a/packages/wrangler/src/email-routing/index.ts +++ b/packages/wrangler/src/email-routing/index.ts @@ -32,17 +32,33 @@ export const emailRoutingRulesNamespace = createNamespace({ }, }); -export const emailRoutingCatchAllNamespace = createNamespace({ +export const emailRoutingAddressesNamespace = createNamespace({ metadata: { - description: "Manage Email Routing catch-all rule", + description: "Manage Email Routing destination addresses", status: "open-beta", owner: "Product: Email Routing", }, }); -export const emailRoutingAddressesNamespace = createNamespace({ +export const emailSendingNamespace = createNamespace({ metadata: { - description: "Manage Email Routing destination addresses", + description: "Manage Email Sending", + status: "open-beta", + owner: "Product: Email Routing", + }, +}); + +export const emailSendingSubdomainsNamespace = createNamespace({ + metadata: { + description: "Manage Email Sending subdomains", + status: "open-beta", + owner: "Product: Email Routing", + }, +}); + +export const emailSendingDnsNamespace = createNamespace({ + metadata: { + description: "Manage Email Sending DNS records", status: "open-beta", owner: "Product: Email Routing", }, @@ -141,3 +157,30 @@ export interface CloudflareZone { name: string; }; } + +// --- Email Sending types --- + +export interface EmailSendingSubdomain { + email_sending_enabled: boolean; + name: string; + tag: string; + created?: string; + email_sending_dkim_selector?: string; + email_sending_return_path_domain?: string; + enabled?: boolean; + modified?: string; +} + +export interface EmailSendingDnsRecord { + content?: string; + name?: string; + priority?: number; + ttl?: number; + type?: string; +} + +export interface EmailSendingSendResponse { + delivered: string[]; + permanent_bounces: string[]; + queued: string[]; +} diff --git a/packages/wrangler/src/email-routing/rules/catch-all-get.ts b/packages/wrangler/src/email-routing/rules/catch-all-get.ts deleted file mode 100644 index c1f6756fb1..0000000000 --- a/packages/wrangler/src/email-routing/rules/catch-all-get.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createCommand } from "../../core/create-command"; -import { logger } from "../../logger"; -import { getEmailRoutingCatchAll } from "../client"; -import { zoneArgs } from "../index"; -import { resolveZoneId } from "../utils"; - -export const emailRoutingCatchAllGetCommand = createCommand({ - metadata: { - description: "Get the Email Routing catch-all rule", - status: "open-beta", - owner: "Product: Email Routing", - }, - args: { - ...zoneArgs, - }, - async handler(args, { config }) { - const zoneId = await resolveZoneId(config, args); - const rule = await getEmailRoutingCatchAll(config, zoneId); - - logger.log(`Catch-all rule:`); - logger.log(` Enabled: ${rule.enabled}`); - logger.log(` Actions:`); - for (const a of rule.actions) { - if (a.value && a.value.length > 0) { - logger.log(` - ${a.type}: ${a.value.join(", ")}`); - } else { - logger.log(` - ${a.type}`); - } - } - }, -}); diff --git a/packages/wrangler/src/email-routing/rules/catch-all-update.ts b/packages/wrangler/src/email-routing/rules/catch-all-update.ts deleted file mode 100644 index 97e505d5f7..0000000000 --- a/packages/wrangler/src/email-routing/rules/catch-all-update.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { UserError } from "@cloudflare/workers-utils"; -import { createCommand } from "../../core/create-command"; -import { logger } from "../../logger"; -import { updateEmailRoutingCatchAll } from "../client"; -import { zoneArgs } from "../index"; -import { resolveZoneId } from "../utils"; - -export const emailRoutingCatchAllUpdateCommand = createCommand({ - metadata: { - description: "Update the Email Routing catch-all rule", - status: "open-beta", - owner: "Product: Email Routing", - }, - args: { - ...zoneArgs, - enabled: { - type: "boolean", - description: "Whether the catch-all rule is enabled", - }, - "action-type": { - type: "string", - demandOption: true, - description: "Action type (forward or drop)", - choices: ["forward", "drop"], - }, - "action-value": { - type: "string", - array: true, - description: - "Destination address(es) to forward to (required if action-type is forward)", - }, - }, - validateArgs: (args) => { - if ( - args.actionType === "forward" && - (!args.actionValue || args.actionValue.length === 0) - ) { - throw new UserError( - "--action-value is required when --action-type is 'forward'" - ); - } - }, - async handler(args, { config }) { - const zoneId = await resolveZoneId(config, args); - const rule = await updateEmailRoutingCatchAll(config, zoneId, { - actions: [ - { - type: args.actionType, - value: args.actionValue, - }, - ], - matchers: [{ type: "all" }], - enabled: args.enabled, - }); - - logger.log(`Updated catch-all rule:`); - logger.log(` Enabled: ${rule.enabled}`); - logger.log(` Actions:`); - for (const a of rule.actions) { - if (a.value && a.value.length > 0) { - logger.log(` - ${a.type}: ${a.value.join(", ")}`); - } else { - logger.log(` - ${a.type}`); - } - } - }, -}); diff --git a/packages/wrangler/src/email-routing/rules/get.ts b/packages/wrangler/src/email-routing/rules/get.ts index 26cf38fe9e..f997b5114b 100644 --- a/packages/wrangler/src/email-routing/rules/get.ts +++ b/packages/wrangler/src/email-routing/rules/get.ts @@ -1,12 +1,13 @@ import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { getEmailRoutingRule } from "../client"; +import { getEmailRoutingCatchAll, getEmailRoutingRule } from "../client"; import { zoneArgs } from "../index"; import { resolveZoneId } from "../utils"; export const emailRoutingRulesGetCommand = createCommand({ metadata: { - description: "Get a specific Email Routing rule", + description: + "Get a specific Email Routing rule (use 'catch-all' as the rule ID to get the catch-all rule)", status: "open-beta", owner: "Product: Email Routing", }, @@ -15,12 +16,30 @@ export const emailRoutingRulesGetCommand = createCommand({ "rule-id": { type: "string", demandOption: true, - description: "The ID of the routing rule", + description: + "The ID of the routing rule, or 'catch-all' for the catch-all rule", }, }, positionalArgs: ["rule-id"], async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); + + if (args.ruleId === "catch-all") { + const rule = await getEmailRoutingCatchAll(config, zoneId); + + logger.log(`Catch-all rule:`); + logger.log(` Enabled: ${rule.enabled}`); + logger.log(` Actions:`); + for (const a of rule.actions) { + if (a.value && a.value.length > 0) { + logger.log(` - ${a.type}: ${a.value.join(", ")}`); + } else { + logger.log(` - ${a.type}`); + } + } + return; + } + const rule = await getEmailRoutingRule(config, zoneId, args.ruleId); logger.log(`Rule: ${rule.id}`); diff --git a/packages/wrangler/src/email-routing/rules/update.ts b/packages/wrangler/src/email-routing/rules/update.ts index 10d15b98e5..42042148e7 100644 --- a/packages/wrangler/src/email-routing/rules/update.ts +++ b/packages/wrangler/src/email-routing/rules/update.ts @@ -1,13 +1,14 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { updateEmailRoutingRule } from "../client"; +import { updateEmailRoutingCatchAll, updateEmailRoutingRule } from "../client"; import { zoneArgs } from "../index"; import { resolveZoneId } from "../utils"; export const emailRoutingRulesUpdateCommand = createCommand({ metadata: { - description: "Update an Email Routing rule", + description: + "Update an Email Routing rule (use 'catch-all' as the rule ID to update the catch-all rule)", status: "open-beta", owner: "Product: Email Routing", }, @@ -16,7 +17,8 @@ export const emailRoutingRulesUpdateCommand = createCommand({ "rule-id": { type: "string", demandOption: true, - description: "The ID of the routing rule to update", + description: + "The ID of the routing rule to update, or 'catch-all' for the catch-all rule", }, name: { type: "string", @@ -28,18 +30,18 @@ export const emailRoutingRulesUpdateCommand = createCommand({ }, "match-type": { type: "string", - demandOption: true, - description: "Matcher type (e.g. literal)", + description: + "Matcher type (e.g. literal). Required for regular rules, ignored for catch-all.", }, "match-field": { type: "string", - demandOption: true, - description: "Matcher field (e.g. to)", + description: + "Matcher field (e.g. to). Required for regular rules, ignored for catch-all.", }, "match-value": { type: "string", - demandOption: true, - description: "Matcher value (e.g. user@example.com)", + description: + "Matcher value (e.g. user@example.com). Required for regular rules, ignored for catch-all.", }, "action-type": { type: "string", @@ -55,27 +57,86 @@ export const emailRoutingRulesUpdateCommand = createCommand({ }, priority: { type: "number", - description: "Rule priority", + description: "Rule priority (ignored for catch-all)", }, }, positionalArgs: ["rule-id"], validateArgs: (args) => { - if ( - args.actionType !== "drop" && - (!args.actionValue || args.actionValue.length === 0) - ) { - throw new UserError( - "--action-value is required when --action-type is not 'drop'" - ); + if (args.ruleId === "catch-all") { + // Catch-all only supports forward and drop + if (args.actionType !== "forward" && args.actionType !== "drop") { + throw new UserError( + "Catch-all rule only supports 'forward' or 'drop' action types" + ); + } + if ( + args.actionType === "forward" && + (!args.actionValue || args.actionValue.length === 0) + ) { + throw new UserError( + "--action-value is required when --action-type is 'forward'" + ); + } + } else { + // Regular rules require matcher args + if (!args.matchType) { + throw new UserError( + "--match-type is required when updating a regular rule" + ); + } + if (!args.matchField) { + throw new UserError( + "--match-field is required when updating a regular rule" + ); + } + if (!args.matchValue) { + throw new UserError( + "--match-value is required when updating a regular rule" + ); + } + if ( + args.actionType !== "drop" && + (!args.actionValue || args.actionValue.length === 0) + ) { + throw new UserError( + "--action-value is required when --action-type is not 'drop'" + ); + } } }, async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); + + if (args.ruleId === "catch-all") { + const rule = await updateEmailRoutingCatchAll(config, zoneId, { + actions: [ + { + type: args.actionType, + value: args.actionValue, + }, + ], + matchers: [{ type: "all" }], + enabled: args.enabled, + }); + + logger.log(`Updated catch-all rule:`); + logger.log(` Enabled: ${rule.enabled}`); + logger.log(` Actions:`); + for (const a of rule.actions) { + if (a.value && a.value.length > 0) { + logger.log(` - ${a.type}: ${a.value.join(", ")}`); + } else { + logger.log(` - ${a.type}`); + } + } + return; + } + const rule = await updateEmailRoutingRule(config, zoneId, args.ruleId, { actions: [{ type: args.actionType, value: args.actionValue }], matchers: [ { - type: args.matchType, + type: args.matchType!, field: args.matchField, value: args.matchValue, }, diff --git a/packages/wrangler/src/email-routing/sending/dns-get.ts b/packages/wrangler/src/email-routing/sending/dns-get.ts new file mode 100644 index 0000000000..e2118d0b55 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/dns-get.ts @@ -0,0 +1,45 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { getEmailSendingSubdomainDns } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailSendingDnsGetCommand = createCommand({ + metadata: { + description: "Get DNS records for an Email Sending subdomain", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + "subdomain-id": { + type: "string", + demandOption: true, + description: "The sending subdomain identifier (tag)", + }, + }, + positionalArgs: ["subdomain-id"], + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const records = await getEmailSendingSubdomainDns( + config, + zoneId, + args.subdomainId + ); + + if (records.length === 0) { + logger.log("No DNS records found for this sending subdomain."); + return; + } + + logger.table( + records.map((r) => ({ + type: r.type || "", + name: r.name || "", + content: r.content || "", + priority: r.priority !== undefined ? String(r.priority) : "", + ttl: r.ttl !== undefined ? String(r.ttl) : "", + })) + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/sending/send-raw.ts b/packages/wrangler/src/email-routing/sending/send-raw.ts new file mode 100644 index 0000000000..ca1a75f5ab --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/send-raw.ts @@ -0,0 +1,72 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { readFileSync } from "fs"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { sendRawEmail } from "../client"; + +export const emailSendingSendRawCommand = createCommand({ + metadata: { + description: "Send a raw MIME email message", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + from: { + type: "string", + demandOption: true, + description: "Sender email address (SMTP envelope)", + }, + to: { + type: "string", + array: true, + demandOption: true, + description: "Recipient email address(es) (SMTP envelope)", + }, + mime: { + type: "string", + description: + "Raw MIME message string. Provide either --mime or --mime-file, not both.", + conflicts: ["mime-file"], + }, + "mime-file": { + type: "string", + description: + "Path to a file containing the raw MIME message. Provide either --mime or --mime-file, not both.", + conflicts: ["mime"], + }, + }, + validateArgs: (args) => { + if (!args.mime && !args.mimeFile) { + throw new UserError( + "You must provide either --mime (inline MIME message) or --mime-file (path to MIME file)" + ); + } + }, + async handler(args, { config }) { + let mimeMessage: string; + + if (args.mimeFile) { + mimeMessage = readFileSync(args.mimeFile, "utf-8"); + } else { + mimeMessage = args.mime!; + } + + const result = await sendRawEmail(config, { + from: args.from, + recipients: args.to, + mime_message: mimeMessage, + }); + + if (result.delivered.length > 0) { + logger.log(`Delivered to: ${result.delivered.join(", ")}`); + } + if (result.queued.length > 0) { + logger.log(`Queued for: ${result.queued.join(", ")}`); + } + if (result.permanent_bounces.length > 0) { + logger.warn( + `Permanently bounced: ${result.permanent_bounces.join(", ")}` + ); + } + }, +}); diff --git a/packages/wrangler/src/email-routing/sending/send.ts b/packages/wrangler/src/email-routing/sending/send.ts new file mode 100644 index 0000000000..7d318e4aac --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/send.ts @@ -0,0 +1,193 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { readFileSync } from "fs"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { sendEmail } from "../client"; + +export const emailSendingSendCommand = createCommand({ + metadata: { + description: "Send an email using the Email Sending builder", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + from: { + type: "string", + demandOption: true, + description: "Sender email address", + }, + to: { + type: "string", + array: true, + demandOption: true, + description: "Recipient email address(es)", + }, + subject: { + type: "string", + demandOption: true, + description: "Email subject line", + }, + text: { + type: "string", + description: "Plain text body of the email", + }, + html: { + type: "string", + description: "HTML body of the email", + }, + cc: { + type: "string", + array: true, + description: "CC recipient email address(es)", + }, + bcc: { + type: "string", + array: true, + description: "BCC recipient email address(es)", + }, + "reply-to": { + type: "string", + description: "Reply-to email address", + }, + "from-name": { + type: "string", + description: "Display name for the sender (used with --from)", + }, + "reply-to-name": { + type: "string", + description: "Display name for the reply-to address", + }, + header: { + type: "string", + array: true, + description: + "Custom header in 'Key:Value' format. Can be specified multiple times.", + }, + attachment: { + type: "string", + array: true, + description: + "File path to attach. Can be specified multiple times.", + }, + }, + validateArgs: (args) => { + if (!args.text && !args.html) { + throw new UserError( + "At least one of --text or --html must be provided" + ); + } + }, + async handler(args, { config }) { + const from = args.fromName + ? { address: args.from, name: args.fromName } + : args.from; + + const replyTo = args.replyTo + ? args.replyToName + ? { address: args.replyTo, name: args.replyToName } + : args.replyTo + : undefined; + + const headers = parseHeaders(args.header); + const attachments = parseAttachments(args.attachment); + + const result = await sendEmail(config, { + from, + to: args.to.length === 1 ? args.to[0] : args.to, + subject: args.subject, + text: args.text, + html: args.html, + cc: args.cc, + bcc: args.bcc, + reply_to: replyTo, + headers: headers.size > 0 ? Object.fromEntries(headers) : undefined, + attachments: attachments.length > 0 ? attachments : undefined, + }); + + if (result.delivered.length > 0) { + logger.log(`Delivered to: ${result.delivered.join(", ")}`); + } + if (result.queued.length > 0) { + logger.log(`Queued for: ${result.queued.join(", ")}`); + } + if (result.permanent_bounces.length > 0) { + logger.warn( + `Permanently bounced: ${result.permanent_bounces.join(", ")}` + ); + } + }, +}); + +function parseHeaders( + headerArgs: string[] | undefined +): Map { + const headers = new Map(); + if (!headerArgs) { + return headers; + } + for (const h of headerArgs) { + const colonIndex = h.indexOf(":"); + if (colonIndex === -1) { + throw new UserError( + `Invalid header format: '${h}'. Expected 'Key:Value'.` + ); + } + headers.set(h.slice(0, colonIndex).trim(), h.slice(colonIndex + 1).trim()); + } + return headers; +} + +function parseAttachments( + attachmentPaths: string[] | undefined +): Array<{ + content: string; + filename: string; + type: string; + disposition: "attachment"; +}> { + if (!attachmentPaths) { + return []; + } + return attachmentPaths.map((filePath) => { + const content = readFileSync(filePath); + const filename = filePath.split("/").pop() || filePath; + + return { + content: content.toString("base64"), + filename, + type: guessMimeType(filename), + disposition: "attachment" as const, + }; + }); +} + +function guessMimeType(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase(); + const mimeTypes: Record = { + txt: "text/plain", + html: "text/html", + htm: "text/html", + css: "text/css", + csv: "text/csv", + json: "application/json", + xml: "application/xml", + pdf: "application/pdf", + zip: "application/zip", + gz: "application/gzip", + tar: "application/x-tar", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + svg: "image/svg+xml", + webp: "image/webp", + ico: "image/x-icon", + mp3: "audio/mpeg", + mp4: "video/mp4", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }; + return mimeTypes[ext || ""] || "application/octet-stream"; +} diff --git a/packages/wrangler/src/email-routing/sending/subdomains/create.ts b/packages/wrangler/src/email-routing/sending/subdomains/create.ts new file mode 100644 index 0000000000..affdc1158e --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/subdomains/create.ts @@ -0,0 +1,43 @@ +import { createCommand } from "../../../core/create-command"; +import { logger } from "../../../logger"; +import { createEmailSendingSubdomain } from "../../client"; +import { zoneArgs } from "../../index"; +import { resolveZoneId } from "../../utils"; + +export const emailSendingSubdomainsCreateCommand = createCommand({ + metadata: { + description: "Create an Email Sending subdomain", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + name: { + type: "string", + demandOption: true, + description: + "The subdomain name (e.g. sub.example.com). Must be within the zone.", + }, + }, + positionalArgs: ["name"], + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const subdomain = await createEmailSendingSubdomain( + config, + zoneId, + args.name + ); + + logger.log(`Created sending subdomain: ${subdomain.name}`); + logger.log(` Tag: ${subdomain.tag}`); + logger.log( + ` Sending enabled: ${subdomain.email_sending_enabled}` + ); + logger.log( + ` DKIM selector: ${subdomain.email_sending_dkim_selector || "(none)"}` + ); + logger.log( + ` Return path: ${subdomain.email_sending_return_path_domain || "(none)"}` + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/sending/subdomains/delete.ts b/packages/wrangler/src/email-routing/sending/subdomains/delete.ts new file mode 100644 index 0000000000..826c01bd87 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/subdomains/delete.ts @@ -0,0 +1,28 @@ +import { createCommand } from "../../../core/create-command"; +import { logger } from "../../../logger"; +import { deleteEmailSendingSubdomain } from "../../client"; +import { zoneArgs } from "../../index"; +import { resolveZoneId } from "../../utils"; + +export const emailSendingSubdomainsDeleteCommand = createCommand({ + metadata: { + description: "Delete an Email Sending subdomain", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + "subdomain-id": { + type: "string", + demandOption: true, + description: "The sending subdomain identifier (tag) to delete", + }, + }, + positionalArgs: ["subdomain-id"], + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + await deleteEmailSendingSubdomain(config, zoneId, args.subdomainId); + + logger.log(`Deleted sending subdomain: ${args.subdomainId}`); + }, +}); diff --git a/packages/wrangler/src/email-routing/sending/subdomains/get.ts b/packages/wrangler/src/email-routing/sending/subdomains/get.ts new file mode 100644 index 0000000000..87bff36d17 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/subdomains/get.ts @@ -0,0 +1,44 @@ +import { createCommand } from "../../../core/create-command"; +import { logger } from "../../../logger"; +import { getEmailSendingSubdomain } from "../../client"; +import { zoneArgs } from "../../index"; +import { resolveZoneId } from "../../utils"; + +export const emailSendingSubdomainsGetCommand = createCommand({ + metadata: { + description: "Get a specific Email Sending subdomain", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + "subdomain-id": { + type: "string", + demandOption: true, + description: "The sending subdomain identifier (tag)", + }, + }, + positionalArgs: ["subdomain-id"], + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const subdomain = await getEmailSendingSubdomain( + config, + zoneId, + args.subdomainId + ); + + logger.log(`Sending subdomain: ${subdomain.name}`); + logger.log(` Tag: ${subdomain.tag}`); + logger.log( + ` Sending enabled: ${subdomain.email_sending_enabled}` + ); + logger.log( + ` DKIM selector: ${subdomain.email_sending_dkim_selector || "(none)"}` + ); + logger.log( + ` Return path: ${subdomain.email_sending_return_path_domain || "(none)"}` + ); + logger.log(` Created: ${subdomain.created || "(unknown)"}`); + logger.log(` Modified: ${subdomain.modified || "(unknown)"}`); + }, +}); diff --git a/packages/wrangler/src/email-routing/sending/subdomains/list.ts b/packages/wrangler/src/email-routing/sending/subdomains/list.ts new file mode 100644 index 0000000000..33dc753a98 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/subdomains/list.ts @@ -0,0 +1,36 @@ +import { createCommand } from "../../../core/create-command"; +import { logger } from "../../../logger"; +import { listEmailSendingSubdomains } from "../../client"; +import { zoneArgs } from "../../index"; +import { resolveZoneId } from "../../utils"; + +export const emailSendingSubdomainsListCommand = createCommand({ + metadata: { + description: "List Email Sending subdomains", + status: "open-beta", + owner: "Product: Email Routing", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const subdomains = await listEmailSendingSubdomains(config, zoneId); + + if (subdomains.length === 0) { + logger.log("No sending subdomains found."); + return; + } + + logger.table( + subdomains.map((s) => ({ + tag: s.tag, + name: s.name, + "sending enabled": s.email_sending_enabled ? "yes" : "no", + "dkim selector": s.email_sending_dkim_selector || "", + "return path": s.email_sending_return_path_domain || "", + created: s.created || "", + })) + ); + }, +}); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 767a428d6f..dbb00c7d88 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -108,21 +108,27 @@ import { emailRoutingEnableCommand } from "./email-routing/enable"; import { emailNamespace, emailRoutingAddressesNamespace, - emailRoutingCatchAllNamespace, emailRoutingDnsNamespace, emailRoutingNamespace, emailRoutingRulesNamespace, + emailSendingDnsNamespace, + emailSendingNamespace, + emailSendingSubdomainsNamespace, } from "./email-routing/index"; import { emailRoutingListCommand } from "./email-routing/list"; -import { emailRoutingCatchAllGetCommand } from "./email-routing/rules/catch-all-get"; -import { emailRoutingCatchAllUpdateCommand } from "./email-routing/rules/catch-all-update"; import { emailRoutingRulesCreateCommand } from "./email-routing/rules/create"; import { emailRoutingRulesDeleteCommand } from "./email-routing/rules/delete"; import { emailRoutingRulesGetCommand } from "./email-routing/rules/get"; import { emailRoutingRulesListCommand } from "./email-routing/rules/list"; import { emailRoutingRulesUpdateCommand } from "./email-routing/rules/update"; import { emailRoutingSettingsCommand } from "./email-routing/settings"; -import { getEnvironmentVariableFactory } from "./environment-variables/factory"; +import { emailSendingDnsGetCommand } from "./email-routing/sending/dns-get"; +import { emailSendingSendCommand } from "./email-routing/sending/send"; +import { emailSendingSendRawCommand } from "./email-routing/sending/send-raw"; +import { emailSendingSubdomainsCreateCommand } from "./email-routing/sending/subdomains/create"; +import { emailSendingSubdomainsDeleteCommand } from "./email-routing/sending/subdomains/delete"; +import { emailSendingSubdomainsGetCommand } from "./email-routing/sending/subdomains/get"; +import { emailSendingSubdomainsListCommand } from "./email-routing/sending/subdomains/list"; import { helloWorldGetCommand, helloWorldNamespace, @@ -1922,18 +1928,6 @@ export function createCLIParser(argv: string[]) { command: "wrangler email routing rules delete", definition: emailRoutingRulesDeleteCommand, }, - { - command: "wrangler email routing rules catch-all", - definition: emailRoutingCatchAllNamespace, - }, - { - command: "wrangler email routing rules catch-all get", - definition: emailRoutingCatchAllGetCommand, - }, - { - command: "wrangler email routing rules catch-all update", - definition: emailRoutingCatchAllUpdateCommand, - }, { command: "wrangler email routing addresses", definition: emailRoutingAddressesNamespace, @@ -1954,6 +1948,43 @@ export function createCLIParser(argv: string[]) { command: "wrangler email routing addresses delete", definition: emailRoutingAddressesDeleteCommand, }, + { command: "wrangler email sending", definition: emailSendingNamespace }, + { + command: "wrangler email sending send", + definition: emailSendingSendCommand, + }, + { + command: "wrangler email sending send-raw", + definition: emailSendingSendRawCommand, + }, + { + command: "wrangler email sending subdomains", + definition: emailSendingSubdomainsNamespace, + }, + { + command: "wrangler email sending subdomains list", + definition: emailSendingSubdomainsListCommand, + }, + { + command: "wrangler email sending subdomains get", + definition: emailSendingSubdomainsGetCommand, + }, + { + command: "wrangler email sending subdomains create", + definition: emailSendingSubdomainsCreateCommand, + }, + { + command: "wrangler email sending subdomains delete", + definition: emailSendingSubdomainsDeleteCommand, + }, + { + command: "wrangler email sending dns", + definition: emailSendingDnsNamespace, + }, + { + command: "wrangler email sending dns get", + definition: emailSendingDnsGetCommand, + }, ]); registry.registerNamespace("email"); From 857e5102afe27e609cfd3ed976251589ba8a8114 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 26 Mar 2026 13:02:35 -0400 Subject: [PATCH 04/32] fix: address PR review comments for email routing commands - Rename owner from 'Product: Email Routing' to 'Product: Email Service' per EMAIL team feedback - Use path.basename() instead of split('/') for cross-platform filename extraction - Use fetchPagedListResult for listEmailSendingSubdomains to handle pagination - Replace non-null assertion in rules/update.ts with type narrowing guard - Replace non-null assertion in send-raw.ts with ?? fallback - Use node: prefix for fs imports per repo conventions --- packages/wrangler/src/core/teams.d.ts | 2 +- .../src/email-routing/addresses/create.ts | 2 +- .../src/email-routing/addresses/delete.ts | 2 +- .../wrangler/src/email-routing/addresses/get.ts | 2 +- .../wrangler/src/email-routing/addresses/list.ts | 2 +- packages/wrangler/src/email-routing/client.ts | 2 +- packages/wrangler/src/email-routing/disable.ts | 2 +- packages/wrangler/src/email-routing/dns-get.ts | 2 +- .../wrangler/src/email-routing/dns-unlock.ts | 2 +- packages/wrangler/src/email-routing/enable.ts | 2 +- packages/wrangler/src/email-routing/index.ts | 16 ++++++++-------- packages/wrangler/src/email-routing/list.ts | 2 +- .../wrangler/src/email-routing/rules/create.ts | 2 +- .../wrangler/src/email-routing/rules/delete.ts | 2 +- packages/wrangler/src/email-routing/rules/get.ts | 2 +- .../wrangler/src/email-routing/rules/list.ts | 2 +- .../wrangler/src/email-routing/rules/update.ts | 8 ++++++-- .../src/email-routing/sending/dns-get.ts | 2 +- .../src/email-routing/sending/send-raw.ts | 6 +++--- .../wrangler/src/email-routing/sending/send.ts | 7 ++++--- .../email-routing/sending/subdomains/create.ts | 2 +- .../email-routing/sending/subdomains/delete.ts | 2 +- .../src/email-routing/sending/subdomains/get.ts | 2 +- .../src/email-routing/sending/subdomains/list.ts | 2 +- packages/wrangler/src/email-routing/settings.ts | 2 +- 25 files changed, 42 insertions(+), 37 deletions(-) diff --git a/packages/wrangler/src/core/teams.d.ts b/packages/wrangler/src/core/teams.d.ts index 603ab3bde2..ef4574244e 100644 --- a/packages/wrangler/src/core/teams.d.ts +++ b/packages/wrangler/src/core/teams.d.ts @@ -22,4 +22,4 @@ export type Teams = | "Product: SSL" | "Product: WVPC" | "Product: Tunnels" - | "Product: Email Routing"; + | "Product: Email Service"; diff --git a/packages/wrangler/src/email-routing/addresses/create.ts b/packages/wrangler/src/email-routing/addresses/create.ts index af8aa868a5..e0c5a49484 100644 --- a/packages/wrangler/src/email-routing/addresses/create.ts +++ b/packages/wrangler/src/email-routing/addresses/create.ts @@ -6,7 +6,7 @@ export const emailRoutingAddressesCreateCommand = createCommand({ metadata: { description: "Create an Email Routing destination address", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { email: { diff --git a/packages/wrangler/src/email-routing/addresses/delete.ts b/packages/wrangler/src/email-routing/addresses/delete.ts index 98c7519a3e..bf31a0f97b 100644 --- a/packages/wrangler/src/email-routing/addresses/delete.ts +++ b/packages/wrangler/src/email-routing/addresses/delete.ts @@ -6,7 +6,7 @@ export const emailRoutingAddressesDeleteCommand = createCommand({ metadata: { description: "Delete an Email Routing destination address", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { "address-id": { diff --git a/packages/wrangler/src/email-routing/addresses/get.ts b/packages/wrangler/src/email-routing/addresses/get.ts index e6e52f80dc..fc6f5c7cfe 100644 --- a/packages/wrangler/src/email-routing/addresses/get.ts +++ b/packages/wrangler/src/email-routing/addresses/get.ts @@ -6,7 +6,7 @@ export const emailRoutingAddressesGetCommand = createCommand({ metadata: { description: "Get a specific Email Routing destination address", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { "address-id": { diff --git a/packages/wrangler/src/email-routing/addresses/list.ts b/packages/wrangler/src/email-routing/addresses/list.ts index 57cbd43e5a..dafcb623ea 100644 --- a/packages/wrangler/src/email-routing/addresses/list.ts +++ b/packages/wrangler/src/email-routing/addresses/list.ts @@ -6,7 +6,7 @@ export const emailRoutingAddressesListCommand = createCommand({ metadata: { description: "List Email Routing destination addresses", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: {}, async handler(_args, { config }) { diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index 3c5de98d3e..d0fedbfd78 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -279,7 +279,7 @@ export async function listEmailSendingSubdomains( zoneId: string ): Promise { await requireAuth(config); - return await fetchResult( + return await fetchPagedListResult( config, `/zones/${zoneId}/email/sending/subdomains` ); diff --git a/packages/wrangler/src/email-routing/disable.ts b/packages/wrangler/src/email-routing/disable.ts index 420eac2ee7..de6ec60f51 100644 --- a/packages/wrangler/src/email-routing/disable.ts +++ b/packages/wrangler/src/email-routing/disable.ts @@ -8,7 +8,7 @@ export const emailRoutingDisableCommand = createCommand({ metadata: { description: "Disable Email Routing for a zone", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/dns-get.ts b/packages/wrangler/src/email-routing/dns-get.ts index 223619d8a0..3075ed1365 100644 --- a/packages/wrangler/src/email-routing/dns-get.ts +++ b/packages/wrangler/src/email-routing/dns-get.ts @@ -8,7 +8,7 @@ export const emailRoutingDnsGetCommand = createCommand({ metadata: { description: "Show DNS records required for Email Routing", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/dns-unlock.ts b/packages/wrangler/src/email-routing/dns-unlock.ts index eefc4c48b2..a8b8b6e49f 100644 --- a/packages/wrangler/src/email-routing/dns-unlock.ts +++ b/packages/wrangler/src/email-routing/dns-unlock.ts @@ -8,7 +8,7 @@ export const emailRoutingDnsUnlockCommand = createCommand({ metadata: { description: "Unlock MX records for Email Routing", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/enable.ts b/packages/wrangler/src/email-routing/enable.ts index 62aa6e83fb..9cabc28b7e 100644 --- a/packages/wrangler/src/email-routing/enable.ts +++ b/packages/wrangler/src/email-routing/enable.ts @@ -8,7 +8,7 @@ export const emailRoutingEnableCommand = createCommand({ metadata: { description: "Enable Email Routing for a zone", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/index.ts b/packages/wrangler/src/email-routing/index.ts index 92f2a6418b..1a1c140408 100644 --- a/packages/wrangler/src/email-routing/index.ts +++ b/packages/wrangler/src/email-routing/index.ts @@ -4,7 +4,7 @@ export const emailNamespace = createNamespace({ metadata: { description: "Manage Cloudflare Email services", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, }); @@ -12,7 +12,7 @@ export const emailRoutingNamespace = createNamespace({ metadata: { description: "Manage Email Routing", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, }); @@ -20,7 +20,7 @@ export const emailRoutingDnsNamespace = createNamespace({ metadata: { description: "Manage Email Routing DNS settings", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, }); @@ -28,7 +28,7 @@ export const emailRoutingRulesNamespace = createNamespace({ metadata: { description: "Manage Email Routing rules", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, }); @@ -36,7 +36,7 @@ export const emailRoutingAddressesNamespace = createNamespace({ metadata: { description: "Manage Email Routing destination addresses", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, }); @@ -44,7 +44,7 @@ export const emailSendingNamespace = createNamespace({ metadata: { description: "Manage Email Sending", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, }); @@ -52,7 +52,7 @@ export const emailSendingSubdomainsNamespace = createNamespace({ metadata: { description: "Manage Email Sending subdomains", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, }); @@ -60,7 +60,7 @@ export const emailSendingDnsNamespace = createNamespace({ metadata: { description: "Manage Email Sending DNS records", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, }); diff --git a/packages/wrangler/src/email-routing/list.ts b/packages/wrangler/src/email-routing/list.ts index 5d1b7e7107..9dea8a222f 100644 --- a/packages/wrangler/src/email-routing/list.ts +++ b/packages/wrangler/src/email-routing/list.ts @@ -16,7 +16,7 @@ export const emailRoutingListCommand = createCommand({ metadata: { description: "List zones with Email Routing", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: {}, async handler(_args, { config }) { diff --git a/packages/wrangler/src/email-routing/rules/create.ts b/packages/wrangler/src/email-routing/rules/create.ts index 0411f224ae..53be833f5d 100644 --- a/packages/wrangler/src/email-routing/rules/create.ts +++ b/packages/wrangler/src/email-routing/rules/create.ts @@ -9,7 +9,7 @@ export const emailRoutingRulesCreateCommand = createCommand({ metadata: { description: "Create an Email Routing rule", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/rules/delete.ts b/packages/wrangler/src/email-routing/rules/delete.ts index 7b9c2f0330..12b6e7a908 100644 --- a/packages/wrangler/src/email-routing/rules/delete.ts +++ b/packages/wrangler/src/email-routing/rules/delete.ts @@ -8,7 +8,7 @@ export const emailRoutingRulesDeleteCommand = createCommand({ metadata: { description: "Delete an Email Routing rule", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/rules/get.ts b/packages/wrangler/src/email-routing/rules/get.ts index f997b5114b..b54616fe65 100644 --- a/packages/wrangler/src/email-routing/rules/get.ts +++ b/packages/wrangler/src/email-routing/rules/get.ts @@ -9,7 +9,7 @@ export const emailRoutingRulesGetCommand = createCommand({ description: "Get a specific Email Routing rule (use 'catch-all' as the rule ID to get the catch-all rule)", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/rules/list.ts b/packages/wrangler/src/email-routing/rules/list.ts index 04eb48baa6..d87612043d 100644 --- a/packages/wrangler/src/email-routing/rules/list.ts +++ b/packages/wrangler/src/email-routing/rules/list.ts @@ -8,7 +8,7 @@ export const emailRoutingRulesListCommand = createCommand({ metadata: { description: "List Email Routing rules", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/rules/update.ts b/packages/wrangler/src/email-routing/rules/update.ts index 42042148e7..2b81afbd5c 100644 --- a/packages/wrangler/src/email-routing/rules/update.ts +++ b/packages/wrangler/src/email-routing/rules/update.ts @@ -10,7 +10,7 @@ export const emailRoutingRulesUpdateCommand = createCommand({ description: "Update an Email Routing rule (use 'catch-all' as the rule ID to update the catch-all rule)", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, @@ -132,11 +132,15 @@ export const emailRoutingRulesUpdateCommand = createCommand({ return; } + if (!args.matchType || !args.matchField || !args.matchValue) { + throw new UserError("Missing matcher arguments for regular rule update"); + } + const rule = await updateEmailRoutingRule(config, zoneId, args.ruleId, { actions: [{ type: args.actionType, value: args.actionValue }], matchers: [ { - type: args.matchType!, + type: args.matchType, field: args.matchField, value: args.matchValue, }, diff --git a/packages/wrangler/src/email-routing/sending/dns-get.ts b/packages/wrangler/src/email-routing/sending/dns-get.ts index e2118d0b55..07a7ec7e50 100644 --- a/packages/wrangler/src/email-routing/sending/dns-get.ts +++ b/packages/wrangler/src/email-routing/sending/dns-get.ts @@ -8,7 +8,7 @@ export const emailSendingDnsGetCommand = createCommand({ metadata: { description: "Get DNS records for an Email Sending subdomain", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/sending/send-raw.ts b/packages/wrangler/src/email-routing/sending/send-raw.ts index ca1a75f5ab..e8ea13af15 100644 --- a/packages/wrangler/src/email-routing/sending/send-raw.ts +++ b/packages/wrangler/src/email-routing/sending/send-raw.ts @@ -1,5 +1,5 @@ import { UserError } from "@cloudflare/workers-utils"; -import { readFileSync } from "fs"; +import { readFileSync } from "node:fs"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { sendRawEmail } from "../client"; @@ -8,7 +8,7 @@ export const emailSendingSendRawCommand = createCommand({ metadata: { description: "Send a raw MIME email message", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { from: { @@ -48,7 +48,7 @@ export const emailSendingSendRawCommand = createCommand({ if (args.mimeFile) { mimeMessage = readFileSync(args.mimeFile, "utf-8"); } else { - mimeMessage = args.mime!; + mimeMessage = args.mime ?? ""; } const result = await sendRawEmail(config, { diff --git a/packages/wrangler/src/email-routing/sending/send.ts b/packages/wrangler/src/email-routing/sending/send.ts index 7d318e4aac..e14795d9fe 100644 --- a/packages/wrangler/src/email-routing/sending/send.ts +++ b/packages/wrangler/src/email-routing/sending/send.ts @@ -1,5 +1,6 @@ import { UserError } from "@cloudflare/workers-utils"; -import { readFileSync } from "fs"; +import { readFileSync } from "node:fs"; +import path from "node:path"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { sendEmail } from "../client"; @@ -8,7 +9,7 @@ export const emailSendingSendCommand = createCommand({ metadata: { description: "Send an email using the Email Sending builder", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { from: { @@ -150,7 +151,7 @@ function parseAttachments( } return attachmentPaths.map((filePath) => { const content = readFileSync(filePath); - const filename = filePath.split("/").pop() || filePath; + const filename = path.basename(filePath); return { content: content.toString("base64"), diff --git a/packages/wrangler/src/email-routing/sending/subdomains/create.ts b/packages/wrangler/src/email-routing/sending/subdomains/create.ts index affdc1158e..86a720387f 100644 --- a/packages/wrangler/src/email-routing/sending/subdomains/create.ts +++ b/packages/wrangler/src/email-routing/sending/subdomains/create.ts @@ -8,7 +8,7 @@ export const emailSendingSubdomainsCreateCommand = createCommand({ metadata: { description: "Create an Email Sending subdomain", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/sending/subdomains/delete.ts b/packages/wrangler/src/email-routing/sending/subdomains/delete.ts index 826c01bd87..e36b8235c4 100644 --- a/packages/wrangler/src/email-routing/sending/subdomains/delete.ts +++ b/packages/wrangler/src/email-routing/sending/subdomains/delete.ts @@ -8,7 +8,7 @@ export const emailSendingSubdomainsDeleteCommand = createCommand({ metadata: { description: "Delete an Email Sending subdomain", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/sending/subdomains/get.ts b/packages/wrangler/src/email-routing/sending/subdomains/get.ts index 87bff36d17..8571803507 100644 --- a/packages/wrangler/src/email-routing/sending/subdomains/get.ts +++ b/packages/wrangler/src/email-routing/sending/subdomains/get.ts @@ -8,7 +8,7 @@ export const emailSendingSubdomainsGetCommand = createCommand({ metadata: { description: "Get a specific Email Sending subdomain", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/sending/subdomains/list.ts b/packages/wrangler/src/email-routing/sending/subdomains/list.ts index 33dc753a98..3ec84bcc17 100644 --- a/packages/wrangler/src/email-routing/sending/subdomains/list.ts +++ b/packages/wrangler/src/email-routing/sending/subdomains/list.ts @@ -8,7 +8,7 @@ export const emailSendingSubdomainsListCommand = createCommand({ metadata: { description: "List Email Sending subdomains", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, diff --git a/packages/wrangler/src/email-routing/settings.ts b/packages/wrangler/src/email-routing/settings.ts index 2e5959ee58..ea6e3da109 100644 --- a/packages/wrangler/src/email-routing/settings.ts +++ b/packages/wrangler/src/email-routing/settings.ts @@ -8,7 +8,7 @@ export const emailRoutingSettingsCommand = createCommand({ metadata: { description: "Get Email Routing settings for a zone", status: "open-beta", - owner: "Product: Email Routing", + owner: "Product: Email Service", }, args: { ...zoneArgs, From bc13d649461ec7763e906e6aec46587014a9923d Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 26 Mar 2026 14:35:20 -0400 Subject: [PATCH 05/32] fix: add email_sending:write OAuth scope to DefaultScopes The email sending commands use a separate OAuth scope (email_sending:write) from the routing commands (email_routing:write). Without this scope, wrangler login tokens get a 10000 auth error when calling email sending API endpoints. Bach staging MR: !4176 (merged) --- packages/wrangler/src/user/user.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index e5e74a86ec..9c2f6b126f 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -380,6 +380,8 @@ const DefaultScopes = { "See, change, and bind to Connectivity Directory services, including creating services targeting Cloudflare Tunnel.", "email_routing:write": "See and change Email Routing settings, rules, and destination addresses.", + "email_sending:write": + "See and change Email Sending settings and configuration.", } as const; /** From 74b76fd312acb6a85fcfcb39565d455526204889 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 26 Mar 2026 15:16:45 -0400 Subject: [PATCH 06/32] fix: resolve type errors and test failures in email routing commands - Change "open-beta" to "open beta" (correct status literal type) - Fix ComplianceConfig import to use @cloudflare/workers-utils instead of non-existent ../environment-variables/misc-variables module - Import describe/it/beforeEach/afterEach/vi from vitest in test file - Use ({ expect }) => test context pattern for all it() callbacks per repo conventions (no global expect) - Cast request.json() as Record to fix spread type errors --- .../src/__tests__/email-routing.test.ts | 118 +++++++++--------- .../src/email-routing/addresses/create.ts | 2 +- .../src/email-routing/addresses/delete.ts | 2 +- .../src/email-routing/addresses/get.ts | 2 +- .../src/email-routing/addresses/list.ts | 2 +- .../wrangler/src/email-routing/disable.ts | 2 +- .../wrangler/src/email-routing/dns-get.ts | 2 +- .../wrangler/src/email-routing/dns-unlock.ts | 2 +- packages/wrangler/src/email-routing/enable.ts | 2 +- packages/wrangler/src/email-routing/index.ts | 16 +-- packages/wrangler/src/email-routing/list.ts | 2 +- .../src/email-routing/rules/create.ts | 2 +- .../src/email-routing/rules/delete.ts | 2 +- .../wrangler/src/email-routing/rules/get.ts | 2 +- .../wrangler/src/email-routing/rules/list.ts | 2 +- .../src/email-routing/rules/update.ts | 2 +- .../src/email-routing/sending/dns-get.ts | 2 +- .../src/email-routing/sending/send-raw.ts | 2 +- .../src/email-routing/sending/send.ts | 2 +- .../sending/subdomains/create.ts | 2 +- .../sending/subdomains/delete.ts | 2 +- .../email-routing/sending/subdomains/get.ts | 2 +- .../email-routing/sending/subdomains/list.ts | 2 +- .../wrangler/src/email-routing/settings.ts | 2 +- packages/wrangler/src/email-routing/utils.ts | 3 +- 25 files changed, 92 insertions(+), 89 deletions(-) diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index 98d9780e46..2c7e06df3d 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -1,5 +1,5 @@ import { http, HttpResponse } from "msw"; -import { vi } from "vitest"; +import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { endEventLoop } from "./helpers/end-event-loop"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; @@ -112,7 +112,7 @@ describe("email routing help", () => { const std = mockConsoleMethods(); runInTempDir(); - it("should show help text for email routing", async () => { + it("should show help text for email routing", async ({ expect }) => { await runWrangler("email routing"); await endEventLoop(); @@ -120,7 +120,7 @@ describe("email routing help", () => { expect(std.out).toContain("Manage Email Routing"); }); - it("should show help text for email routing rules", async () => { + it("should show help text for email routing rules", async ({ expect }) => { await runWrangler("email routing rules"); await endEventLoop(); @@ -128,7 +128,7 @@ describe("email routing help", () => { expect(std.out).toContain("Manage Email Routing rules"); }); - it("should show help text for email routing addresses", async () => { + it("should show help text for email routing addresses", async ({ expect }) => { await runWrangler("email routing addresses"); await endEventLoop(); @@ -136,7 +136,7 @@ describe("email routing help", () => { expect(std.out).toContain("Manage Email Routing destination addresses"); }); - it("should show help text for email sending", async () => { + it("should show help text for email sending", async ({ expect }) => { await runWrangler("email sending"); await endEventLoop(); @@ -144,7 +144,7 @@ describe("email routing help", () => { expect(std.out).toContain("Manage Email Sending"); }); - it("should show help text for email sending subdomains", async () => { + it("should show help text for email sending subdomains", async ({ expect }) => { await runWrangler("email sending subdomains"); await endEventLoop(); @@ -177,7 +177,7 @@ describe("email routing commands", () => { // --- list --- describe("list", () => { - it("should list zones with email routing status", async () => { + it("should list zones with email routing status", async ({ expect }) => { mockListZones([mockZone]); mockGetSettings(mockZone.id, mockSettings); @@ -187,7 +187,7 @@ describe("email routing commands", () => { expect(std.out).toContain("yes"); }); - it("should handle no zones", async () => { + it("should handle no zones", async ({ expect }) => { mockListZones([]); await runWrangler("email routing list"); @@ -195,7 +195,7 @@ describe("email routing commands", () => { expect(std.out).toContain("No zones found in this account."); }); - it("should show 'not configured' for zones where email routing is not set up", async () => { + it("should show 'not configured' for zones where email routing is not set up", async ({ expect }) => { mockListZones([mockZone]); msw.use( http.get( @@ -221,7 +221,7 @@ describe("email routing commands", () => { expect(std.out).toContain("not configured"); }); - it("should show 'error' and warn for real API failures", async () => { + it("should show 'error' and warn for real API failures", async ({ expect }) => { mockListZones([mockZone]); msw.use( http.get( @@ -251,13 +251,13 @@ describe("email routing commands", () => { // --- zone validation --- describe("zone validation", () => { - it("should error when neither --zone nor --zone-id is provided", async () => { + it("should error when neither --zone nor --zone-id is provided", async ({ expect }) => { await expect(runWrangler("email routing settings")).rejects.toThrow( "You must provide either --zone (domain name) or --zone-id (zone ID)." ); }); - it("should error when both --zone and --zone-id are provided", async () => { + it("should error when both --zone and --zone-id are provided", async ({ expect }) => { await expect( runWrangler( "email routing settings --zone example.com --zone-id zone-id-1" @@ -265,7 +265,7 @@ describe("email routing commands", () => { ).rejects.toThrow(); }); - it("should error when --zone domain is not found", async () => { + it("should error when --zone domain is not found", async ({ expect }) => { // Return empty zones list for the domain lookup msw.use( http.get( @@ -286,7 +286,7 @@ describe("email routing commands", () => { // --- settings --- describe("settings", () => { - it("should get settings with --zone-id", async () => { + it("should get settings with --zone-id", async ({ expect }) => { mockGetSettings("zone-id-1", mockSettings); await runWrangler("email routing settings --zone-id zone-id-1"); @@ -296,7 +296,7 @@ describe("email routing commands", () => { expect(std.out).toContain("Status: ready"); }); - it("should get settings with --zone (domain resolution)", async () => { + it("should get settings with --zone (domain resolution)", async ({ expect }) => { mockZoneLookup("example.com", "zone-id-1"); mockGetSettings("zone-id-1", mockSettings); @@ -309,7 +309,7 @@ describe("email routing commands", () => { // --- enable --- describe("enable", () => { - it("should enable email routing", async () => { + it("should enable email routing", async ({ expect }) => { mockEnableEmailRouting("zone-id-1", mockSettings); await runWrangler("email routing enable --zone-id zone-id-1"); @@ -321,7 +321,7 @@ describe("email routing commands", () => { // --- disable --- describe("disable", () => { - it("should disable email routing", async () => { + it("should disable email routing", async ({ expect }) => { mockDisableEmailRouting("zone-id-1"); await runWrangler("email routing disable --zone-id zone-id-1"); @@ -333,7 +333,7 @@ describe("email routing commands", () => { // --- dns get --- describe("dns get", () => { - it("should show dns records", async () => { + it("should show dns records", async ({ expect }) => { mockGetDns("zone-id-1", mockDnsRecords); await runWrangler("email routing dns get --zone-id zone-id-1"); @@ -346,7 +346,7 @@ describe("email routing commands", () => { // --- dns unlock --- describe("dns unlock", () => { - it("should unlock dns records", async () => { + it("should unlock dns records", async ({ expect }) => { mockUnlockDns("zone-id-1", mockSettings); await runWrangler("email routing dns unlock --zone-id zone-id-1"); @@ -358,7 +358,7 @@ describe("email routing commands", () => { // --- rules list --- describe("rules list", () => { - it("should list routing rules", async () => { + it("should list routing rules", async ({ expect }) => { mockListRules("zone-id-1", [mockRule]); await runWrangler("email routing rules list --zone-id zone-id-1"); @@ -367,7 +367,7 @@ describe("email routing commands", () => { expect(std.out).toContain("My Rule"); }); - it("should handle no rules", async () => { + it("should handle no rules", async ({ expect }) => { mockListRules("zone-id-1", []); await runWrangler("email routing rules list --zone-id zone-id-1"); @@ -379,7 +379,7 @@ describe("email routing commands", () => { // --- rules get --- describe("rules get", () => { - it("should get a specific rule", async () => { + it("should get a specific rule", async ({ expect }) => { mockGetRule("zone-id-1", "rule-id-1", mockRule); await runWrangler( @@ -391,7 +391,7 @@ describe("email routing commands", () => { expect(std.out).toContain("Enabled: true"); }); - it("should get the catch-all rule when rule-id is 'catch-all'", async () => { + it("should get the catch-all rule when rule-id is 'catch-all'", async ({ expect }) => { mockGetCatchAll("zone-id-1", mockCatchAll); await runWrangler( @@ -407,7 +407,7 @@ describe("email routing commands", () => { // --- rules create --- describe("rules create", () => { - it("should create a forwarding rule", async () => { + it("should create a forwarding rule", async ({ expect }) => { const reqProm = mockCreateRule("zone-id-1"); await runWrangler( @@ -423,7 +423,7 @@ describe("email routing commands", () => { expect(std.out).toContain("Created routing rule:"); }); - it("should create a drop rule without --action-value", async () => { + it("should create a drop rule without --action-value", async ({ expect }) => { const reqProm = mockCreateRule("zone-id-1"); await runWrangler( @@ -438,7 +438,7 @@ describe("email routing commands", () => { expect(std.out).toContain("Created routing rule:"); }); - it("should error when forward is used without --action-value", async () => { + it("should error when forward is used without --action-value", async ({ expect }) => { await expect( runWrangler( "email routing rules create --zone-id zone-id-1 --match-type literal --match-field to --match-value user@example.com --action-type forward" @@ -452,7 +452,7 @@ describe("email routing commands", () => { // --- rules update --- describe("rules update", () => { - it("should update a routing rule", async () => { + it("should update a routing rule", async ({ expect }) => { const reqProm = mockUpdateRule("zone-id-1", "rule-id-1"); await runWrangler( @@ -469,7 +469,7 @@ describe("email routing commands", () => { expect(std.out).toContain("Updated routing rule:"); }); - it("should update the catch-all rule to drop", async () => { + it("should update the catch-all rule to drop", async ({ expect }) => { const reqProm = mockUpdateCatchAll("zone-id-1"); await runWrangler( @@ -485,7 +485,7 @@ describe("email routing commands", () => { expect(std.out).toContain("Updated catch-all rule:"); }); - it("should update the catch-all rule to forward", async () => { + it("should update the catch-all rule to forward", async ({ expect }) => { const reqProm = mockUpdateCatchAll("zone-id-1"); await runWrangler( @@ -500,7 +500,7 @@ describe("email routing commands", () => { expect(std.out).toContain("Updated catch-all rule:"); }); - it("should error when catch-all forward is used without --action-value", async () => { + it("should error when catch-all forward is used without --action-value", async ({ expect }) => { await expect( runWrangler( "email routing rules update catch-all --zone-id zone-id-1 --action-type forward" @@ -510,7 +510,7 @@ describe("email routing commands", () => { ); }); - it("should error when regular rule update is missing --match-type", async () => { + it("should error when regular rule update is missing --match-type", async ({ expect }) => { await expect( runWrangler( "email routing rules update rule-id-1 --zone-id zone-id-1 --action-type forward --action-value dest@example.com" @@ -524,7 +524,7 @@ describe("email routing commands", () => { // --- rules delete --- describe("rules delete", () => { - it("should delete a routing rule", async () => { + it("should delete a routing rule", async ({ expect }) => { mockDeleteRule("zone-id-1", "rule-id-1"); await runWrangler( @@ -538,7 +538,7 @@ describe("email routing commands", () => { // --- addresses list --- describe("addresses list", () => { - it("should list destination addresses", async () => { + it("should list destination addresses", async ({ expect }) => { mockListAddresses([mockAddress]); await runWrangler("email routing addresses list"); @@ -547,7 +547,7 @@ describe("email routing commands", () => { expect(std.out).toContain("addr-id-1"); }); - it("should handle no addresses", async () => { + it("should handle no addresses", async ({ expect }) => { mockListAddresses([]); await runWrangler("email routing addresses list"); @@ -559,7 +559,7 @@ describe("email routing commands", () => { // --- addresses get --- describe("addresses get", () => { - it("should get a destination address", async () => { + it("should get a destination address", async ({ expect }) => { mockGetAddress("addr-id-1", mockAddress); await runWrangler("email routing addresses get addr-id-1"); @@ -572,7 +572,7 @@ describe("email routing commands", () => { // --- addresses create --- describe("addresses create", () => { - it("should create a destination address", async () => { + it("should create a destination address", async ({ expect }) => { mockCreateAddress(); await runWrangler("email routing addresses create newdest@example.com"); @@ -587,7 +587,7 @@ describe("email routing commands", () => { // --- addresses delete --- describe("addresses delete", () => { - it("should delete a destination address", async () => { + it("should delete a destination address", async ({ expect }) => { mockDeleteAddress("addr-id-1"); await runWrangler("email routing addresses delete addr-id-1"); @@ -621,7 +621,7 @@ describe("email sending commands", () => { // --- subdomains list --- describe("subdomains list", () => { - it("should list sending subdomains", async () => { + it("should list sending subdomains", async ({ expect }) => { mockListSendingSubdomains("zone-id-1", [mockSubdomain]); await runWrangler( @@ -632,7 +632,7 @@ describe("email sending commands", () => { expect(std.out).toContain("yes"); }); - it("should handle no sending subdomains", async () => { + it("should handle no sending subdomains", async ({ expect }) => { mockListSendingSubdomains("zone-id-1", []); await runWrangler( @@ -646,7 +646,7 @@ describe("email sending commands", () => { // --- subdomains get --- describe("subdomains get", () => { - it("should get a sending subdomain", async () => { + it("should get a sending subdomain", async ({ expect }) => { mockGetSendingSubdomain( "zone-id-1", "aabbccdd11223344aabbccdd11223344", @@ -667,7 +667,7 @@ describe("email sending commands", () => { // --- subdomains create --- describe("subdomains create", () => { - it("should create a sending subdomain", async () => { + it("should create a sending subdomain", async ({ expect }) => { const reqProm = mockCreateSendingSubdomain("zone-id-1"); await runWrangler( @@ -685,7 +685,7 @@ describe("email sending commands", () => { // --- subdomains delete --- describe("subdomains delete", () => { - it("should delete a sending subdomain", async () => { + it("should delete a sending subdomain", async ({ expect }) => { mockDeleteSendingSubdomain( "zone-id-1", "aabbccdd11223344aabbccdd11223344" @@ -704,7 +704,7 @@ describe("email sending commands", () => { // --- dns get --- describe("dns get", () => { - it("should show sending subdomain dns records", async () => { + it("should show sending subdomain dns records", async ({ expect }) => { mockGetSendingDns( "zone-id-1", "aabbccdd11223344aabbccdd11223344", @@ -719,7 +719,7 @@ describe("email sending commands", () => { expect(std.out).toContain("v=spf1"); }); - it("should handle no dns records", async () => { + it("should handle no dns records", async ({ expect }) => { mockGetSendingDns( "zone-id-1", "aabbccdd11223344aabbccdd11223344", @@ -739,7 +739,7 @@ describe("email sending commands", () => { // --- send --- describe("send", () => { - it("should send an email with text body", async () => { + it("should send an email with text body", async ({ expect }) => { const reqProm = mockSendEmail(); await runWrangler( @@ -756,7 +756,7 @@ describe("email sending commands", () => { expect(std.out).toContain("Delivered to: recipient@example.com"); }); - it("should send an email with html body", async () => { + it("should send an email with html body", async ({ expect }) => { const reqProm = mockSendEmail(); await runWrangler( @@ -772,7 +772,7 @@ describe("email sending commands", () => { expect(std.out).toContain("Delivered to:"); }); - it("should send with from-name", async () => { + it("should send with from-name", async ({ expect }) => { const reqProm = mockSendEmail(); await runWrangler( @@ -784,7 +784,7 @@ describe("email sending commands", () => { }); }); - it("should send with cc and bcc", async () => { + it("should send with cc and bcc", async ({ expect }) => { const reqProm = mockSendEmail(); await runWrangler( @@ -797,7 +797,7 @@ describe("email sending commands", () => { }); }); - it("should send with custom headers", async () => { + it("should send with custom headers", async ({ expect }) => { const reqProm = mockSendEmail(); await runWrangler( @@ -809,7 +809,7 @@ describe("email sending commands", () => { }); }); - it("should error when neither --text nor --html is provided", async () => { + it("should error when neither --text nor --html is provided", async ({ expect }) => { await expect( runWrangler( "email sending send --from sender@example.com --to recipient@example.com --subject 'Test'" @@ -819,7 +819,7 @@ describe("email sending commands", () => { ); }); - it("should display queued and bounced recipients", async () => { + it("should display queued and bounced recipients", async ({ expect }) => { mockSendEmailWithResult({ delivered: [], queued: ["queued@example.com"], @@ -838,7 +838,7 @@ describe("email sending commands", () => { // --- send-raw --- describe("send-raw", () => { - it("should send a raw MIME email", async () => { + it("should send a raw MIME email", async ({ expect }) => { const reqProm = mockSendRawEmail(); const mimeMessage = "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Hello\r\n\r\nHello, World!"; @@ -856,7 +856,7 @@ describe("email sending commands", () => { expect(std.out).toContain("Delivered to: recipient@example.com"); }); - it("should error when neither --mime nor --mime-file is provided", async () => { + it("should error when neither --mime nor --mime-file is provided", async ({ expect }) => { await expect( runWrangler( "email sending send-raw --from sender@example.com --to recipient@example.com" @@ -999,7 +999,8 @@ function mockCreateRule(_zoneId: string): Promise { http.post( "*/zones/:zoneId/email/routing/rules", async ({ request }) => { - const reqBody = await request.json(); + const reqBody = + (await request.json()) as Record; resolve(reqBody); return HttpResponse.json( createFetchResult({ id: "new-rule-id", ...reqBody }, true) @@ -1017,7 +1018,8 @@ function mockUpdateRule(_zoneId: string, _ruleId: string): Promise { http.put( "*/zones/:zoneId/email/routing/rules/:ruleId", async ({ request }) => { - const reqBody = await request.json(); + const reqBody = + (await request.json()) as Record; resolve(reqBody); return HttpResponse.json( createFetchResult({ id: "rule-id-1", ...reqBody }, true) @@ -1059,7 +1061,8 @@ function mockUpdateCatchAll(_zoneId: string): Promise { http.put( "*/zones/:zoneId/email/routing/rules/catch_all", async ({ request }) => { - const reqBody = await request.json(); + const reqBody = + (await request.json()) as Record; resolve(reqBody); return HttpResponse.json( createFetchResult({ id: "catch-all-id", ...reqBody }, true) @@ -1171,7 +1174,8 @@ function mockCreateSendingSubdomain(_zoneId: string): Promise { http.post( "*/zones/:zoneId/email/sending/subdomains", async ({ request }) => { - const reqBody = await request.json(); + const reqBody = + (await request.json()) as Record; resolve(reqBody); return HttpResponse.json( createFetchResult({ ...mockSubdomain, ...reqBody }, true) diff --git a/packages/wrangler/src/email-routing/addresses/create.ts b/packages/wrangler/src/email-routing/addresses/create.ts index e0c5a49484..8f64191a41 100644 --- a/packages/wrangler/src/email-routing/addresses/create.ts +++ b/packages/wrangler/src/email-routing/addresses/create.ts @@ -5,7 +5,7 @@ import { createEmailRoutingAddress } from "../client"; export const emailRoutingAddressesCreateCommand = createCommand({ metadata: { description: "Create an Email Routing destination address", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/addresses/delete.ts b/packages/wrangler/src/email-routing/addresses/delete.ts index bf31a0f97b..1f107ce795 100644 --- a/packages/wrangler/src/email-routing/addresses/delete.ts +++ b/packages/wrangler/src/email-routing/addresses/delete.ts @@ -5,7 +5,7 @@ import { deleteEmailRoutingAddress } from "../client"; export const emailRoutingAddressesDeleteCommand = createCommand({ metadata: { description: "Delete an Email Routing destination address", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/addresses/get.ts b/packages/wrangler/src/email-routing/addresses/get.ts index fc6f5c7cfe..ca8b144273 100644 --- a/packages/wrangler/src/email-routing/addresses/get.ts +++ b/packages/wrangler/src/email-routing/addresses/get.ts @@ -5,7 +5,7 @@ import { getEmailRoutingAddress } from "../client"; export const emailRoutingAddressesGetCommand = createCommand({ metadata: { description: "Get a specific Email Routing destination address", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/addresses/list.ts b/packages/wrangler/src/email-routing/addresses/list.ts index dafcb623ea..be32b58a49 100644 --- a/packages/wrangler/src/email-routing/addresses/list.ts +++ b/packages/wrangler/src/email-routing/addresses/list.ts @@ -5,7 +5,7 @@ import { listEmailRoutingAddresses } from "../client"; export const emailRoutingAddressesListCommand = createCommand({ metadata: { description: "List Email Routing destination addresses", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: {}, diff --git a/packages/wrangler/src/email-routing/disable.ts b/packages/wrangler/src/email-routing/disable.ts index de6ec60f51..96997b5433 100644 --- a/packages/wrangler/src/email-routing/disable.ts +++ b/packages/wrangler/src/email-routing/disable.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "./utils"; export const emailRoutingDisableCommand = createCommand({ metadata: { description: "Disable Email Routing for a zone", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/dns-get.ts b/packages/wrangler/src/email-routing/dns-get.ts index 3075ed1365..ae6a29bfb5 100644 --- a/packages/wrangler/src/email-routing/dns-get.ts +++ b/packages/wrangler/src/email-routing/dns-get.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "./utils"; export const emailRoutingDnsGetCommand = createCommand({ metadata: { description: "Show DNS records required for Email Routing", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/dns-unlock.ts b/packages/wrangler/src/email-routing/dns-unlock.ts index a8b8b6e49f..6688a28dd0 100644 --- a/packages/wrangler/src/email-routing/dns-unlock.ts +++ b/packages/wrangler/src/email-routing/dns-unlock.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "./utils"; export const emailRoutingDnsUnlockCommand = createCommand({ metadata: { description: "Unlock MX records for Email Routing", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/enable.ts b/packages/wrangler/src/email-routing/enable.ts index 9cabc28b7e..2391239eff 100644 --- a/packages/wrangler/src/email-routing/enable.ts +++ b/packages/wrangler/src/email-routing/enable.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "./utils"; export const emailRoutingEnableCommand = createCommand({ metadata: { description: "Enable Email Routing for a zone", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/index.ts b/packages/wrangler/src/email-routing/index.ts index 1a1c140408..4a53bff752 100644 --- a/packages/wrangler/src/email-routing/index.ts +++ b/packages/wrangler/src/email-routing/index.ts @@ -3,7 +3,7 @@ import { createNamespace } from "../core/create-command"; export const emailNamespace = createNamespace({ metadata: { description: "Manage Cloudflare Email services", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, }); @@ -11,7 +11,7 @@ export const emailNamespace = createNamespace({ export const emailRoutingNamespace = createNamespace({ metadata: { description: "Manage Email Routing", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, }); @@ -19,7 +19,7 @@ export const emailRoutingNamespace = createNamespace({ export const emailRoutingDnsNamespace = createNamespace({ metadata: { description: "Manage Email Routing DNS settings", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, }); @@ -27,7 +27,7 @@ export const emailRoutingDnsNamespace = createNamespace({ export const emailRoutingRulesNamespace = createNamespace({ metadata: { description: "Manage Email Routing rules", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, }); @@ -35,7 +35,7 @@ export const emailRoutingRulesNamespace = createNamespace({ export const emailRoutingAddressesNamespace = createNamespace({ metadata: { description: "Manage Email Routing destination addresses", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, }); @@ -43,7 +43,7 @@ export const emailRoutingAddressesNamespace = createNamespace({ export const emailSendingNamespace = createNamespace({ metadata: { description: "Manage Email Sending", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, }); @@ -51,7 +51,7 @@ export const emailSendingNamespace = createNamespace({ export const emailSendingSubdomainsNamespace = createNamespace({ metadata: { description: "Manage Email Sending subdomains", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, }); @@ -59,7 +59,7 @@ export const emailSendingSubdomainsNamespace = createNamespace({ export const emailSendingDnsNamespace = createNamespace({ metadata: { description: "Manage Email Sending DNS records", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, }); diff --git a/packages/wrangler/src/email-routing/list.ts b/packages/wrangler/src/email-routing/list.ts index 9dea8a222f..cf10f36190 100644 --- a/packages/wrangler/src/email-routing/list.ts +++ b/packages/wrangler/src/email-routing/list.ts @@ -15,7 +15,7 @@ const NOT_CONFIGURED_CODES = new Set([ export const emailRoutingListCommand = createCommand({ metadata: { description: "List zones with Email Routing", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: {}, diff --git a/packages/wrangler/src/email-routing/rules/create.ts b/packages/wrangler/src/email-routing/rules/create.ts index 53be833f5d..1a0368fc67 100644 --- a/packages/wrangler/src/email-routing/rules/create.ts +++ b/packages/wrangler/src/email-routing/rules/create.ts @@ -8,7 +8,7 @@ import { resolveZoneId } from "../utils"; export const emailRoutingRulesCreateCommand = createCommand({ metadata: { description: "Create an Email Routing rule", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/rules/delete.ts b/packages/wrangler/src/email-routing/rules/delete.ts index 12b6e7a908..341455cbdf 100644 --- a/packages/wrangler/src/email-routing/rules/delete.ts +++ b/packages/wrangler/src/email-routing/rules/delete.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "../utils"; export const emailRoutingRulesDeleteCommand = createCommand({ metadata: { description: "Delete an Email Routing rule", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/rules/get.ts b/packages/wrangler/src/email-routing/rules/get.ts index b54616fe65..9cee24d9fe 100644 --- a/packages/wrangler/src/email-routing/rules/get.ts +++ b/packages/wrangler/src/email-routing/rules/get.ts @@ -8,7 +8,7 @@ export const emailRoutingRulesGetCommand = createCommand({ metadata: { description: "Get a specific Email Routing rule (use 'catch-all' as the rule ID to get the catch-all rule)", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/rules/list.ts b/packages/wrangler/src/email-routing/rules/list.ts index d87612043d..7a13d2105f 100644 --- a/packages/wrangler/src/email-routing/rules/list.ts +++ b/packages/wrangler/src/email-routing/rules/list.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "../utils"; export const emailRoutingRulesListCommand = createCommand({ metadata: { description: "List Email Routing rules", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/rules/update.ts b/packages/wrangler/src/email-routing/rules/update.ts index 2b81afbd5c..c35681f14d 100644 --- a/packages/wrangler/src/email-routing/rules/update.ts +++ b/packages/wrangler/src/email-routing/rules/update.ts @@ -9,7 +9,7 @@ export const emailRoutingRulesUpdateCommand = createCommand({ metadata: { description: "Update an Email Routing rule (use 'catch-all' as the rule ID to update the catch-all rule)", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/sending/dns-get.ts b/packages/wrangler/src/email-routing/sending/dns-get.ts index 07a7ec7e50..7dc31a6fa3 100644 --- a/packages/wrangler/src/email-routing/sending/dns-get.ts +++ b/packages/wrangler/src/email-routing/sending/dns-get.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "../utils"; export const emailSendingDnsGetCommand = createCommand({ metadata: { description: "Get DNS records for an Email Sending subdomain", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/sending/send-raw.ts b/packages/wrangler/src/email-routing/sending/send-raw.ts index e8ea13af15..198ea90c81 100644 --- a/packages/wrangler/src/email-routing/sending/send-raw.ts +++ b/packages/wrangler/src/email-routing/sending/send-raw.ts @@ -7,7 +7,7 @@ import { sendRawEmail } from "../client"; export const emailSendingSendRawCommand = createCommand({ metadata: { description: "Send a raw MIME email message", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/sending/send.ts b/packages/wrangler/src/email-routing/sending/send.ts index e14795d9fe..1185bb3adb 100644 --- a/packages/wrangler/src/email-routing/sending/send.ts +++ b/packages/wrangler/src/email-routing/sending/send.ts @@ -8,7 +8,7 @@ import { sendEmail } from "../client"; export const emailSendingSendCommand = createCommand({ metadata: { description: "Send an email using the Email Sending builder", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/sending/subdomains/create.ts b/packages/wrangler/src/email-routing/sending/subdomains/create.ts index 86a720387f..46eaf9564d 100644 --- a/packages/wrangler/src/email-routing/sending/subdomains/create.ts +++ b/packages/wrangler/src/email-routing/sending/subdomains/create.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "../../utils"; export const emailSendingSubdomainsCreateCommand = createCommand({ metadata: { description: "Create an Email Sending subdomain", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/sending/subdomains/delete.ts b/packages/wrangler/src/email-routing/sending/subdomains/delete.ts index e36b8235c4..e15084e84f 100644 --- a/packages/wrangler/src/email-routing/sending/subdomains/delete.ts +++ b/packages/wrangler/src/email-routing/sending/subdomains/delete.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "../../utils"; export const emailSendingSubdomainsDeleteCommand = createCommand({ metadata: { description: "Delete an Email Sending subdomain", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/sending/subdomains/get.ts b/packages/wrangler/src/email-routing/sending/subdomains/get.ts index 8571803507..317b8c43f9 100644 --- a/packages/wrangler/src/email-routing/sending/subdomains/get.ts +++ b/packages/wrangler/src/email-routing/sending/subdomains/get.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "../../utils"; export const emailSendingSubdomainsGetCommand = createCommand({ metadata: { description: "Get a specific Email Sending subdomain", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/sending/subdomains/list.ts b/packages/wrangler/src/email-routing/sending/subdomains/list.ts index 3ec84bcc17..fd22025530 100644 --- a/packages/wrangler/src/email-routing/sending/subdomains/list.ts +++ b/packages/wrangler/src/email-routing/sending/subdomains/list.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "../../utils"; export const emailSendingSubdomainsListCommand = createCommand({ metadata: { description: "List Email Sending subdomains", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/settings.ts b/packages/wrangler/src/email-routing/settings.ts index ea6e3da109..d89c147f63 100644 --- a/packages/wrangler/src/email-routing/settings.ts +++ b/packages/wrangler/src/email-routing/settings.ts @@ -7,7 +7,7 @@ import { resolveZoneId } from "./utils"; export const emailRoutingSettingsCommand = createCommand({ metadata: { description: "Get Email Routing settings for a zone", - status: "open-beta", + status: "open beta", owner: "Product: Email Service", }, args: { diff --git a/packages/wrangler/src/email-routing/utils.ts b/packages/wrangler/src/email-routing/utils.ts index c9a3974b7e..5ff3f3edf6 100644 --- a/packages/wrangler/src/email-routing/utils.ts +++ b/packages/wrangler/src/email-routing/utils.ts @@ -2,8 +2,7 @@ import { UserError } from "@cloudflare/workers-utils"; import { fetchListResult } from "../cfetch"; import { requireAuth } from "../user"; import { retryOnAPIFailure } from "../utils/retry"; -import type { ComplianceConfig } from "../environment-variables/misc-variables"; -import type { Config } from "@cloudflare/workers-utils"; +import type { ComplianceConfig, Config } from "@cloudflare/workers-utils"; /** * Resolve a zone ID from either --zone (domain name) or --zone-id (direct ID). From b19c5889910532610f3e53c2cf84125befa3bfde Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 26 Mar 2026 16:39:46 -0400 Subject: [PATCH 07/32] fix: use account-scoped email routing zones endpoint for list command - Use /accounts/{accountId}/email/routing/zones instead of fetching /zones then N separate /zones/{id}/email/routing calls. This matches the dashboard behavior and is more efficient (1 API call vs N+1). - Add order/direction query params to rules list and addresses list to match dashboard API calls. - Add VS Code debug launch configs for email routing commands. --- .vscode/launch.json | 98 +++++++++++++++++++ packages/wrangler/src/email-routing/client.ts | 18 +++- packages/wrangler/src/email-routing/list.ts | 84 ++-------------- 3 files changed, 123 insertions(+), 77 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 8f054e1043..b023e14c56 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,104 @@ { "version": "0.2.0", "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug: email routing list", + "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", + "args": ["email", "routing", "list"], + "env": { + "WRANGLER_API_ENVIRONMENT": "staging", + "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" + }, + "cwd": "${workspaceFolder}/packages/wrangler", + "console": "integratedTerminal", + "skipFiles": ["/**"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug: email routing settings", + "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", + "args": ["email", "routing", "settings", "--zone-id", "faeeba7abcba046b44286aa53c2fcc82"], + "env": { + "WRANGLER_API_ENVIRONMENT": "staging", + "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" + }, + "cwd": "${workspaceFolder}/packages/wrangler", + "console": "integratedTerminal", + "skipFiles": ["/**"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug: email routing enable", + "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", + "args": ["email", "routing", "enable", "--zone-id", "faeeba7abcba046b44286aa53c2fcc82"], + "env": { + "WRANGLER_API_ENVIRONMENT": "staging", + "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" + }, + "cwd": "${workspaceFolder}/packages/wrangler", + "console": "integratedTerminal", + "skipFiles": ["/**"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug: email routing rules list", + "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", + "args": ["email", "routing", "rules", "list", "--zone-id", "faeeba7abcba046b44286aa53c2fcc82"], + "env": { + "WRANGLER_API_ENVIRONMENT": "staging", + "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" + }, + "cwd": "${workspaceFolder}/packages/wrangler", + "console": "integratedTerminal", + "skipFiles": ["/**"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug: email routing addresses list", + "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", + "args": ["email", "routing", "addresses", "list"], + "env": { + "WRANGLER_API_ENVIRONMENT": "staging", + "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" + }, + "cwd": "${workspaceFolder}/packages/wrangler", + "console": "integratedTerminal", + "skipFiles": ["/**"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug: email routing dns get", + "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", + "args": ["email", "routing", "dns", "get", "--zone-id", "faeeba7abcba046b44286aa53c2fcc82"], + "env": { + "WRANGLER_API_ENVIRONMENT": "staging", + "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" + }, + "cwd": "${workspaceFolder}/packages/wrangler", + "console": "integratedTerminal", + "skipFiles": ["/**"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug: email routing catch-all get", + "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", + "args": ["email", "routing", "rules", "get", "catch-all", "--zone-id", "faeeba7abcba046b44286aa53c2fcc82"], + "env": { + "WRANGLER_API_ENVIRONMENT": "staging", + "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" + }, + "cwd": "${workspaceFolder}/packages/wrangler", + "console": "integratedTerminal", + "skipFiles": ["/**"] + }, { "name": "Debug Current Test File", "type": "node", diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index d0fedbfd78..089d6461be 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -25,6 +25,16 @@ export async function listZones(config: Config): Promise { ); } +export async function listEmailRoutingZones( + config: Config +): Promise { + const accountId = await requireAuth(config); + return await fetchPagedListResult( + config, + `/accounts/${accountId}/email/routing/zones` + ); +} + // --- Settings --- export async function getEmailRoutingSettings( @@ -106,7 +116,9 @@ export async function listEmailRoutingRules( await requireAuth(config); return await fetchPagedListResult( config, - `/zones/${zoneId}/email/routing/rules` + `/zones/${zoneId}/email/routing/rules`, + {}, + new URLSearchParams({ order: "created", direction: "asc" }) ); } @@ -227,7 +239,9 @@ export async function listEmailRoutingAddresses( const accountId = await requireAuth(config); return await fetchPagedListResult( config, - `/accounts/${accountId}/email/routing/addresses` + `/accounts/${accountId}/email/routing/addresses`, + {}, + new URLSearchParams({ order: "created", direction: "asc" }) ); } diff --git a/packages/wrangler/src/email-routing/list.ts b/packages/wrangler/src/email-routing/list.ts index cf10f36190..b236ebe744 100644 --- a/packages/wrangler/src/email-routing/list.ts +++ b/packages/wrangler/src/email-routing/list.ts @@ -1,16 +1,6 @@ -import { APIError } from "@cloudflare/workers-utils"; import { createCommand } from "../core/create-command"; import { logger } from "../logger"; -import { getEmailRoutingSettings, listZones } from "./client"; - -const CONCURRENCY_LIMIT = 5; - -// Error codes that indicate email routing is not configured for this zone -// rather than a real API failure. -const NOT_CONFIGURED_CODES = new Set([ - 1000, // not found - 1001, // unknown zone -]); +import { listEmailRoutingZones } from "./client"; export const emailRoutingListCommand = createCommand({ metadata: { @@ -20,76 +10,20 @@ export const emailRoutingListCommand = createCommand({ }, args: {}, async handler(_args, { config }) { - const zones = await listZones(config); + const zones = await listEmailRoutingZones(config); if (zones.length === 0) { - logger.log("No zones found in this account."); + logger.log("No zones found with Email Routing in this account."); return; } - // Fetch settings concurrently with a concurrency limit to avoid rate limiting - const results: { - zone: string; - "zone id": string; - enabled: string; - status: string; - }[] = []; - - let firstError: unknown = null; - - for (let i = 0; i < zones.length; i += CONCURRENCY_LIMIT) { - const batch = zones.slice(i, i + CONCURRENCY_LIMIT); - const batchResults = await Promise.all( - batch.map(async (zone) => { - try { - const settings = await getEmailRoutingSettings(config, zone.id); - return { - zone: zone.name, - "zone id": zone.id, - enabled: settings.enabled ? "yes" : "no", - status: settings.status, - }; - } catch (e) { - // Distinguish "not configured" from real API errors - if ( - e instanceof APIError && - e.code !== undefined && - NOT_CONFIGURED_CODES.has(e.code) - ) { - return { - zone: zone.name, - "zone id": zone.id, - enabled: "no", - status: "not configured", - }; - } - // Real error — log it and mark this zone as errored - logger.debug( - `Failed to get email routing settings for zone ${zone.name}: ${e}` - ); - if (!firstError) { - firstError = e; - } - return { - zone: zone.name, - "zone id": zone.id, - enabled: "error", - status: - e instanceof APIError ? `API error (code ${e.code})` : "error", - }; - } - }) - ); - results.push(...batchResults); - } + const results = zones.map((zone) => ({ + zone: zone.name, + "zone id": zone.id, + enabled: zone.enabled ? "yes" : "no", + status: zone.status, + })); logger.table(results); - - if (firstError) { - logger.warn( - `\nFailed to fetch email routing settings for some zones. This may be a permissions issue — ensure your API token has the "Email Routing" read permission.` - ); - logger.debug(`First error: ${firstError}`); - } }, }); From 0d3e24d47ff4c44b92cc6ee1c3161049bc830fab Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 26 Mar 2026 17:24:25 -0400 Subject: [PATCH 08/32] fix: align email routing API endpoints with dashboard implementation - Enable: use POST /zones/{id}/email/routing/enable (was POST /dns) - Disable: use POST /zones/{id}/email/routing/disable (was DELETE /dns) - Unlock: use POST /zones/{id}/email/routing/unlock (was PATCH /dns) - Update tests to mock the correct endpoints - Add mockListEmailRoutingZones helper for list tests - Replace "not configured" and "error" per-zone tests with "disabled" test matching the single-endpoint list implementation - Align with Stratus dashboard API calls per code review --- .../src/__tests__/email-routing.test.ts | 95 ++++++++----------- packages/wrangler/src/email-routing/client.ts | 16 ++-- .../wrangler/src/email-routing/disable.ts | 6 +- 3 files changed, 50 insertions(+), 67 deletions(-) diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index 2c7e06df3d..801397626e 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -178,8 +178,7 @@ describe("email routing commands", () => { describe("list", () => { it("should list zones with email routing status", async ({ expect }) => { - mockListZones([mockZone]); - mockGetSettings(mockZone.id, mockSettings); + mockListEmailRoutingZones([mockSettings]); await runWrangler("email routing list"); @@ -188,63 +187,25 @@ describe("email routing commands", () => { }); it("should handle no zones", async ({ expect }) => { - mockListZones([]); + mockListEmailRoutingZones([]); await runWrangler("email routing list"); - expect(std.out).toContain("No zones found in this account."); - }); - - it("should show 'not configured' for zones where email routing is not set up", async ({ expect }) => { - mockListZones([mockZone]); - msw.use( - http.get( - "*/zones/:zoneId/email/routing", - () => { - return HttpResponse.json( - createFetchResult(null, false, [ - { - code: 1000, - message: "not found", - }, - ]) - ); - }, - { once: true } - ) + expect(std.out).toContain( + "No zones found with Email Routing in this account." ); - - await runWrangler("email routing list"); - - expect(std.out).toContain("example.com"); - expect(std.out).toContain("no"); - expect(std.out).toContain("not configured"); }); - it("should show 'error' and warn for real API failures", async ({ expect }) => { - mockListZones([mockZone]); - msw.use( - http.get( - "*/zones/:zoneId/email/routing", - () => { - return HttpResponse.json( - createFetchResult(null, false, [ - { - code: 10000, - message: "Authentication error", - }, - ]) - ); - }, - { once: true } - ) - ); + it("should show disabled zones", async ({ expect }) => { + mockListEmailRoutingZones([ + { ...mockSettings, enabled: false, status: "disabled" }, + ]); await runWrangler("email routing list"); expect(std.out).toContain("example.com"); - expect(std.out).toContain("error"); - expect(std.warn).toContain("Failed to fetch email routing settings"); + expect(std.out).toContain("no"); + expect(std.out).toContain("disabled"); }); }); @@ -322,11 +283,14 @@ describe("email routing commands", () => { describe("disable", () => { it("should disable email routing", async ({ expect }) => { - mockDisableEmailRouting("zone-id-1"); + mockDisableEmailRouting("zone-id-1", { + ...mockSettings, + enabled: false, + }); await runWrangler("email routing disable --zone-id zone-id-1"); - expect(std.out).toContain("Email Routing disabled for zone zone-id-1"); + expect(std.out).toContain("Email Routing disabled"); }); }); @@ -870,6 +834,18 @@ describe("email sending commands", () => { // --- Mock API handlers: Email Routing --- +function mockListEmailRoutingZones(settings: (typeof mockSettings)[]) { + msw.use( + http.get( + "*/accounts/:accountId/email/routing/zones", + () => { + return HttpResponse.json(createFetchResult(settings, true)); + }, + { once: true } + ) + ); +} + function mockListZones( zones: Array<{ id: string; @@ -924,7 +900,7 @@ function mockEnableEmailRouting( ) { msw.use( http.post( - "*/zones/:zoneId/email/routing/dns", + "*/zones/:zoneId/email/routing/enable", () => { return HttpResponse.json(createFetchResult(settings, true)); }, @@ -933,12 +909,15 @@ function mockEnableEmailRouting( ); } -function mockDisableEmailRouting(_zoneId: string) { +function mockDisableEmailRouting( + _zoneId: string, + settings: typeof mockSettings +) { msw.use( - http.delete( - "*/zones/:zoneId/email/routing/dns", + http.post( + "*/zones/:zoneId/email/routing/disable", () => { - return HttpResponse.json(createFetchResult([], true)); + return HttpResponse.json(createFetchResult(settings, true)); }, { once: true } ) @@ -959,8 +938,8 @@ function mockGetDns(_zoneId: string, records: typeof mockDnsRecords) { function mockUnlockDns(_zoneId: string, settings: typeof mockSettings) { msw.use( - http.patch( - "*/zones/:zoneId/email/routing/dns", + http.post( + "*/zones/:zoneId/email/routing/unlock", () => { return HttpResponse.json(createFetchResult(settings, true)); }, diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index 089d6461be..cf8ce50b08 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -57,7 +57,7 @@ export async function enableEmailRouting( await requireAuth(config); return await fetchResult( config, - `/zones/${zoneId}/email/routing/dns`, + `/zones/${zoneId}/email/routing/enable`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -69,13 +69,15 @@ export async function enableEmailRouting( export async function disableEmailRouting( config: Config, zoneId: string -): Promise { +): Promise { await requireAuth(config); - return await fetchResult( + return await fetchResult( config, - `/zones/${zoneId}/email/routing/dns`, + `/zones/${zoneId}/email/routing/disable`, { - method: "DELETE", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), } ); } @@ -98,9 +100,9 @@ export async function unlockEmailRoutingDns( await requireAuth(config); return await fetchResult( config, - `/zones/${zoneId}/email/routing/dns`, + `/zones/${zoneId}/email/routing/unlock`, { - method: "PATCH", + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), } diff --git a/packages/wrangler/src/email-routing/disable.ts b/packages/wrangler/src/email-routing/disable.ts index 96997b5433..ff6c527e88 100644 --- a/packages/wrangler/src/email-routing/disable.ts +++ b/packages/wrangler/src/email-routing/disable.ts @@ -15,8 +15,10 @@ export const emailRoutingDisableCommand = createCommand({ }, async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); - await disableEmailRouting(config, zoneId); + const settings = await disableEmailRouting(config, zoneId); - logger.log(`Email Routing disabled for zone ${zoneId}.`); + logger.log( + `Email Routing disabled for ${settings.name} (status: ${settings.status})` + ); }, }); From 8eae00934dd9d395ef5340bc6fda582fbb9407a7 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Tue, 31 Mar 2026 19:06:18 -0400 Subject: [PATCH 09/32] fix: handle non-standard API error envelopes and fix dns-unlock output - Guard against undefined response.errors in throwFetchError (fixes crash on APIs returning {code, error} instead of {errors: [...]}) - Handle messages as objects, not just strings (fixes esbuild formatMessages crash when API returns {code, message} objects in messages array) - Surface non-standard error details (e.g. 'Unauthorized [code: 2036]') when standard errors array is empty - Fix dns-unlock showing 'status: undefined' by using 'enabled' field which the PATCH /email/routing/dns endpoint actually returns --- packages/wrangler/src/cfetch/index.ts | 31 ++++++++++++++----- .../wrangler/src/email-routing/dns-unlock.ts | 2 +- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/wrangler/src/cfetch/index.ts b/packages/wrangler/src/cfetch/index.ts index b4b55db190..4795e6918e 100644 --- a/packages/wrangler/src/cfetch/index.ts +++ b/packages/wrangler/src/cfetch/index.ts @@ -14,7 +14,7 @@ export interface FetchResult { success: boolean; result: ResponseType; errors: FetchError[]; - messages?: string[]; + messages?: (string | { code?: number; message: string })[]; result_info?: unknown; } @@ -216,21 +216,38 @@ function throwFetchError( if (typeof vitest !== "undefined" && !("errors" in response)) { throw response; } - for (const error of response.errors) { + const errors = response.errors ?? []; + for (const error of errors) { maybeThrowFriendlyError(error); } + // Some API endpoints return non-standard error envelopes (e.g. {code, error} + // instead of {errors: [...]}). Surface those as notes when errors is empty. + const notes = [ + ...errors.map((err) => ({ text: renderError(err) })), + ...(response.messages?.map((msg) => ({ + text: typeof msg === "string" ? msg : msg.message ?? String(msg), + })) ?? []), + ]; + if (notes.length === 0) { + const raw = response as unknown as Record; + const fallbackMessage = + typeof raw.error === "string" + ? `${raw.error}${raw.code ? ` [code: ${raw.code}]` : ""}` + : undefined; + if (fallbackMessage) { + notes.push({ text: fallbackMessage }); + } + } + const error = new APIError({ text: `A request to the Cloudflare API (${resource}) failed.`, - notes: [ - ...response.errors.map((err) => ({ text: renderError(err) })), - ...(response.messages?.map((text) => ({ text })) ?? []), - ], + notes, status, }); // add the first error code directly to this error // so consumers can use it for specific behaviour - const code = response.errors[0]?.code; + const code = errors[0]?.code; if (code) { error.code = code; } diff --git a/packages/wrangler/src/email-routing/dns-unlock.ts b/packages/wrangler/src/email-routing/dns-unlock.ts index 6688a28dd0..53f134ef75 100644 --- a/packages/wrangler/src/email-routing/dns-unlock.ts +++ b/packages/wrangler/src/email-routing/dns-unlock.ts @@ -18,7 +18,7 @@ export const emailRoutingDnsUnlockCommand = createCommand({ const settings = await unlockEmailRoutingDns(config, zoneId); logger.log( - `MX records unlocked for ${settings.name} (status: ${settings.status})` + `MX records unlocked for ${settings.name} (enabled: ${settings.enabled})` ); }, }); From effeda9bfd7587760c8e4541e5aa8e3cec20f69a Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Tue, 31 Mar 2026 20:24:39 -0400 Subject: [PATCH 10/32] fix: improve email routing CLI output and catch-all rule handling - Switch DNS record display from table to vertical format (DKIM keys made tables unreadable) - Remove empty status column from zone list (API doesn't return it) - Separate catch-all from regular rules in rules list with usage hint - Fallback to catch-all endpoint when rules get receives error 2020 --- .../wrangler/src/email-routing/dns-get.ts | 19 ++++--- packages/wrangler/src/email-routing/list.ts | 1 - .../wrangler/src/email-routing/rules/get.ts | 30 +++++++++- .../wrangler/src/email-routing/rules/list.ts | 55 +++++++++++++------ .../src/email-routing/sending/dns-get.ts | 21 ++++--- 5 files changed, 90 insertions(+), 36 deletions(-) diff --git a/packages/wrangler/src/email-routing/dns-get.ts b/packages/wrangler/src/email-routing/dns-get.ts index ae6a29bfb5..18de657d50 100644 --- a/packages/wrangler/src/email-routing/dns-get.ts +++ b/packages/wrangler/src/email-routing/dns-get.ts @@ -22,14 +22,15 @@ export const emailRoutingDnsGetCommand = createCommand({ return; } - logger.table( - records.map((r) => ({ - type: r.type, - name: r.name, - content: r.content, - priority: r.priority !== undefined ? String(r.priority) : "", - ttl: String(r.ttl), - })) - ); + for (const r of records) { + logger.log(`${r.type} record:`); + logger.log(` Name: ${r.name}`); + logger.log(` Content: ${r.content}`); + if (r.priority !== undefined) { + logger.log(` Priority: ${r.priority}`); + } + logger.log(` TTL: ${r.ttl}`); + logger.log(""); + } }, }); diff --git a/packages/wrangler/src/email-routing/list.ts b/packages/wrangler/src/email-routing/list.ts index b236ebe744..1e78a0c67e 100644 --- a/packages/wrangler/src/email-routing/list.ts +++ b/packages/wrangler/src/email-routing/list.ts @@ -21,7 +21,6 @@ export const emailRoutingListCommand = createCommand({ zone: zone.name, "zone id": zone.id, enabled: zone.enabled ? "yes" : "no", - status: zone.status, })); logger.table(results); diff --git a/packages/wrangler/src/email-routing/rules/get.ts b/packages/wrangler/src/email-routing/rules/get.ts index 9cee24d9fe..e3388f6d40 100644 --- a/packages/wrangler/src/email-routing/rules/get.ts +++ b/packages/wrangler/src/email-routing/rules/get.ts @@ -1,3 +1,4 @@ +import { APIError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { getEmailRoutingCatchAll, getEmailRoutingRule } from "../client"; @@ -40,7 +41,34 @@ export const emailRoutingRulesGetCommand = createCommand({ return; } - const rule = await getEmailRoutingRule(config, zoneId, args.ruleId); + let rule; + try { + rule = await getEmailRoutingRule(config, zoneId, args.ruleId); + } catch (e) { + // The catch-all rule appears in the rules list but can only be + // fetched via the dedicated catch-all endpoint. If the regular + // endpoint returns "Invalid rule operation" (code 2020), try the + // catch-all endpoint before giving up. + if (!(e instanceof APIError && e.code === 2020)) { + throw e; + } + + const catchAllRule = await getEmailRoutingCatchAll(config, zoneId); + if (catchAllRule.tag === args.ruleId) { + logger.log(`Catch-all rule:`); + logger.log(` Enabled: ${catchAllRule.enabled}`); + logger.log(` Actions:`); + for (const a of catchAllRule.actions) { + if (a.value && a.value.length > 0) { + logger.log(` - ${a.type}: ${a.value.join(", ")}`); + } else { + logger.log(` - ${a.type}`); + } + } + return; + } + throw e; + } logger.log(`Rule: ${rule.id}`); logger.log(` Name: ${rule.name || "(none)"}`); diff --git a/packages/wrangler/src/email-routing/rules/list.ts b/packages/wrangler/src/email-routing/rules/list.ts index 7a13d2105f..0dbf17caa7 100644 --- a/packages/wrangler/src/email-routing/rules/list.ts +++ b/packages/wrangler/src/email-routing/rules/list.ts @@ -17,24 +17,47 @@ export const emailRoutingRulesListCommand = createCommand({ const zoneId = await resolveZoneId(config, args); const rules = await listEmailRoutingRules(config, zoneId); - if (rules.length === 0) { + const catchAll = rules.find((r) => + r.matchers.some((m) => m.type === "all") + ); + const regularRules = rules.filter( + (r) => !r.matchers.some((m) => m.type === "all") + ); + + if (regularRules.length === 0) { logger.log("No routing rules found."); - return; + } else { + logger.table( + regularRules.map((r) => ({ + id: r.id, + name: r.name || "", + enabled: r.enabled ? "yes" : "no", + matchers: r.matchers + .map((m) => + m.field && m.value ? `${m.field}:${m.value}` : m.type + ) + .join(", "), + actions: r.actions + .map((a) => + a.value ? `${a.type}:${a.value.join(",")}` : a.type + ) + .join(", "), + priority: String(r.priority), + })) + ); } - logger.table( - rules.map((r) => ({ - id: r.id, - name: r.name || "", - enabled: r.enabled ? "yes" : "no", - matchers: r.matchers - .map((m) => (m.field && m.value ? `${m.field}:${m.value}` : m.type)) - .join(", "), - actions: r.actions - .map((a) => (a.value ? `${a.type}:${a.value.join(",")}` : a.type)) - .join(", "), - priority: String(r.priority), - })) - ); + if (catchAll) { + const actions = catchAll.actions + .map((a) => (a.value ? `${a.type}:${a.value.join(",")}` : a.type)) + .join(", "); + logger.log(""); + logger.log( + `Catch-all rule: ${catchAll.enabled ? "enabled" : "disabled"}, action: ${actions}` + ); + logger.log( + ` (use \`wrangler email routing rules get catch-all\` to view details)` + ); + } }, }); diff --git a/packages/wrangler/src/email-routing/sending/dns-get.ts b/packages/wrangler/src/email-routing/sending/dns-get.ts index 7dc31a6fa3..7d9198c006 100644 --- a/packages/wrangler/src/email-routing/sending/dns-get.ts +++ b/packages/wrangler/src/email-routing/sending/dns-get.ts @@ -32,14 +32,17 @@ export const emailSendingDnsGetCommand = createCommand({ return; } - logger.table( - records.map((r) => ({ - type: r.type || "", - name: r.name || "", - content: r.content || "", - priority: r.priority !== undefined ? String(r.priority) : "", - ttl: r.ttl !== undefined ? String(r.ttl) : "", - })) - ); + for (const r of records) { + logger.log(`${r.type || "DNS"} record:`); + logger.log(` Name: ${r.name || ""}`); + logger.log(` Content: ${r.content || ""}`); + if (r.priority !== undefined) { + logger.log(` Priority: ${r.priority}`); + } + if (r.ttl !== undefined) { + logger.log(` TTL: ${r.ttl}`); + } + logger.log(""); + } }, }); From 4434e45a8a108bc4b96e627b84028866df28db04 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 17:19:45 -0400 Subject: [PATCH 11/32] fix(wrangler): show success message for email send/send-raw commands When the API returns empty delivered/queued/bounced arrays, the commands previously printed nothing. Now shows 'Email sent successfully.' as feedback. Also adds emoji prefixes to delivery status output. --- .../wrangler/src/email-routing/sending/send-raw.ts | 11 +++++++++-- packages/wrangler/src/email-routing/sending/send.ts | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/wrangler/src/email-routing/sending/send-raw.ts b/packages/wrangler/src/email-routing/sending/send-raw.ts index 198ea90c81..cad1be84d7 100644 --- a/packages/wrangler/src/email-routing/sending/send-raw.ts +++ b/packages/wrangler/src/email-routing/sending/send-raw.ts @@ -58,15 +58,22 @@ export const emailSendingSendRawCommand = createCommand({ }); if (result.delivered.length > 0) { - logger.log(`Delivered to: ${result.delivered.join(", ")}`); + logger.log(`✅ Delivered to: ${result.delivered.join(", ")}`); } if (result.queued.length > 0) { - logger.log(`Queued for: ${result.queued.join(", ")}`); + logger.log(`📬 Queued for: ${result.queued.join(", ")}`); } if (result.permanent_bounces.length > 0) { logger.warn( `Permanently bounced: ${result.permanent_bounces.join(", ")}` ); } + if ( + result.delivered.length === 0 && + result.queued.length === 0 && + result.permanent_bounces.length === 0 + ) { + logger.log("✅ Email sent successfully."); + } }, }); diff --git a/packages/wrangler/src/email-routing/sending/send.ts b/packages/wrangler/src/email-routing/sending/send.ts index 1185bb3adb..340cb8a566 100644 --- a/packages/wrangler/src/email-routing/sending/send.ts +++ b/packages/wrangler/src/email-routing/sending/send.ts @@ -106,16 +106,23 @@ export const emailSendingSendCommand = createCommand({ }); if (result.delivered.length > 0) { - logger.log(`Delivered to: ${result.delivered.join(", ")}`); + logger.log(`✅ Delivered to: ${result.delivered.join(", ")}`); } if (result.queued.length > 0) { - logger.log(`Queued for: ${result.queued.join(", ")}`); + logger.log(`📬 Queued for: ${result.queued.join(", ")}`); } if (result.permanent_bounces.length > 0) { logger.warn( `Permanently bounced: ${result.permanent_bounces.join(", ")}` ); } + if ( + result.delivered.length === 0 && + result.queued.length === 0 && + result.permanent_bounces.length === 0 + ) { + logger.log("✅ Email sent successfully."); + } }, }); From 8b0e7a3c092e65e98b64ff0511df019cde2dbc89 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 17:32:45 -0400 Subject: [PATCH 12/32] fix(wrangler): add status column to routing list and improve error handling - Add missing status column to email routing list table output - Wrap readFileSync in try/catch for --attachment and --mime-file flags to produce friendly UserError messages instead of raw ENOENT errors --- packages/wrangler/src/email-routing/list.ts | 1 + packages/wrangler/src/email-routing/sending/send-raw.ts | 8 +++++++- packages/wrangler/src/email-routing/sending/send.ts | 9 ++++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/wrangler/src/email-routing/list.ts b/packages/wrangler/src/email-routing/list.ts index 1e78a0c67e..b236ebe744 100644 --- a/packages/wrangler/src/email-routing/list.ts +++ b/packages/wrangler/src/email-routing/list.ts @@ -21,6 +21,7 @@ export const emailRoutingListCommand = createCommand({ zone: zone.name, "zone id": zone.id, enabled: zone.enabled ? "yes" : "no", + status: zone.status, })); logger.table(results); diff --git a/packages/wrangler/src/email-routing/sending/send-raw.ts b/packages/wrangler/src/email-routing/sending/send-raw.ts index cad1be84d7..02c26eba8e 100644 --- a/packages/wrangler/src/email-routing/sending/send-raw.ts +++ b/packages/wrangler/src/email-routing/sending/send-raw.ts @@ -46,7 +46,13 @@ export const emailSendingSendRawCommand = createCommand({ let mimeMessage: string; if (args.mimeFile) { - mimeMessage = readFileSync(args.mimeFile, "utf-8"); + try { + mimeMessage = readFileSync(args.mimeFile, "utf-8"); + } catch (e) { + throw new UserError( + `Failed to read MIME file '${args.mimeFile}': ${e instanceof Error ? e.message : e}` + ); + } } else { mimeMessage = args.mime ?? ""; } diff --git a/packages/wrangler/src/email-routing/sending/send.ts b/packages/wrangler/src/email-routing/sending/send.ts index 340cb8a566..617c969d9c 100644 --- a/packages/wrangler/src/email-routing/sending/send.ts +++ b/packages/wrangler/src/email-routing/sending/send.ts @@ -157,7 +157,14 @@ function parseAttachments( return []; } return attachmentPaths.map((filePath) => { - const content = readFileSync(filePath); + let content: Buffer; + try { + content = readFileSync(filePath); + } catch (e) { + throw new UserError( + `Failed to read attachment file '${filePath}': ${e instanceof Error ? e.message : e}` + ); + } const filename = path.basename(filePath); return { From b21b7950e9a3efc710a9e1914b3ef046db9866c1 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 17:42:51 -0400 Subject: [PATCH 13/32] refactor(wrangler): clean up email routing code - Remove dead listZones function and CloudflareZone type - Remove unnecessary JSDoc comments in utils.ts - Remove section separator comments from client.ts and index.ts - Remove redundant null guard in rules/update.ts (already in validateArgs) - Deduplicate EmailRoutingCatchAllAction/Matcher types (reuse base types) - Extract logSendResult helper to avoid duplication in send/send-raw --- packages/wrangler/src/email-routing/client.ts | 21 --------------- packages/wrangler/src/email-routing/index.ts | 27 ++----------------- .../src/email-routing/rules/update.ts | 4 --- .../src/email-routing/sending/send-raw.ts | 21 ++------------- .../src/email-routing/sending/send.ts | 20 ++------------ .../src/email-routing/sending/utils.ts | 23 ++++++++++++++++ packages/wrangler/src/email-routing/utils.ts | 10 ------- 7 files changed, 29 insertions(+), 97 deletions(-) create mode 100644 packages/wrangler/src/email-routing/sending/utils.ts diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index cf8ce50b08..d1cd64a8bf 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -1,7 +1,6 @@ import { fetchPagedListResult, fetchResult } from "../cfetch"; import { requireAuth } from "../user"; import type { - CloudflareZone, EmailRoutingAddress, EmailRoutingCatchAllRule, EmailRoutingDnsRecord, @@ -13,18 +12,6 @@ import type { } from "./index"; import type { Config } from "@cloudflare/workers-utils"; -// --- Zones --- - -export async function listZones(config: Config): Promise { - const accountId = await requireAuth(config); - return await fetchPagedListResult( - config, - `/zones`, - {}, - new URLSearchParams({ "account.id": accountId }) - ); -} - export async function listEmailRoutingZones( config: Config ): Promise { @@ -35,7 +22,6 @@ export async function listEmailRoutingZones( ); } -// --- Settings --- export async function getEmailRoutingSettings( config: Config, @@ -48,7 +34,6 @@ export async function getEmailRoutingSettings( ); } -// --- DNS (enable/disable/get/unlock) --- export async function enableEmailRouting( config: Config, @@ -109,7 +94,6 @@ export async function unlockEmailRoutingDns( ); } -// --- Rules --- export async function listEmailRoutingRules( config: Config, @@ -198,7 +182,6 @@ export async function deleteEmailRoutingRule( ); } -// --- Catch-All --- export async function getEmailRoutingCatchAll( config: Config, @@ -233,7 +216,6 @@ export async function updateEmailRoutingCatchAll( ); } -// --- Addresses --- export async function listEmailRoutingAddresses( config: Config @@ -288,7 +270,6 @@ export async function deleteEmailRoutingAddress( ); } -// --- Email Sending: Subdomains --- export async function listEmailSendingSubdomains( config: Config, @@ -345,7 +326,6 @@ export async function deleteEmailSendingSubdomain( ); } -// --- Email Sending: DNS --- export async function getEmailSendingSubdomainDns( config: Config, @@ -359,7 +339,6 @@ export async function getEmailSendingSubdomainDns( ); } -// --- Email Sending: Send --- export async function sendEmail( config: Config, diff --git a/packages/wrangler/src/email-routing/index.ts b/packages/wrangler/src/email-routing/index.ts index 4a53bff752..76a3b2ea12 100644 --- a/packages/wrangler/src/email-routing/index.ts +++ b/packages/wrangler/src/email-routing/index.ts @@ -64,7 +64,6 @@ export const emailSendingDnsNamespace = createNamespace({ }, }); -// --- Shared arg definitions --- export const zoneArgs = { zone: { @@ -79,7 +78,6 @@ export const zoneArgs = { }, } as const; -// --- Types --- export interface EmailRoutingSettings { id: string; @@ -123,22 +121,13 @@ export interface EmailRoutingMatcher { export interface EmailRoutingCatchAllRule { id: string; - actions: EmailRoutingCatchAllAction[]; + actions: EmailRoutingAction[]; enabled: boolean; - matchers: EmailRoutingCatchAllMatcher[]; + matchers: { type: string }[]; name: string; tag: string; } -export interface EmailRoutingCatchAllAction { - type: string; - value?: string[]; -} - -export interface EmailRoutingCatchAllMatcher { - type: string; -} - export interface EmailRoutingAddress { id: string; created: string; @@ -148,18 +137,6 @@ export interface EmailRoutingAddress { verified: string; } -export interface CloudflareZone { - id: string; - name: string; - status: string; - account: { - id: string; - name: string; - }; -} - -// --- Email Sending types --- - export interface EmailSendingSubdomain { email_sending_enabled: boolean; name: string; diff --git a/packages/wrangler/src/email-routing/rules/update.ts b/packages/wrangler/src/email-routing/rules/update.ts index c35681f14d..7976d5b303 100644 --- a/packages/wrangler/src/email-routing/rules/update.ts +++ b/packages/wrangler/src/email-routing/rules/update.ts @@ -132,10 +132,6 @@ export const emailRoutingRulesUpdateCommand = createCommand({ return; } - if (!args.matchType || !args.matchField || !args.matchValue) { - throw new UserError("Missing matcher arguments for regular rule update"); - } - const rule = await updateEmailRoutingRule(config, zoneId, args.ruleId, { actions: [{ type: args.actionType, value: args.actionValue }], matchers: [ diff --git a/packages/wrangler/src/email-routing/sending/send-raw.ts b/packages/wrangler/src/email-routing/sending/send-raw.ts index 02c26eba8e..4989c844f1 100644 --- a/packages/wrangler/src/email-routing/sending/send-raw.ts +++ b/packages/wrangler/src/email-routing/sending/send-raw.ts @@ -1,8 +1,8 @@ import { UserError } from "@cloudflare/workers-utils"; import { readFileSync } from "node:fs"; import { createCommand } from "../../core/create-command"; -import { logger } from "../../logger"; import { sendRawEmail } from "../client"; +import { logSendResult } from "./utils"; export const emailSendingSendRawCommand = createCommand({ metadata: { @@ -63,23 +63,6 @@ export const emailSendingSendRawCommand = createCommand({ mime_message: mimeMessage, }); - if (result.delivered.length > 0) { - logger.log(`✅ Delivered to: ${result.delivered.join(", ")}`); - } - if (result.queued.length > 0) { - logger.log(`📬 Queued for: ${result.queued.join(", ")}`); - } - if (result.permanent_bounces.length > 0) { - logger.warn( - `Permanently bounced: ${result.permanent_bounces.join(", ")}` - ); - } - if ( - result.delivered.length === 0 && - result.queued.length === 0 && - result.permanent_bounces.length === 0 - ) { - logger.log("✅ Email sent successfully."); - } + logSendResult(result); }, }); diff --git a/packages/wrangler/src/email-routing/sending/send.ts b/packages/wrangler/src/email-routing/sending/send.ts index 617c969d9c..2d27878498 100644 --- a/packages/wrangler/src/email-routing/sending/send.ts +++ b/packages/wrangler/src/email-routing/sending/send.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; +import { logSendResult } from "./utils"; import { sendEmail } from "../client"; export const emailSendingSendCommand = createCommand({ @@ -105,24 +106,7 @@ export const emailSendingSendCommand = createCommand({ attachments: attachments.length > 0 ? attachments : undefined, }); - if (result.delivered.length > 0) { - logger.log(`✅ Delivered to: ${result.delivered.join(", ")}`); - } - if (result.queued.length > 0) { - logger.log(`📬 Queued for: ${result.queued.join(", ")}`); - } - if (result.permanent_bounces.length > 0) { - logger.warn( - `Permanently bounced: ${result.permanent_bounces.join(", ")}` - ); - } - if ( - result.delivered.length === 0 && - result.queued.length === 0 && - result.permanent_bounces.length === 0 - ) { - logger.log("✅ Email sent successfully."); - } + logSendResult(result); }, }); diff --git a/packages/wrangler/src/email-routing/sending/utils.ts b/packages/wrangler/src/email-routing/sending/utils.ts new file mode 100644 index 0000000000..62d6278111 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/utils.ts @@ -0,0 +1,23 @@ +import { logger } from "../../logger"; +import type { EmailSendingSendResponse } from "../index"; + +export function logSendResult(result: EmailSendingSendResponse): void { + if (result.delivered.length > 0) { + logger.log(`✅ Delivered to: ${result.delivered.join(", ")}`); + } + if (result.queued.length > 0) { + logger.log(`📬 Queued for: ${result.queued.join(", ")}`); + } + if (result.permanent_bounces.length > 0) { + logger.warn( + `Permanently bounced: ${result.permanent_bounces.join(", ")}` + ); + } + if ( + result.delivered.length === 0 && + result.queued.length === 0 && + result.permanent_bounces.length === 0 + ) { + logger.log("✅ Email sent successfully."); + } +} diff --git a/packages/wrangler/src/email-routing/utils.ts b/packages/wrangler/src/email-routing/utils.ts index 5ff3f3edf6..fa50431808 100644 --- a/packages/wrangler/src/email-routing/utils.ts +++ b/packages/wrangler/src/email-routing/utils.ts @@ -4,10 +4,6 @@ import { requireAuth } from "../user"; import { retryOnAPIFailure } from "../utils/retry"; import type { ComplianceConfig, Config } from "@cloudflare/workers-utils"; -/** - * Resolve a zone ID from either --zone (domain name) or --zone-id (direct ID). - * At least one must be provided. - */ export async function resolveZoneId( config: Config, args: { zone?: string; zoneId?: string } @@ -26,12 +22,6 @@ export async function resolveZoneId( ); } -/** - * Look up a zone ID by domain name, using the same approach as zones.ts getZoneIdFromHost. - * Uses fetchListResult (cursor-based) rather than fetchPagedListResult (page-based) to match - * the existing pattern in zones.ts. This is safe because the `name` query parameter filters - * to an exact domain match, so the result is always 0 or 1 items — pagination is never needed. - */ async function getZoneIdByDomain( complianceConfig: ComplianceConfig, domain: string, From 236280d040cd45b1bd598a6d596dadbc4821864e Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 22:10:33 -0400 Subject: [PATCH 14/32] chore: revert .vscode/launch.json changes --- .vscode/launch.json | 98 --------------------------------------------- 1 file changed, 98 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b023e14c56..8f054e1043 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,104 +1,6 @@ { "version": "0.2.0", "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Debug: email routing list", - "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", - "args": ["email", "routing", "list"], - "env": { - "WRANGLER_API_ENVIRONMENT": "staging", - "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" - }, - "cwd": "${workspaceFolder}/packages/wrangler", - "console": "integratedTerminal", - "skipFiles": ["/**"] - }, - { - "type": "node", - "request": "launch", - "name": "Debug: email routing settings", - "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", - "args": ["email", "routing", "settings", "--zone-id", "faeeba7abcba046b44286aa53c2fcc82"], - "env": { - "WRANGLER_API_ENVIRONMENT": "staging", - "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" - }, - "cwd": "${workspaceFolder}/packages/wrangler", - "console": "integratedTerminal", - "skipFiles": ["/**"] - }, - { - "type": "node", - "request": "launch", - "name": "Debug: email routing enable", - "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", - "args": ["email", "routing", "enable", "--zone-id", "faeeba7abcba046b44286aa53c2fcc82"], - "env": { - "WRANGLER_API_ENVIRONMENT": "staging", - "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" - }, - "cwd": "${workspaceFolder}/packages/wrangler", - "console": "integratedTerminal", - "skipFiles": ["/**"] - }, - { - "type": "node", - "request": "launch", - "name": "Debug: email routing rules list", - "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", - "args": ["email", "routing", "rules", "list", "--zone-id", "faeeba7abcba046b44286aa53c2fcc82"], - "env": { - "WRANGLER_API_ENVIRONMENT": "staging", - "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" - }, - "cwd": "${workspaceFolder}/packages/wrangler", - "console": "integratedTerminal", - "skipFiles": ["/**"] - }, - { - "type": "node", - "request": "launch", - "name": "Debug: email routing addresses list", - "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", - "args": ["email", "routing", "addresses", "list"], - "env": { - "WRANGLER_API_ENVIRONMENT": "staging", - "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" - }, - "cwd": "${workspaceFolder}/packages/wrangler", - "console": "integratedTerminal", - "skipFiles": ["/**"] - }, - { - "type": "node", - "request": "launch", - "name": "Debug: email routing dns get", - "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", - "args": ["email", "routing", "dns", "get", "--zone-id", "faeeba7abcba046b44286aa53c2fcc82"], - "env": { - "WRANGLER_API_ENVIRONMENT": "staging", - "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" - }, - "cwd": "${workspaceFolder}/packages/wrangler", - "console": "integratedTerminal", - "skipFiles": ["/**"] - }, - { - "type": "node", - "request": "launch", - "name": "Debug: email routing catch-all get", - "program": "${workspaceFolder}/packages/wrangler/bin/wrangler.js", - "args": ["email", "routing", "rules", "get", "catch-all", "--zone-id", "faeeba7abcba046b44286aa53c2fcc82"], - "env": { - "WRANGLER_API_ENVIRONMENT": "staging", - "CLOUDFLARE_ACCOUNT_ID": "eb1643a804b54c0048b628388b142012" - }, - "cwd": "${workspaceFolder}/packages/wrangler", - "console": "integratedTerminal", - "skipFiles": ["/**"] - }, { "name": "Debug Current Test File", "type": "node", From 4be8fb062af9ba3a7a7a5368c526be05b6cd5e6e Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 22:11:48 -0400 Subject: [PATCH 15/32] =?UTF-8?q?fix:=20import=20order=20=E2=80=94=20built?= =?UTF-8?q?ins=20before=20third-party=20in=20send.ts=20and=20send-raw.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/wrangler/src/email-routing/sending/send-raw.ts | 2 +- packages/wrangler/src/email-routing/sending/send.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/wrangler/src/email-routing/sending/send-raw.ts b/packages/wrangler/src/email-routing/sending/send-raw.ts index 4989c844f1..5432c2a820 100644 --- a/packages/wrangler/src/email-routing/sending/send-raw.ts +++ b/packages/wrangler/src/email-routing/sending/send-raw.ts @@ -1,5 +1,5 @@ -import { UserError } from "@cloudflare/workers-utils"; import { readFileSync } from "node:fs"; +import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { sendRawEmail } from "../client"; import { logSendResult } from "./utils"; diff --git a/packages/wrangler/src/email-routing/sending/send.ts b/packages/wrangler/src/email-routing/sending/send.ts index 2d27878498..a965fec82d 100644 --- a/packages/wrangler/src/email-routing/sending/send.ts +++ b/packages/wrangler/src/email-routing/sending/send.ts @@ -1,10 +1,10 @@ -import { UserError } from "@cloudflare/workers-utils"; import { readFileSync } from "node:fs"; import path from "node:path"; +import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { logSendResult } from "./utils"; import { sendEmail } from "../client"; +import { logSendResult } from "./utils"; export const emailSendingSendCommand = createCommand({ metadata: { From b484f721d1152c77271bcb76390202e09ad5f7d3 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 22:18:26 -0400 Subject: [PATCH 16/32] feat(wrangler): add email sending settings, enable, and disable commands Add zone-level email sending management commands: - wrangler email sending settings --zone - wrangler email sending enable --zone - wrangler email sending disable --zone These mirror the email routing enable/disable pattern and use the /zones/{id}/email/sending, /enable, and /disable API endpoints. --- packages/wrangler/src/email-routing/client.ts | 43 +++++++++++++++++++ .../src/email-routing/sending/disable.ts | 24 +++++++++++ .../src/email-routing/sending/enable.ts | 24 +++++++++++ .../src/email-routing/sending/settings.ts | 26 +++++++++++ packages/wrangler/src/index.ts | 15 +++++++ 5 files changed, 132 insertions(+) create mode 100644 packages/wrangler/src/email-routing/sending/disable.ts create mode 100644 packages/wrangler/src/email-routing/sending/enable.ts create mode 100644 packages/wrangler/src/email-routing/sending/settings.ts diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index d1cd64a8bf..35a87f2e5c 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -271,6 +271,49 @@ export async function deleteEmailRoutingAddress( } +export async function getEmailSendingSettings( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending` + ); +} + +export async function enableEmailSending( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending/enable`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ); +} + +export async function disableEmailSending( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending/disable`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ); +} + export async function listEmailSendingSubdomains( config: Config, zoneId: string diff --git a/packages/wrangler/src/email-routing/sending/disable.ts b/packages/wrangler/src/email-routing/sending/disable.ts new file mode 100644 index 0000000000..c230134ce7 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/disable.ts @@ -0,0 +1,24 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { disableEmailSending } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailSendingDisableCommand = createCommand({ + metadata: { + description: "Disable Email Sending for a zone", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const settings = await disableEmailSending(config, zoneId); + + logger.log( + `Email Sending disabled for ${settings.name} (status: ${settings.status})` + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/sending/enable.ts b/packages/wrangler/src/email-routing/sending/enable.ts new file mode 100644 index 0000000000..fc9bd48cc4 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/enable.ts @@ -0,0 +1,24 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { enableEmailSending } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailSendingEnableCommand = createCommand({ + metadata: { + description: "Enable Email Sending for a zone", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const settings = await enableEmailSending(config, zoneId); + + logger.log( + `Email Sending enabled for ${settings.name} (status: ${settings.status})` + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/sending/settings.ts b/packages/wrangler/src/email-routing/sending/settings.ts new file mode 100644 index 0000000000..9cb2db3814 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/settings.ts @@ -0,0 +1,26 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { getEmailSendingSettings } from "../client"; +import { zoneArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailSendingSettingsCommand = createCommand({ + metadata: { + description: "Get Email Sending settings for a zone", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...zoneArgs, + }, + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const settings = await getEmailSendingSettings(config, zoneId); + + logger.log(`Email Sending for ${settings.name}:`); + logger.log(` Enabled: ${settings.enabled}`); + logger.log(` Status: ${settings.status}`); + logger.log(` Created: ${settings.created}`); + logger.log(` Modified: ${settings.modified}`); + }, +}); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index dbb00c7d88..784f85f289 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -122,9 +122,12 @@ import { emailRoutingRulesGetCommand } from "./email-routing/rules/get"; import { emailRoutingRulesListCommand } from "./email-routing/rules/list"; import { emailRoutingRulesUpdateCommand } from "./email-routing/rules/update"; import { emailRoutingSettingsCommand } from "./email-routing/settings"; +import { emailSendingDisableCommand } from "./email-routing/sending/disable"; import { emailSendingDnsGetCommand } from "./email-routing/sending/dns-get"; +import { emailSendingEnableCommand } from "./email-routing/sending/enable"; import { emailSendingSendCommand } from "./email-routing/sending/send"; import { emailSendingSendRawCommand } from "./email-routing/sending/send-raw"; +import { emailSendingSettingsCommand } from "./email-routing/sending/settings"; import { emailSendingSubdomainsCreateCommand } from "./email-routing/sending/subdomains/create"; import { emailSendingSubdomainsDeleteCommand } from "./email-routing/sending/subdomains/delete"; import { emailSendingSubdomainsGetCommand } from "./email-routing/sending/subdomains/get"; @@ -1949,6 +1952,18 @@ export function createCLIParser(argv: string[]) { definition: emailRoutingAddressesDeleteCommand, }, { command: "wrangler email sending", definition: emailSendingNamespace }, + { + command: "wrangler email sending settings", + definition: emailSendingSettingsCommand, + }, + { + command: "wrangler email sending enable", + definition: emailSendingEnableCommand, + }, + { + command: "wrangler email sending disable", + definition: emailSendingDisableCommand, + }, { command: "wrangler email sending send", definition: emailSendingSendCommand, From 94fa8f830b11490ab62a59f660bdd6ffeb8b9c54 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 22:20:18 -0400 Subject: [PATCH 17/32] feat(wrangler): add email sending list command for symmetry with routing Add wrangler email sending list to show all zones with email sending status, mirroring wrangler email routing list. Uses the /accounts/{id}/email/sending/zones API endpoint. --- packages/wrangler/src/email-routing/client.ts | 10 +++++++ .../src/email-routing/sending/list.ts | 29 +++++++++++++++++++ packages/wrangler/src/index.ts | 5 ++++ 3 files changed, 44 insertions(+) create mode 100644 packages/wrangler/src/email-routing/sending/list.ts diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index 35a87f2e5c..39d91a0f78 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -23,6 +23,16 @@ export async function listEmailRoutingZones( } +export async function listEmailSendingZones( + config: Config +): Promise { + const accountId = await requireAuth(config); + return await fetchPagedListResult( + config, + `/accounts/${accountId}/email/sending/zones` + ); +} + export async function getEmailRoutingSettings( config: Config, zoneId: string diff --git a/packages/wrangler/src/email-routing/sending/list.ts b/packages/wrangler/src/email-routing/sending/list.ts new file mode 100644 index 0000000000..f58c4c4f65 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/list.ts @@ -0,0 +1,29 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { listEmailSendingZones } from "../client"; + +export const emailSendingListCommand = createCommand({ + metadata: { + description: "List zones with Email Sending", + status: "open beta", + owner: "Product: Email Service", + }, + args: {}, + async handler(_args, { config }) { + const zones = await listEmailSendingZones(config); + + if (zones.length === 0) { + logger.log("No zones found with Email Sending in this account."); + return; + } + + const results = zones.map((zone) => ({ + zone: zone.name, + "zone id": zone.id, + enabled: zone.enabled ? "yes" : "no", + status: zone.status ?? "", + })); + + logger.table(results); + }, +}); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 784f85f289..4e6e8bf2c6 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -125,6 +125,7 @@ import { emailRoutingSettingsCommand } from "./email-routing/settings"; import { emailSendingDisableCommand } from "./email-routing/sending/disable"; import { emailSendingDnsGetCommand } from "./email-routing/sending/dns-get"; import { emailSendingEnableCommand } from "./email-routing/sending/enable"; +import { emailSendingListCommand } from "./email-routing/sending/list"; import { emailSendingSendCommand } from "./email-routing/sending/send"; import { emailSendingSendRawCommand } from "./email-routing/sending/send-raw"; import { emailSendingSettingsCommand } from "./email-routing/sending/settings"; @@ -1952,6 +1953,10 @@ export function createCLIParser(argv: string[]) { definition: emailRoutingAddressesDeleteCommand, }, { command: "wrangler email sending", definition: emailSendingNamespace }, + { + command: "wrangler email sending list", + definition: emailSendingListCommand, + }, { command: "wrangler email sending settings", definition: emailSendingSettingsCommand, From 92b27d5a4d2292adc15bf94aefcb96515cebe7f3 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 22:31:24 -0400 Subject: [PATCH 18/32] feat(wrangler): add zone:write OAuth scope for documented email routing DNS endpoints The documented email routing enable/disable endpoints (POST/DELETE /zones/{id}/email/routing/dns) require Zone Settings Write permission. Add zone:write to DefaultScopes so wrangler login requests it. --- packages/wrangler/src/user/user.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index 9c2f6b126f..908860865f 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -365,6 +365,7 @@ const DefaultScopes = { "pages:write": "See and change Cloudflare Pages projects, settings and deployments.", "zone:read": "Grants read level access to account zone.", + "zone:write": "Grants write level access to account zone.", "ssl_certs:write": "See and manage mTLS certificates for your account", "ai:write": "See and change Workers AI catalog and assets", "ai-search:write": "See and change AI Search data", From e97f2a7ee9c038e45ad6e1b73e9564ec4eca4b5f Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 22:32:30 -0400 Subject: [PATCH 19/32] Revert "feat(wrangler): add zone:write OAuth scope for documented email routing DNS endpoints" This reverts commit 92b27d5a4d2292adc15bf94aefcb96515cebe7f3. --- packages/wrangler/src/user/user.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index 908860865f..9c2f6b126f 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -365,7 +365,6 @@ const DefaultScopes = { "pages:write": "See and change Cloudflare Pages projects, settings and deployments.", "zone:read": "Grants read level access to account zone.", - "zone:write": "Grants write level access to account zone.", "ssl_certs:write": "See and manage mTLS certificates for your account", "ai:write": "See and change Workers AI catalog and assets", "ai-search:write": "See and change AI Search data", From 4073c0c2721e7bedaf350a9eb154931eaa980553 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 22:44:47 -0400 Subject: [PATCH 20/32] =?UTF-8?q?refactor(wrangler):=20flatten=20email=20s?= =?UTF-8?q?ending=20commands=20=E2=80=94=20remove=20subdomains=20namespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the nested subdomains CRUD with flat domain-aware commands: - `wrangler email sending enable ` — auto-detects zone vs subdomain - `wrangler email sending disable ` — same - `wrangler email sending settings ` — shows zone + subdomains - `wrangler email sending dns get ` — resolves subdomain tag automatically The CLI walks up domain labels to find the zone (e.g. sub.example.com tries sub.example.com, then example.com), so users never need to specify --zone separately. Removes subdomains/ directory, EmailSendingSubdomain type, and 4 subdomain CRUD client functions. --- .../src/__tests__/email-routing.test.ts | 200 +++++++----------- packages/wrangler/src/email-routing/client.ts | 67 +----- packages/wrangler/src/email-routing/index.ts | 20 -- .../src/email-routing/sending/disable.ts | 31 ++- .../src/email-routing/sending/dns-get.ts | 36 +++- .../src/email-routing/sending/enable.ts | 21 +- .../src/email-routing/sending/settings.ts | 24 ++- .../sending/subdomains/create.ts | 43 ---- .../sending/subdomains/delete.ts | 28 --- .../email-routing/sending/subdomains/get.ts | 44 ---- .../email-routing/sending/subdomains/list.ts | 36 ---- packages/wrangler/src/email-routing/utils.ts | 43 ++++ packages/wrangler/src/index.ts | 26 +-- 13 files changed, 206 insertions(+), 413 deletions(-) delete mode 100644 packages/wrangler/src/email-routing/sending/subdomains/create.ts delete mode 100644 packages/wrangler/src/email-routing/sending/subdomains/delete.ts delete mode 100644 packages/wrangler/src/email-routing/sending/subdomains/get.ts delete mode 100644 packages/wrangler/src/email-routing/sending/subdomains/list.ts diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index 801397626e..a63e9a2152 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -144,12 +144,12 @@ describe("email routing help", () => { expect(std.out).toContain("Manage Email Sending"); }); - it("should show help text for email sending subdomains", async ({ expect }) => { - await runWrangler("email sending subdomains"); + it("should show help text for email sending dns", async ({ expect }) => { + await runWrangler("email sending dns"); await endEventLoop(); expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.out).toContain("Manage Email Sending subdomains"); + expect(std.out).toContain("Manage Email Sending DNS records"); }); }); @@ -582,120 +582,70 @@ describe("email sending commands", () => { clearDialogs(); }); - // --- subdomains list --- + // --- enable/disable --- - describe("subdomains list", () => { - it("should list sending subdomains", async ({ expect }) => { - mockListSendingSubdomains("zone-id-1", [mockSubdomain]); - - await runWrangler( - "email sending subdomains list --zone-id zone-id-1" - ); - - expect(std.out).toContain("sub.example.com"); - expect(std.out).toContain("yes"); - }); - - it("should handle no sending subdomains", async ({ expect }) => { - mockListSendingSubdomains("zone-id-1", []); - - await runWrangler( - "email sending subdomains list --zone-id zone-id-1" - ); - - expect(std.out).toContain("No sending subdomains found."); - }); - }); - - // --- subdomains get --- - - describe("subdomains get", () => { - it("should get a sending subdomain", async ({ expect }) => { - mockGetSendingSubdomain( - "zone-id-1", - "aabbccdd11223344aabbccdd11223344", - mockSubdomain - ); + describe("enable", () => { + it("should enable sending for a zone", async ({ expect }) => { + mockZoneLookup("example.com", "zone-id-1"); + mockEnableSending("zone-id-1"); - await runWrangler( - "email sending subdomains get aabbccdd11223344aabbccdd11223344 --zone-id zone-id-1" - ); + await runWrangler("email sending enable example.com"); - expect(std.out).toContain("Sending subdomain: sub.example.com"); - expect(std.out).toContain("Tag: aabbccdd11223344aabbccdd11223344"); - expect(std.out).toContain("Sending enabled: true"); - expect(std.out).toContain("DKIM selector: cf-bounce"); + expect(std.out).toContain("Email Sending enabled for example.com"); }); - }); - - // --- subdomains create --- - describe("subdomains create", () => { - it("should create a sending subdomain", async ({ expect }) => { - const reqProm = mockCreateSendingSubdomain("zone-id-1"); + it("should enable sending for a subdomain", async ({ expect }) => { + mockZoneLookup("sub.example.com", "zone-id-1"); + mockEnableSending("zone-id-1"); - await runWrangler( - "email sending subdomains create sub.example.com --zone-id zone-id-1" - ); - - await expect(reqProm).resolves.toMatchObject({ - name: "sub.example.com", - }); + await runWrangler("email sending enable sub.example.com"); - expect(std.out).toContain("Created sending subdomain: sub.example.com"); + expect(std.out).toContain("Email Sending enabled for sub.example.com"); }); }); - // --- subdomains delete --- - - describe("subdomains delete", () => { - it("should delete a sending subdomain", async ({ expect }) => { - mockDeleteSendingSubdomain( - "zone-id-1", - "aabbccdd11223344aabbccdd11223344" - ); + describe("disable", () => { + it("should disable sending for a zone", async ({ expect }) => { + mockZoneLookup("example.com", "zone-id-1"); + mockDisableSending("zone-id-1"); - await runWrangler( - "email sending subdomains delete aabbccdd11223344aabbccdd11223344 --zone-id zone-id-1" - ); + await runWrangler("email sending disable example.com"); - expect(std.out).toContain( - "Deleted sending subdomain: aabbccdd11223344aabbccdd11223344" - ); + expect(std.out).toContain("Email Sending disabled for example.com"); }); }); // --- dns get --- describe("dns get", () => { - it("should show sending subdomain dns records", async ({ expect }) => { + it("should show sending dns records", async ({ expect }) => { + mockZoneLookup("sub.example.com", "zone-id-1"); + mockGetSendingSettings("zone-id-1"); mockGetSendingDns( "zone-id-1", "aabbccdd11223344aabbccdd11223344", mockSendingDnsRecords ); - await runWrangler( - "email sending dns get aabbccdd11223344aabbccdd11223344 --zone-id zone-id-1" - ); + await runWrangler("email sending dns get sub.example.com"); expect(std.out).toContain("TXT"); expect(std.out).toContain("v=spf1"); }); it("should handle no dns records", async ({ expect }) => { + mockZoneLookup("sub.example.com", "zone-id-1"); + mockGetSendingSettings("zone-id-1"); mockGetSendingDns( "zone-id-1", "aabbccdd11223344aabbccdd11223344", [] ); - await runWrangler( - "email sending dns get aabbccdd11223344aabbccdd11223344 --zone-id zone-id-1" - ); + await runWrangler("email sending dns get sub.example.com"); expect(std.out).toContain( - "No DNS records found for this sending subdomain." + "No DNS records found for this sending domain." ); }); }); @@ -866,18 +816,23 @@ function mockListZones( } function mockZoneLookup(domain: string, zoneId: string) { + // Extract the zone name (last two labels) to handle subdomain lookups + // resolveDomain walks up labels, so "sub.example.com" tries "sub.example.com" then "example.com" + const labels = domain.split("."); + const zoneName = labels.slice(-2).join("."); msw.use( http.get( "*/zones", ({ request }) => { const url = new URL(request.url); const name = url.searchParams.get("name"); - if (name === domain) { - return HttpResponse.json(createFetchResult([{ id: zoneId }], true)); + if (name === zoneName) { + return HttpResponse.json( + createFetchResult([{ id: zoneId, name: zoneName }], true) + ); } return HttpResponse.json(createFetchResult([], true)); - }, - { once: true } + } ) ); } @@ -1116,65 +1071,58 @@ function mockDeleteAddress(_addressId: string) { // --- Mock API handlers: Email Sending --- -function mockListSendingSubdomains( - _zoneId: string, - subdomains: (typeof mockSubdomain)[] -) { +function mockEnableSending(_zoneId: string) { msw.use( - http.get( - "*/zones/:zoneId/email/sending/subdomains", - () => { - return HttpResponse.json(createFetchResult(subdomains, true)); + http.post( + "*/zones/:zoneId/email/sending/enable", + async ({ request }) => { + const body = (await request.json()) as Record; + const name = (body.name as string) || "example.com"; + return HttpResponse.json( + createFetchResult( + { ...mockSettings, name, status: "ready" }, + true + ) + ); }, { once: true } ) ); } -function mockGetSendingSubdomain( - _zoneId: string, - _subdomainId: string, - subdomain: typeof mockSubdomain -) { +function mockDisableSending(_zoneId: string) { msw.use( - http.get( - "*/zones/:zoneId/email/sending/subdomains/:subdomainId", - () => { - return HttpResponse.json(createFetchResult(subdomain, true)); + http.post( + "*/zones/:zoneId/email/sending/disable", + async ({ request }) => { + const body = (await request.json()) as Record; + const name = (body.name as string) || "example.com"; + return HttpResponse.json( + createFetchResult( + { ...mockSettings, name, enabled: false, status: "unconfigured" }, + true + ) + ); }, { once: true } ) ); } -function mockCreateSendingSubdomain(_zoneId: string): Promise { - return new Promise((resolve) => { - msw.use( - http.post( - "*/zones/:zoneId/email/sending/subdomains", - async ({ request }) => { - const reqBody = - (await request.json()) as Record; - resolve(reqBody); - return HttpResponse.json( - createFetchResult({ ...mockSubdomain, ...reqBody }, true) - ); - }, - { once: true } - ) - ); - }); -} - -function mockDeleteSendingSubdomain( - _zoneId: string, - _subdomainId: string -) { +function mockGetSendingSettings(_zoneId: string) { msw.use( - http.delete( - "*/zones/:zoneId/email/sending/subdomains/:subdomainId", + http.get( + "*/zones/:zoneId/email/sending", () => { - return HttpResponse.json(createFetchResult(null, true)); + return HttpResponse.json( + createFetchResult( + { + ...mockSettings, + subdomains: [mockSubdomain], + }, + true + ) + ); }, { once: true } ) diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index 39d91a0f78..021eec57ae 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -8,7 +8,6 @@ import type { EmailRoutingSettings, EmailSendingDnsRecord, EmailSendingSendResponse, - EmailSendingSubdomain, } from "./index"; import type { Config } from "@cloudflare/workers-utils"; @@ -294,7 +293,8 @@ export async function getEmailSendingSettings( export async function enableEmailSending( config: Config, - zoneId: string + zoneId: string, + name?: string ): Promise { await requireAuth(config); return await fetchResult( @@ -303,14 +303,15 @@ export async function enableEmailSending( { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify(name ? { name } : {}), } ); } export async function disableEmailSending( config: Config, - zoneId: string + zoneId: string, + name?: string ): Promise { await requireAuth(config); return await fetchResult( @@ -319,67 +320,11 @@ export async function disableEmailSending( { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - } - ); -} - -export async function listEmailSendingSubdomains( - config: Config, - zoneId: string -): Promise { - await requireAuth(config); - return await fetchPagedListResult( - config, - `/zones/${zoneId}/email/sending/subdomains` - ); -} - -export async function getEmailSendingSubdomain( - config: Config, - zoneId: string, - subdomainId: string -): Promise { - await requireAuth(config); - return await fetchResult( - config, - `/zones/${zoneId}/email/sending/subdomains/${subdomainId}` - ); -} - -export async function createEmailSendingSubdomain( - config: Config, - zoneId: string, - name: string -): Promise { - await requireAuth(config); - return await fetchResult( - config, - `/zones/${zoneId}/email/sending/subdomains`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), - } - ); -} - -export async function deleteEmailSendingSubdomain( - config: Config, - zoneId: string, - subdomainId: string -): Promise { - await requireAuth(config); - await fetchResult( - config, - `/zones/${zoneId}/email/sending/subdomains/${subdomainId}`, - { - method: "DELETE", + body: JSON.stringify(name ? { name } : {}), } ); } - export async function getEmailSendingSubdomainDns( config: Config, zoneId: string, diff --git a/packages/wrangler/src/email-routing/index.ts b/packages/wrangler/src/email-routing/index.ts index 76a3b2ea12..689fac43f3 100644 --- a/packages/wrangler/src/email-routing/index.ts +++ b/packages/wrangler/src/email-routing/index.ts @@ -48,14 +48,6 @@ export const emailSendingNamespace = createNamespace({ }, }); -export const emailSendingSubdomainsNamespace = createNamespace({ - metadata: { - description: "Manage Email Sending subdomains", - status: "open beta", - owner: "Product: Email Service", - }, -}); - export const emailSendingDnsNamespace = createNamespace({ metadata: { description: "Manage Email Sending DNS records", @@ -64,7 +56,6 @@ export const emailSendingDnsNamespace = createNamespace({ }, }); - export const zoneArgs = { zone: { type: "string", @@ -137,17 +128,6 @@ export interface EmailRoutingAddress { verified: string; } -export interface EmailSendingSubdomain { - email_sending_enabled: boolean; - name: string; - tag: string; - created?: string; - email_sending_dkim_selector?: string; - email_sending_return_path_domain?: string; - enabled?: boolean; - modified?: string; -} - export interface EmailSendingDnsRecord { content?: string; name?: string; diff --git a/packages/wrangler/src/email-routing/sending/disable.ts b/packages/wrangler/src/email-routing/sending/disable.ts index c230134ce7..bf8592c0dd 100644 --- a/packages/wrangler/src/email-routing/sending/disable.ts +++ b/packages/wrangler/src/email-routing/sending/disable.ts @@ -1,24 +1,37 @@ import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { disableEmailSending } from "../client"; -import { zoneArgs } from "../index"; -import { resolveZoneId } from "../utils"; +import { resolveDomain } from "../utils"; export const emailSendingDisableCommand = createCommand({ metadata: { - description: "Disable Email Sending for a zone", + description: "Disable Email Sending for a zone or subdomain", status: "open beta", owner: "Product: Email Service", }, args: { - ...zoneArgs, + domain: { + type: "string", + demandOption: true, + description: + "Domain to disable sending for (e.g. example.com or notifications.example.com)", + }, }, + positionalArgs: ["domain"], async handler(args, { config }) { - const zoneId = await resolveZoneId(config, args); - const settings = await disableEmailSending(config, zoneId); - - logger.log( - `Email Sending disabled for ${settings.name} (status: ${settings.status})` + const { zoneId, isSubdomain, domain } = await resolveDomain( + config, + args.domain ); + const name = isSubdomain ? domain : undefined; + const settings = await disableEmailSending(config, zoneId, name); + + if (settings) { + logger.log( + `Email Sending disabled for ${settings.name} (status: ${settings.status})` + ); + } else { + logger.log(`Email Sending disabled for ${domain}`); + } }, }); diff --git a/packages/wrangler/src/email-routing/sending/dns-get.ts b/packages/wrangler/src/email-routing/sending/dns-get.ts index 7d9198c006..96ddd7c458 100644 --- a/packages/wrangler/src/email-routing/sending/dns-get.ts +++ b/packages/wrangler/src/email-routing/sending/dns-get.ts @@ -1,34 +1,48 @@ +import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { getEmailSendingSubdomainDns } from "../client"; -import { zoneArgs } from "../index"; -import { resolveZoneId } from "../utils"; +import { getEmailSendingSettings, getEmailSendingSubdomainDns } from "../client"; +import { resolveDomain } from "../utils"; export const emailSendingDnsGetCommand = createCommand({ metadata: { - description: "Get DNS records for an Email Sending subdomain", + description: "Get DNS records for an Email Sending domain", status: "open beta", owner: "Product: Email Service", }, args: { - ...zoneArgs, - "subdomain-id": { + domain: { type: "string", demandOption: true, - description: "The sending subdomain identifier (tag)", + description: + "Domain to get DNS records for (e.g. example.com or notifications.example.com)", }, }, - positionalArgs: ["subdomain-id"], + positionalArgs: ["domain"], async handler(args, { config }) { - const zoneId = await resolveZoneId(config, args); + const { zoneId } = await resolveDomain(config, args.domain); + + // Find the subdomain tag by matching the domain name in settings + const settings = await getEmailSendingSettings(config, zoneId); + const subdomains = (settings as Record).subdomains as + | Array<{ tag: string; name: string }> + | undefined; + + const match = subdomains?.find((s) => s.name === args.domain); + if (!match) { + throw new UserError( + `No sending domain found for \`${args.domain}\`. Run \`wrangler email sending settings ${args.domain}\` to see configured domains.` + ); + } + const records = await getEmailSendingSubdomainDns( config, zoneId, - args.subdomainId + match.tag ); if (records.length === 0) { - logger.log("No DNS records found for this sending subdomain."); + logger.log("No DNS records found for this sending domain."); return; } diff --git a/packages/wrangler/src/email-routing/sending/enable.ts b/packages/wrangler/src/email-routing/sending/enable.ts index fc9bd48cc4..a29e01ed60 100644 --- a/packages/wrangler/src/email-routing/sending/enable.ts +++ b/packages/wrangler/src/email-routing/sending/enable.ts @@ -1,21 +1,30 @@ import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { enableEmailSending } from "../client"; -import { zoneArgs } from "../index"; -import { resolveZoneId } from "../utils"; +import { resolveDomain } from "../utils"; export const emailSendingEnableCommand = createCommand({ metadata: { - description: "Enable Email Sending for a zone", + description: "Enable Email Sending for a zone or subdomain", status: "open beta", owner: "Product: Email Service", }, args: { - ...zoneArgs, + domain: { + type: "string", + demandOption: true, + description: + "Domain to enable sending for (e.g. example.com or notifications.example.com)", + }, }, + positionalArgs: ["domain"], async handler(args, { config }) { - const zoneId = await resolveZoneId(config, args); - const settings = await enableEmailSending(config, zoneId); + const { zoneId, isSubdomain, domain } = await resolveDomain( + config, + args.domain + ); + const name = isSubdomain ? domain : undefined; + const settings = await enableEmailSending(config, zoneId, name); logger.log( `Email Sending enabled for ${settings.name} (status: ${settings.status})` diff --git a/packages/wrangler/src/email-routing/sending/settings.ts b/packages/wrangler/src/email-routing/sending/settings.ts index 9cb2db3814..2302eab448 100644 --- a/packages/wrangler/src/email-routing/sending/settings.ts +++ b/packages/wrangler/src/email-routing/sending/settings.ts @@ -1,8 +1,7 @@ import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { getEmailSendingSettings } from "../client"; -import { zoneArgs } from "../index"; -import { resolveZoneId } from "../utils"; +import { resolveDomain } from "../utils"; export const emailSendingSettingsCommand = createCommand({ metadata: { @@ -11,10 +10,15 @@ export const emailSendingSettingsCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + domain: { + type: "string", + demandOption: true, + description: "Domain to get sending settings for (e.g. example.com)", + }, }, + positionalArgs: ["domain"], async handler(args, { config }) { - const zoneId = await resolveZoneId(config, args); + const { zoneId } = await resolveDomain(config, args.domain); const settings = await getEmailSendingSettings(config, zoneId); logger.log(`Email Sending for ${settings.name}:`); @@ -22,5 +26,17 @@ export const emailSendingSettingsCommand = createCommand({ logger.log(` Status: ${settings.status}`); logger.log(` Created: ${settings.created}`); logger.log(` Modified: ${settings.modified}`); + + const subdomains = (settings as Record).subdomains as + | Array<{ name: string; enabled: boolean; status?: string }> + | undefined; + if (subdomains && subdomains.length > 0) { + logger.log(` Subdomains:`); + for (const s of subdomains) { + logger.log( + ` - ${s.name} (enabled: ${s.enabled}, status: ${s.status ?? "unknown"})` + ); + } + } }, }); diff --git a/packages/wrangler/src/email-routing/sending/subdomains/create.ts b/packages/wrangler/src/email-routing/sending/subdomains/create.ts deleted file mode 100644 index 46eaf9564d..0000000000 --- a/packages/wrangler/src/email-routing/sending/subdomains/create.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createCommand } from "../../../core/create-command"; -import { logger } from "../../../logger"; -import { createEmailSendingSubdomain } from "../../client"; -import { zoneArgs } from "../../index"; -import { resolveZoneId } from "../../utils"; - -export const emailSendingSubdomainsCreateCommand = createCommand({ - metadata: { - description: "Create an Email Sending subdomain", - status: "open beta", - owner: "Product: Email Service", - }, - args: { - ...zoneArgs, - name: { - type: "string", - demandOption: true, - description: - "The subdomain name (e.g. sub.example.com). Must be within the zone.", - }, - }, - positionalArgs: ["name"], - async handler(args, { config }) { - const zoneId = await resolveZoneId(config, args); - const subdomain = await createEmailSendingSubdomain( - config, - zoneId, - args.name - ); - - logger.log(`Created sending subdomain: ${subdomain.name}`); - logger.log(` Tag: ${subdomain.tag}`); - logger.log( - ` Sending enabled: ${subdomain.email_sending_enabled}` - ); - logger.log( - ` DKIM selector: ${subdomain.email_sending_dkim_selector || "(none)"}` - ); - logger.log( - ` Return path: ${subdomain.email_sending_return_path_domain || "(none)"}` - ); - }, -}); diff --git a/packages/wrangler/src/email-routing/sending/subdomains/delete.ts b/packages/wrangler/src/email-routing/sending/subdomains/delete.ts deleted file mode 100644 index e15084e84f..0000000000 --- a/packages/wrangler/src/email-routing/sending/subdomains/delete.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createCommand } from "../../../core/create-command"; -import { logger } from "../../../logger"; -import { deleteEmailSendingSubdomain } from "../../client"; -import { zoneArgs } from "../../index"; -import { resolveZoneId } from "../../utils"; - -export const emailSendingSubdomainsDeleteCommand = createCommand({ - metadata: { - description: "Delete an Email Sending subdomain", - status: "open beta", - owner: "Product: Email Service", - }, - args: { - ...zoneArgs, - "subdomain-id": { - type: "string", - demandOption: true, - description: "The sending subdomain identifier (tag) to delete", - }, - }, - positionalArgs: ["subdomain-id"], - async handler(args, { config }) { - const zoneId = await resolveZoneId(config, args); - await deleteEmailSendingSubdomain(config, zoneId, args.subdomainId); - - logger.log(`Deleted sending subdomain: ${args.subdomainId}`); - }, -}); diff --git a/packages/wrangler/src/email-routing/sending/subdomains/get.ts b/packages/wrangler/src/email-routing/sending/subdomains/get.ts deleted file mode 100644 index 317b8c43f9..0000000000 --- a/packages/wrangler/src/email-routing/sending/subdomains/get.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createCommand } from "../../../core/create-command"; -import { logger } from "../../../logger"; -import { getEmailSendingSubdomain } from "../../client"; -import { zoneArgs } from "../../index"; -import { resolveZoneId } from "../../utils"; - -export const emailSendingSubdomainsGetCommand = createCommand({ - metadata: { - description: "Get a specific Email Sending subdomain", - status: "open beta", - owner: "Product: Email Service", - }, - args: { - ...zoneArgs, - "subdomain-id": { - type: "string", - demandOption: true, - description: "The sending subdomain identifier (tag)", - }, - }, - positionalArgs: ["subdomain-id"], - async handler(args, { config }) { - const zoneId = await resolveZoneId(config, args); - const subdomain = await getEmailSendingSubdomain( - config, - zoneId, - args.subdomainId - ); - - logger.log(`Sending subdomain: ${subdomain.name}`); - logger.log(` Tag: ${subdomain.tag}`); - logger.log( - ` Sending enabled: ${subdomain.email_sending_enabled}` - ); - logger.log( - ` DKIM selector: ${subdomain.email_sending_dkim_selector || "(none)"}` - ); - logger.log( - ` Return path: ${subdomain.email_sending_return_path_domain || "(none)"}` - ); - logger.log(` Created: ${subdomain.created || "(unknown)"}`); - logger.log(` Modified: ${subdomain.modified || "(unknown)"}`); - }, -}); diff --git a/packages/wrangler/src/email-routing/sending/subdomains/list.ts b/packages/wrangler/src/email-routing/sending/subdomains/list.ts deleted file mode 100644 index fd22025530..0000000000 --- a/packages/wrangler/src/email-routing/sending/subdomains/list.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createCommand } from "../../../core/create-command"; -import { logger } from "../../../logger"; -import { listEmailSendingSubdomains } from "../../client"; -import { zoneArgs } from "../../index"; -import { resolveZoneId } from "../../utils"; - -export const emailSendingSubdomainsListCommand = createCommand({ - metadata: { - description: "List Email Sending subdomains", - status: "open beta", - owner: "Product: Email Service", - }, - args: { - ...zoneArgs, - }, - async handler(args, { config }) { - const zoneId = await resolveZoneId(config, args); - const subdomains = await listEmailSendingSubdomains(config, zoneId); - - if (subdomains.length === 0) { - logger.log("No sending subdomains found."); - return; - } - - logger.table( - subdomains.map((s) => ({ - tag: s.tag, - name: s.name, - "sending enabled": s.email_sending_enabled ? "yes" : "no", - "dkim selector": s.email_sending_dkim_selector || "", - "return path": s.email_sending_return_path_domain || "", - created: s.created || "", - })) - ); - }, -}); diff --git a/packages/wrangler/src/email-routing/utils.ts b/packages/wrangler/src/email-routing/utils.ts index fa50431808..f8baa31391 100644 --- a/packages/wrangler/src/email-routing/utils.ts +++ b/packages/wrangler/src/email-routing/utils.ts @@ -48,3 +48,46 @@ async function getZoneIdByDomain( return zoneId; } + +export interface ResolvedDomain { + zoneId: string; + zoneName: string; + isSubdomain: boolean; + domain: string; +} + +export async function resolveDomain( + config: Config, + domain: string +): Promise { + const accountId = await requireAuth(config); + + // Walk up the domain labels: try "sub.example.com", then "example.com" + const labels = domain.split("."); + for (let i = 0; i <= labels.length - 2; i++) { + const candidate = labels.slice(i).join("."); + const zones = await retryOnAPIFailure(() => + fetchListResult<{ id: string; name: string }>( + config, + `/zones`, + {}, + new URLSearchParams({ + name: candidate, + "account.id": accountId, + }) + ) + ); + if (zones[0]) { + return { + zoneId: zones[0].id, + zoneName: zones[0].name, + isSubdomain: domain !== zones[0].name, + domain, + }; + } + } + + throw new UserError( + `Could not find a zone for \`${domain}\`. Make sure the domain or its parent zone exists in your account.` + ); +} diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 4e6e8bf2c6..4d21e80f18 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -113,7 +113,6 @@ import { emailRoutingRulesNamespace, emailSendingDnsNamespace, emailSendingNamespace, - emailSendingSubdomainsNamespace, } from "./email-routing/index"; import { emailRoutingListCommand } from "./email-routing/list"; import { emailRoutingRulesCreateCommand } from "./email-routing/rules/create"; @@ -129,10 +128,7 @@ import { emailSendingListCommand } from "./email-routing/sending/list"; import { emailSendingSendCommand } from "./email-routing/sending/send"; import { emailSendingSendRawCommand } from "./email-routing/sending/send-raw"; import { emailSendingSettingsCommand } from "./email-routing/sending/settings"; -import { emailSendingSubdomainsCreateCommand } from "./email-routing/sending/subdomains/create"; -import { emailSendingSubdomainsDeleteCommand } from "./email-routing/sending/subdomains/delete"; -import { emailSendingSubdomainsGetCommand } from "./email-routing/sending/subdomains/get"; -import { emailSendingSubdomainsListCommand } from "./email-routing/sending/subdomains/list"; + import { helloWorldGetCommand, helloWorldNamespace, @@ -1977,26 +1973,6 @@ export function createCLIParser(argv: string[]) { command: "wrangler email sending send-raw", definition: emailSendingSendRawCommand, }, - { - command: "wrangler email sending subdomains", - definition: emailSendingSubdomainsNamespace, - }, - { - command: "wrangler email sending subdomains list", - definition: emailSendingSubdomainsListCommand, - }, - { - command: "wrangler email sending subdomains get", - definition: emailSendingSubdomainsGetCommand, - }, - { - command: "wrangler email sending subdomains create", - definition: emailSendingSubdomainsCreateCommand, - }, - { - command: "wrangler email sending subdomains delete", - definition: emailSendingSubdomainsDeleteCommand, - }, { command: "wrangler email sending dns", definition: emailSendingDnsNamespace, From 0f1b70dca9f54e7b9bd45b1fa08666a6b527cf01 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 22:46:09 -0400 Subject: [PATCH 21/32] chore: update changeset for flattened email sending commands --- ...ail-routing-commands.md => email-service-commands.md} | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) rename .changeset/{email-routing-commands.md => email-service-commands.md} (61%) diff --git a/.changeset/email-routing-commands.md b/.changeset/email-service-commands.md similarity index 61% rename from .changeset/email-routing-commands.md rename to .changeset/email-service-commands.md index 20ef9e0589..c9d7842f99 100644 --- a/.changeset/email-routing-commands.md +++ b/.changeset/email-service-commands.md @@ -15,7 +15,12 @@ Email Routing commands: Email Sending commands: +- `wrangler email sending list` - list zones with email sending +- `wrangler email sending settings ` - get email sending settings for a zone +- `wrangler email sending enable ` - enable email sending for a zone or subdomain +- `wrangler email sending disable ` - disable email sending for a zone or subdomain +- `wrangler email sending dns get ` - get DNS records for a sending domain - `wrangler email sending send` - send an email using the builder API - `wrangler email sending send-raw` - send a raw MIME email message -- `wrangler email sending subdomains list/get/create/delete` - manage sending subdomains -- `wrangler email sending dns get` - get DNS records for a sending subdomain + +Also adds `email_routing:write` and `email_sending:write` OAuth scopes to `wrangler login`. From a46d781f0ebca7d40e60a4f54d7e5b30ab373ca9 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 23:20:57 -0400 Subject: [PATCH 22/32] fix: address review feedback for email routing commands - Add confirmation prompts to destructive commands (disable, dns-unlock, rules delete, addresses delete) with --force/-y bypass - Add EmailSendingSettings type to replace unsafe casts for subdomains - Fix catch-all fallback in rules get to match both tag and id - Fix rules update to include --name in catch-all payload - Fix rules list contradictory output when only catch-all exists - Fix sending dns-get to handle zone-level domains, not just subdomains - Remove dead code (mockListZones, null guard in sending/disable) - Remove emojis from CLI output - Clean up test mock signatures and formatting nits --- .../src/__tests__/email-routing.test.ts | 114 ++++++++---------- .../src/email-routing/addresses/delete.ts | 18 +++ packages/wrangler/src/email-routing/client.ts | 12 +- .../wrangler/src/email-routing/disable.ts | 19 +++ .../wrangler/src/email-routing/dns-unlock.ts | 19 +++ packages/wrangler/src/email-routing/index.ts | 12 +- packages/wrangler/src/email-routing/list.ts | 2 +- .../src/email-routing/rules/delete.ts | 19 +++ .../wrangler/src/email-routing/rules/get.ts | 2 +- .../wrangler/src/email-routing/rules/list.ts | 4 +- .../src/email-routing/rules/update.ts | 1 + .../src/email-routing/sending/disable.ts | 10 +- .../src/email-routing/sending/dns-get.ts | 27 +++-- .../src/email-routing/sending/settings.ts | 6 +- .../src/email-routing/sending/utils.ts | 6 +- 15 files changed, 171 insertions(+), 100 deletions(-) diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index a63e9a2152..e0dd1abbf9 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { endEventLoop } from "./helpers/end-event-loop"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; -import { clearDialogs } from "./helpers/mock-dialogs"; +import { clearDialogs, mockConfirm } from "./helpers/mock-dialogs"; import { useMockIsTTY } from "./helpers/mock-istty"; import { createFetchResult, msw } from "./helpers/msw"; import { runInTempDir } from "./helpers/run-in-tmp"; @@ -11,13 +11,6 @@ import { runWrangler } from "./helpers/run-wrangler"; // --- Mock data --- -const mockZone = { - id: "zone-id-1", - name: "example.com", - status: "active", - account: { id: "some-account-id", name: "Test Account" }, -}; - const mockSettings = { id: "75610dab9e69410a82cf7e400a09ecec", enabled: true, @@ -248,7 +241,7 @@ describe("email routing commands", () => { describe("settings", () => { it("should get settings with --zone-id", async ({ expect }) => { - mockGetSettings("zone-id-1", mockSettings); + mockGetSettings(mockSettings); await runWrangler("email routing settings --zone-id zone-id-1"); @@ -259,7 +252,7 @@ describe("email routing commands", () => { it("should get settings with --zone (domain resolution)", async ({ expect }) => { mockZoneLookup("example.com", "zone-id-1"); - mockGetSettings("zone-id-1", mockSettings); + mockGetSettings(mockSettings); await runWrangler("email routing settings --zone example.com"); @@ -271,7 +264,7 @@ describe("email routing commands", () => { describe("enable", () => { it("should enable email routing", async ({ expect }) => { - mockEnableEmailRouting("zone-id-1", mockSettings); + mockEnableEmailRouting(mockSettings); await runWrangler("email routing enable --zone-id zone-id-1"); @@ -283,7 +276,11 @@ describe("email routing commands", () => { describe("disable", () => { it("should disable email routing", async ({ expect }) => { - mockDisableEmailRouting("zone-id-1", { + mockConfirm({ + text: "Are you sure you want to disable Email Routing for this zone?", + result: true, + }); + mockDisableEmailRouting({ ...mockSettings, enabled: false, }); @@ -298,7 +295,7 @@ describe("email routing commands", () => { describe("dns get", () => { it("should show dns records", async ({ expect }) => { - mockGetDns("zone-id-1", mockDnsRecords); + mockGetDns(mockDnsRecords); await runWrangler("email routing dns get --zone-id zone-id-1"); @@ -311,7 +308,11 @@ describe("email routing commands", () => { describe("dns unlock", () => { it("should unlock dns records", async ({ expect }) => { - mockUnlockDns("zone-id-1", mockSettings); + mockConfirm({ + text: "Are you sure you want to unlock MX records? This allows external MX records to be set.", + result: true, + }); + mockUnlockDns(mockSettings); await runWrangler("email routing dns unlock --zone-id zone-id-1"); @@ -323,7 +324,7 @@ describe("email routing commands", () => { describe("rules list", () => { it("should list routing rules", async ({ expect }) => { - mockListRules("zone-id-1", [mockRule]); + mockListRules([mockRule]); await runWrangler("email routing rules list --zone-id zone-id-1"); @@ -332,7 +333,7 @@ describe("email routing commands", () => { }); it("should handle no rules", async ({ expect }) => { - mockListRules("zone-id-1", []); + mockListRules([]); await runWrangler("email routing rules list --zone-id zone-id-1"); @@ -344,7 +345,7 @@ describe("email routing commands", () => { describe("rules get", () => { it("should get a specific rule", async ({ expect }) => { - mockGetRule("zone-id-1", "rule-id-1", mockRule); + mockGetRule(mockRule); await runWrangler( "email routing rules get rule-id-1 --zone-id zone-id-1" @@ -356,7 +357,7 @@ describe("email routing commands", () => { }); it("should get the catch-all rule when rule-id is 'catch-all'", async ({ expect }) => { - mockGetCatchAll("zone-id-1", mockCatchAll); + mockGetCatchAll(mockCatchAll); await runWrangler( "email routing rules get catch-all --zone-id zone-id-1" @@ -372,7 +373,7 @@ describe("email routing commands", () => { describe("rules create", () => { it("should create a forwarding rule", async ({ expect }) => { - const reqProm = mockCreateRule("zone-id-1"); + const reqProm = mockCreateRule(); await runWrangler( "email routing rules create --zone-id zone-id-1 --match-type literal --match-field to --match-value user@example.com --action-type forward --action-value dest@example.com --name 'My Rule'" @@ -388,7 +389,7 @@ describe("email routing commands", () => { }); it("should create a drop rule without --action-value", async ({ expect }) => { - const reqProm = mockCreateRule("zone-id-1"); + const reqProm = mockCreateRule(); await runWrangler( "email routing rules create --zone-id zone-id-1 --match-type literal --match-field to --match-value spam@example.com --action-type drop" @@ -417,7 +418,7 @@ describe("email routing commands", () => { describe("rules update", () => { it("should update a routing rule", async ({ expect }) => { - const reqProm = mockUpdateRule("zone-id-1", "rule-id-1"); + const reqProm = mockUpdateRule(); await runWrangler( "email routing rules update rule-id-1 --zone-id zone-id-1 --match-type literal --match-field to --match-value updated@example.com --action-type forward --action-value newdest@example.com" @@ -434,7 +435,7 @@ describe("email routing commands", () => { }); it("should update the catch-all rule to drop", async ({ expect }) => { - const reqProm = mockUpdateCatchAll("zone-id-1"); + const reqProm = mockUpdateCatchAll(); await runWrangler( "email routing rules update catch-all --zone-id zone-id-1 --action-type drop --enabled true" @@ -450,7 +451,7 @@ describe("email routing commands", () => { }); it("should update the catch-all rule to forward", async ({ expect }) => { - const reqProm = mockUpdateCatchAll("zone-id-1"); + const reqProm = mockUpdateCatchAll(); await runWrangler( "email routing rules update catch-all --zone-id zone-id-1 --action-type forward --action-value catchall@example.com" @@ -489,7 +490,11 @@ describe("email routing commands", () => { describe("rules delete", () => { it("should delete a routing rule", async ({ expect }) => { - mockDeleteRule("zone-id-1", "rule-id-1"); + mockConfirm({ + text: "Are you sure you want to delete routing rule 'rule-id-1'?", + result: true, + }); + mockDeleteRule(); await runWrangler( "email routing rules delete rule-id-1 --zone-id zone-id-1" @@ -524,7 +529,7 @@ describe("email routing commands", () => { describe("addresses get", () => { it("should get a destination address", async ({ expect }) => { - mockGetAddress("addr-id-1", mockAddress); + mockGetAddress(mockAddress); await runWrangler("email routing addresses get addr-id-1"); @@ -552,7 +557,11 @@ describe("email routing commands", () => { describe("addresses delete", () => { it("should delete a destination address", async ({ expect }) => { - mockDeleteAddress("addr-id-1"); + mockConfirm({ + text: "Are you sure you want to delete destination address 'addr-id-1'?", + result: true, + }); + mockDeleteAddress(); await runWrangler("email routing addresses delete addr-id-1"); @@ -796,25 +805,6 @@ function mockListEmailRoutingZones(settings: (typeof mockSettings)[]) { ); } -function mockListZones( - zones: Array<{ - id: string; - name: string; - status: string; - account: { id: string; name: string }; - }> -) { - msw.use( - http.get( - "*/zones", - () => { - return HttpResponse.json(createFetchResult(zones, true)); - }, - { once: true } - ) - ); -} - function mockZoneLookup(domain: string, zoneId: string) { // Extract the zone name (last two labels) to handle subdomain lookups // resolveDomain walks up labels, so "sub.example.com" tries "sub.example.com" then "example.com" @@ -837,7 +827,7 @@ function mockZoneLookup(domain: string, zoneId: string) { ); } -function mockGetSettings(_zoneId: string, settings: typeof mockSettings) { +function mockGetSettings(settings: typeof mockSettings) { msw.use( http.get( "*/zones/:zoneId/email/routing", @@ -849,10 +839,7 @@ function mockGetSettings(_zoneId: string, settings: typeof mockSettings) { ); } -function mockEnableEmailRouting( - _zoneId: string, - settings: typeof mockSettings -) { +function mockEnableEmailRouting(settings: typeof mockSettings) { msw.use( http.post( "*/zones/:zoneId/email/routing/enable", @@ -864,10 +851,7 @@ function mockEnableEmailRouting( ); } -function mockDisableEmailRouting( - _zoneId: string, - settings: typeof mockSettings -) { +function mockDisableEmailRouting(settings: typeof mockSettings) { msw.use( http.post( "*/zones/:zoneId/email/routing/disable", @@ -879,7 +863,7 @@ function mockDisableEmailRouting( ); } -function mockGetDns(_zoneId: string, records: typeof mockDnsRecords) { +function mockGetDns(records: typeof mockDnsRecords) { msw.use( http.get( "*/zones/:zoneId/email/routing/dns", @@ -891,7 +875,7 @@ function mockGetDns(_zoneId: string, records: typeof mockDnsRecords) { ); } -function mockUnlockDns(_zoneId: string, settings: typeof mockSettings) { +function mockUnlockDns(settings: typeof mockSettings) { msw.use( http.post( "*/zones/:zoneId/email/routing/unlock", @@ -903,7 +887,7 @@ function mockUnlockDns(_zoneId: string, settings: typeof mockSettings) { ); } -function mockListRules(_zoneId: string, rules: (typeof mockRule)[]) { +function mockListRules(rules: (typeof mockRule)[]) { msw.use( http.get( "*/zones/:zoneId/email/routing/rules", @@ -915,7 +899,7 @@ function mockListRules(_zoneId: string, rules: (typeof mockRule)[]) { ); } -function mockGetRule(_zoneId: string, _ruleId: string, rule: typeof mockRule) { +function mockGetRule(rule: typeof mockRule) { msw.use( http.get( "*/zones/:zoneId/email/routing/rules/:ruleId", @@ -927,7 +911,7 @@ function mockGetRule(_zoneId: string, _ruleId: string, rule: typeof mockRule) { ); } -function mockCreateRule(_zoneId: string): Promise { +function mockCreateRule(): Promise { return new Promise((resolve) => { msw.use( http.post( @@ -946,7 +930,7 @@ function mockCreateRule(_zoneId: string): Promise { }); } -function mockUpdateRule(_zoneId: string, _ruleId: string): Promise { +function mockUpdateRule(): Promise { return new Promise((resolve) => { msw.use( http.put( @@ -965,7 +949,7 @@ function mockUpdateRule(_zoneId: string, _ruleId: string): Promise { }); } -function mockDeleteRule(_zoneId: string, _ruleId: string) { +function mockDeleteRule() { msw.use( http.delete( "*/zones/:zoneId/email/routing/rules/:ruleId", @@ -977,7 +961,7 @@ function mockDeleteRule(_zoneId: string, _ruleId: string) { ); } -function mockGetCatchAll(_zoneId: string, catchAll: typeof mockCatchAll) { +function mockGetCatchAll(catchAll: typeof mockCatchAll) { msw.use( http.get( "*/zones/:zoneId/email/routing/rules/catch_all", @@ -989,7 +973,7 @@ function mockGetCatchAll(_zoneId: string, catchAll: typeof mockCatchAll) { ); } -function mockUpdateCatchAll(_zoneId: string): Promise { +function mockUpdateCatchAll(): Promise { return new Promise((resolve) => { msw.use( http.put( @@ -1020,7 +1004,7 @@ function mockListAddresses(addresses: (typeof mockAddress)[]) { ); } -function mockGetAddress(_addressId: string, address: typeof mockAddress) { +function mockGetAddress(address: typeof mockAddress) { msw.use( http.get( "*/accounts/:accountId/email/routing/addresses/:addressId", @@ -1057,7 +1041,7 @@ function mockCreateAddress() { ); } -function mockDeleteAddress(_addressId: string) { +function mockDeleteAddress() { msw.use( http.delete( "*/accounts/:accountId/email/routing/addresses/:addressId", diff --git a/packages/wrangler/src/email-routing/addresses/delete.ts b/packages/wrangler/src/email-routing/addresses/delete.ts index 1f107ce795..361b08732a 100644 --- a/packages/wrangler/src/email-routing/addresses/delete.ts +++ b/packages/wrangler/src/email-routing/addresses/delete.ts @@ -1,4 +1,5 @@ import { createCommand } from "../../core/create-command"; +import { confirm } from "../../dialogs"; import { logger } from "../../logger"; import { deleteEmailRoutingAddress } from "../client"; @@ -14,9 +15,26 @@ export const emailRoutingAddressesDeleteCommand = createCommand({ demandOption: true, description: "The ID of the destination address to delete", }, + force: { + type: "boolean", + alias: "y", + description: "Skip confirmation", + default: false, + }, }, positionalArgs: ["address-id"], async handler(args, { config }) { + if (!args.force) { + const confirmed = await confirm( + `Are you sure you want to delete destination address '${args.addressId}'?`, + { fallbackValue: false } + ); + if (!confirmed) { + logger.log("Not deleting."); + return; + } + } + await deleteEmailRoutingAddress(config, args.addressId); logger.log(`Deleted destination address: ${args.addressId}`); diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index 021eec57ae..1e1343ec75 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -8,6 +8,7 @@ import type { EmailRoutingSettings, EmailSendingDnsRecord, EmailSendingSendResponse, + EmailSendingSettings, } from "./index"; import type { Config } from "@cloudflare/workers-utils"; @@ -21,7 +22,6 @@ export async function listEmailRoutingZones( ); } - export async function listEmailSendingZones( config: Config ): Promise { @@ -43,7 +43,6 @@ export async function getEmailRoutingSettings( ); } - export async function enableEmailRouting( config: Config, zoneId: string @@ -103,7 +102,6 @@ export async function unlockEmailRoutingDns( ); } - export async function listEmailRoutingRules( config: Config, zoneId: string @@ -191,7 +189,6 @@ export async function deleteEmailRoutingRule( ); } - export async function getEmailRoutingCatchAll( config: Config, zoneId: string @@ -225,7 +222,6 @@ export async function updateEmailRoutingCatchAll( ); } - export async function listEmailRoutingAddresses( config: Config ): Promise { @@ -279,13 +275,12 @@ export async function deleteEmailRoutingAddress( ); } - export async function getEmailSendingSettings( config: Config, zoneId: string -): Promise { +): Promise { await requireAuth(config); - return await fetchResult( + return await fetchResult( config, `/zones/${zoneId}/email/sending` ); @@ -337,7 +332,6 @@ export async function getEmailSendingSubdomainDns( ); } - export async function sendEmail( config: Config, body: { diff --git a/packages/wrangler/src/email-routing/disable.ts b/packages/wrangler/src/email-routing/disable.ts index ff6c527e88..ffd8ec2f8a 100644 --- a/packages/wrangler/src/email-routing/disable.ts +++ b/packages/wrangler/src/email-routing/disable.ts @@ -1,4 +1,5 @@ import { createCommand } from "../core/create-command"; +import { confirm } from "../dialogs"; import { logger } from "../logger"; import { disableEmailRouting } from "./client"; import { zoneArgs } from "./index"; @@ -12,9 +13,27 @@ export const emailRoutingDisableCommand = createCommand({ }, args: { ...zoneArgs, + force: { + type: "boolean", + alias: "y", + description: "Skip confirmation", + default: false, + }, }, async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); + + if (!args.force) { + const confirmed = await confirm( + "Are you sure you want to disable Email Routing for this zone?", + { fallbackValue: false } + ); + if (!confirmed) { + logger.log("Not disabling."); + return; + } + } + const settings = await disableEmailRouting(config, zoneId); logger.log( diff --git a/packages/wrangler/src/email-routing/dns-unlock.ts b/packages/wrangler/src/email-routing/dns-unlock.ts index 53f134ef75..d607794e48 100644 --- a/packages/wrangler/src/email-routing/dns-unlock.ts +++ b/packages/wrangler/src/email-routing/dns-unlock.ts @@ -1,4 +1,5 @@ import { createCommand } from "../core/create-command"; +import { confirm } from "../dialogs"; import { logger } from "../logger"; import { unlockEmailRoutingDns } from "./client"; import { zoneArgs } from "./index"; @@ -12,9 +13,27 @@ export const emailRoutingDnsUnlockCommand = createCommand({ }, args: { ...zoneArgs, + force: { + type: "boolean", + alias: "y", + description: "Skip confirmation", + default: false, + }, }, async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); + + if (!args.force) { + const confirmed = await confirm( + "Are you sure you want to unlock MX records? This allows external MX records to be set.", + { fallbackValue: false } + ); + if (!confirmed) { + logger.log("Not unlocking."); + return; + } + } + const settings = await unlockEmailRoutingDns(config, zoneId); logger.log( diff --git a/packages/wrangler/src/email-routing/index.ts b/packages/wrangler/src/email-routing/index.ts index 689fac43f3..ba6c3e051d 100644 --- a/packages/wrangler/src/email-routing/index.ts +++ b/packages/wrangler/src/email-routing/index.ts @@ -69,7 +69,6 @@ export const zoneArgs = { }, } as const; - export interface EmailRoutingSettings { id: string; enabled: boolean; @@ -128,6 +127,17 @@ export interface EmailRoutingAddress { verified: string; } +export interface EmailSendingSubdomain { + tag: string; + name: string; + enabled: boolean; + status?: string; +} + +export interface EmailSendingSettings extends EmailRoutingSettings { + subdomains?: EmailSendingSubdomain[]; +} + export interface EmailSendingDnsRecord { content?: string; name?: string; diff --git a/packages/wrangler/src/email-routing/list.ts b/packages/wrangler/src/email-routing/list.ts index b236ebe744..fc46509145 100644 --- a/packages/wrangler/src/email-routing/list.ts +++ b/packages/wrangler/src/email-routing/list.ts @@ -21,7 +21,7 @@ export const emailRoutingListCommand = createCommand({ zone: zone.name, "zone id": zone.id, enabled: zone.enabled ? "yes" : "no", - status: zone.status, + status: zone.status ?? "", })); logger.table(results); diff --git a/packages/wrangler/src/email-routing/rules/delete.ts b/packages/wrangler/src/email-routing/rules/delete.ts index 341455cbdf..634125e74f 100644 --- a/packages/wrangler/src/email-routing/rules/delete.ts +++ b/packages/wrangler/src/email-routing/rules/delete.ts @@ -1,4 +1,5 @@ import { createCommand } from "../../core/create-command"; +import { confirm } from "../../dialogs"; import { logger } from "../../logger"; import { deleteEmailRoutingRule } from "../client"; import { zoneArgs } from "../index"; @@ -17,10 +18,28 @@ export const emailRoutingRulesDeleteCommand = createCommand({ demandOption: true, description: "The ID of the routing rule to delete", }, + force: { + type: "boolean", + alias: "y", + description: "Skip confirmation", + default: false, + }, }, positionalArgs: ["rule-id"], async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); + + if (!args.force) { + const confirmed = await confirm( + `Are you sure you want to delete routing rule '${args.ruleId}'?`, + { fallbackValue: false } + ); + if (!confirmed) { + logger.log("Not deleting."); + return; + } + } + await deleteEmailRoutingRule(config, zoneId, args.ruleId); logger.log(`Deleted routing rule: ${args.ruleId}`); diff --git a/packages/wrangler/src/email-routing/rules/get.ts b/packages/wrangler/src/email-routing/rules/get.ts index e3388f6d40..499164a1ee 100644 --- a/packages/wrangler/src/email-routing/rules/get.ts +++ b/packages/wrangler/src/email-routing/rules/get.ts @@ -54,7 +54,7 @@ export const emailRoutingRulesGetCommand = createCommand({ } const catchAllRule = await getEmailRoutingCatchAll(config, zoneId); - if (catchAllRule.tag === args.ruleId) { + if (catchAllRule.tag === args.ruleId || catchAllRule.id === args.ruleId) { logger.log(`Catch-all rule:`); logger.log(` Enabled: ${catchAllRule.enabled}`); logger.log(` Actions:`); diff --git a/packages/wrangler/src/email-routing/rules/list.ts b/packages/wrangler/src/email-routing/rules/list.ts index 0dbf17caa7..7ee5c2d9a4 100644 --- a/packages/wrangler/src/email-routing/rules/list.ts +++ b/packages/wrangler/src/email-routing/rules/list.ts @@ -24,8 +24,10 @@ export const emailRoutingRulesListCommand = createCommand({ (r) => !r.matchers.some((m) => m.type === "all") ); - if (regularRules.length === 0) { + if (regularRules.length === 0 && !catchAll) { logger.log("No routing rules found."); + } else if (regularRules.length === 0) { + logger.log("No custom routing rules found."); } else { logger.table( regularRules.map((r) => ({ diff --git a/packages/wrangler/src/email-routing/rules/update.ts b/packages/wrangler/src/email-routing/rules/update.ts index 7976d5b303..e3dab8d4a8 100644 --- a/packages/wrangler/src/email-routing/rules/update.ts +++ b/packages/wrangler/src/email-routing/rules/update.ts @@ -117,6 +117,7 @@ export const emailRoutingRulesUpdateCommand = createCommand({ ], matchers: [{ type: "all" }], enabled: args.enabled, + name: args.name, }); logger.log(`Updated catch-all rule:`); diff --git a/packages/wrangler/src/email-routing/sending/disable.ts b/packages/wrangler/src/email-routing/sending/disable.ts index bf8592c0dd..0251cdbeea 100644 --- a/packages/wrangler/src/email-routing/sending/disable.ts +++ b/packages/wrangler/src/email-routing/sending/disable.ts @@ -26,12 +26,8 @@ export const emailSendingDisableCommand = createCommand({ const name = isSubdomain ? domain : undefined; const settings = await disableEmailSending(config, zoneId, name); - if (settings) { - logger.log( - `Email Sending disabled for ${settings.name} (status: ${settings.status})` - ); - } else { - logger.log(`Email Sending disabled for ${domain}`); - } + logger.log( + `Email Sending disabled for ${settings.name} (status: ${settings.status})` + ); }, }); diff --git a/packages/wrangler/src/email-routing/sending/dns-get.ts b/packages/wrangler/src/email-routing/sending/dns-get.ts index 96ddd7c458..a80f685945 100644 --- a/packages/wrangler/src/email-routing/sending/dns-get.ts +++ b/packages/wrangler/src/email-routing/sending/dns-get.ts @@ -1,7 +1,10 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { getEmailSendingSettings, getEmailSendingSubdomainDns } from "../client"; +import { + getEmailSendingSettings, + getEmailSendingSubdomainDns, +} from "../client"; import { resolveDomain } from "../utils"; export const emailSendingDnsGetCommand = createCommand({ @@ -22,14 +25,22 @@ export const emailSendingDnsGetCommand = createCommand({ async handler(args, { config }) { const { zoneId } = await resolveDomain(config, args.domain); - // Find the subdomain tag by matching the domain name in settings + // Find the sending domain tag by matching the domain name in settings. + // The domain may be the zone itself or a subdomain listed in settings.subdomains. const settings = await getEmailSendingSettings(config, zoneId); - const subdomains = (settings as Record).subdomains as - | Array<{ tag: string; name: string }> - | undefined; + const subdomains = settings.subdomains; - const match = subdomains?.find((s) => s.name === args.domain); - if (!match) { + let tag: string | undefined; + if (settings.name === args.domain) { + // Zone-level sending domain — use the zone's own tag + tag = settings.tag; + } else { + // Subdomain — look up in the subdomains list + const match = subdomains?.find((s) => s.name === args.domain); + tag = match?.tag; + } + + if (!tag) { throw new UserError( `No sending domain found for \`${args.domain}\`. Run \`wrangler email sending settings ${args.domain}\` to see configured domains.` ); @@ -38,7 +49,7 @@ export const emailSendingDnsGetCommand = createCommand({ const records = await getEmailSendingSubdomainDns( config, zoneId, - match.tag + tag ); if (records.length === 0) { diff --git a/packages/wrangler/src/email-routing/sending/settings.ts b/packages/wrangler/src/email-routing/sending/settings.ts index 2302eab448..9593ea34fa 100644 --- a/packages/wrangler/src/email-routing/sending/settings.ts +++ b/packages/wrangler/src/email-routing/sending/settings.ts @@ -27,10 +27,8 @@ export const emailSendingSettingsCommand = createCommand({ logger.log(` Created: ${settings.created}`); logger.log(` Modified: ${settings.modified}`); - const subdomains = (settings as Record).subdomains as - | Array<{ name: string; enabled: boolean; status?: string }> - | undefined; - if (subdomains && subdomains.length > 0) { + const subdomains = settings.subdomains; + if (Array.isArray(subdomains) && subdomains.length > 0) { logger.log(` Subdomains:`); for (const s of subdomains) { logger.log( diff --git a/packages/wrangler/src/email-routing/sending/utils.ts b/packages/wrangler/src/email-routing/sending/utils.ts index 62d6278111..86e844ed53 100644 --- a/packages/wrangler/src/email-routing/sending/utils.ts +++ b/packages/wrangler/src/email-routing/sending/utils.ts @@ -3,10 +3,10 @@ import type { EmailSendingSendResponse } from "../index"; export function logSendResult(result: EmailSendingSendResponse): void { if (result.delivered.length > 0) { - logger.log(`✅ Delivered to: ${result.delivered.join(", ")}`); + logger.log(`Delivered to: ${result.delivered.join(", ")}`); } if (result.queued.length > 0) { - logger.log(`📬 Queued for: ${result.queued.join(", ")}`); + logger.log(`Queued for: ${result.queued.join(", ")}`); } if (result.permanent_bounces.length > 0) { logger.warn( @@ -18,6 +18,6 @@ export function logSendResult(result: EmailSendingSendResponse): void { result.queued.length === 0 && result.permanent_bounces.length === 0 ) { - logger.log("✅ Email sent successfully."); + logger.log("Email sent successfully."); } } From bc60f8ba54c32ced1555d7dfb1783ae3fda6aa5f Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 23:27:51 -0400 Subject: [PATCH 23/32] fix: validate header names and deduplicate send result logging - Reject empty header names in --header parsing (e.g. ':value') - Extract shared logSendResult() into sending/utils.ts to deduplicate inline emoji-laden logging in send.ts and send-raw.ts - Add tests for malformed header input - Remove emojis from send result output --- .../wrangler/src/__tests__/email-routing.test.ts | 16 ++++++++++++++++ .../wrangler/src/email-routing/sending/send.ts | 12 +++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index e0dd1abbf9..dbcf35c145 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -732,6 +732,22 @@ describe("email sending commands", () => { }); }); + it("should error on malformed header with empty name", async ({ expect }) => { + await expect( + runWrangler( + "email sending send --from sender@example.com --to recipient@example.com --subject 'Test' --text 'Hi' --header ':value'" + ) + ).rejects.toThrow("Header name cannot be empty"); + }); + + it("should error on header without colon separator", async ({ expect }) => { + await expect( + runWrangler( + "email sending send --from sender@example.com --to recipient@example.com --subject 'Test' --text 'Hi' --header 'NoColon'" + ) + ).rejects.toThrow("Expected 'Key:Value'"); + }); + it("should error when neither --text nor --html is provided", async ({ expect }) => { await expect( runWrangler( diff --git a/packages/wrangler/src/email-routing/sending/send.ts b/packages/wrangler/src/email-routing/sending/send.ts index a965fec82d..f0cdd48078 100644 --- a/packages/wrangler/src/email-routing/sending/send.ts +++ b/packages/wrangler/src/email-routing/sending/send.ts @@ -1,8 +1,7 @@ +import { UserError } from "@cloudflare/workers-utils"; import { readFileSync } from "node:fs"; import path from "node:path"; -import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; -import { logger } from "../../logger"; import { sendEmail } from "../client"; import { logSendResult } from "./utils"; @@ -124,7 +123,14 @@ function parseHeaders( `Invalid header format: '${h}'. Expected 'Key:Value'.` ); } - headers.set(h.slice(0, colonIndex).trim(), h.slice(colonIndex + 1).trim()); + const key = h.slice(0, colonIndex).trim(); + const value = h.slice(colonIndex + 1).trim(); + if (!key) { + throw new UserError( + `Invalid header format: '${h}'. Header name cannot be empty.` + ); + } + headers.set(key, value); } return headers; } From 3c295455cf0dd4819e9e9c3f86b08965c32642da Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Wed, 1 Apr 2026 23:45:06 -0400 Subject: [PATCH 24/32] fix: add --zone-id support to sending commands, confirmation to disable, zone-level DNS - Add optional --zone-id flag to all sending commands (enable, disable, settings, dns get) to skip zone lookup for tokens without zone:read - Add confirmation prompt to email sending disable (matches routing disable) - Add zone-level /email/sending/dns endpoint for apex domain DNS records, use subdomain endpoint only for actual subdomains - Update resolveDomain() to accept optional zoneId override --- .../src/__tests__/email-routing.test.ts | 4 ++ packages/wrangler/src/email-routing/client.ts | 11 ++++ .../src/email-routing/sending/disable.ts | 27 +++++++++- .../src/email-routing/sending/dns-get.ts | 53 +++++++++++-------- .../src/email-routing/sending/enable.ts | 8 ++- .../src/email-routing/sending/settings.ts | 7 ++- packages/wrangler/src/email-routing/utils.ts | 16 +++++- 7 files changed, 99 insertions(+), 27 deletions(-) diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index dbcf35c145..116842fde2 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -615,6 +615,10 @@ describe("email sending commands", () => { describe("disable", () => { it("should disable sending for a zone", async ({ expect }) => { + mockConfirm({ + text: "Are you sure you want to disable Email Sending for example.com?", + result: true, + }); mockZoneLookup("example.com", "zone-id-1"); mockDisableSending("zone-id-1"); diff --git a/packages/wrangler/src/email-routing/client.ts b/packages/wrangler/src/email-routing/client.ts index 1e1343ec75..5c4c1a0bad 100644 --- a/packages/wrangler/src/email-routing/client.ts +++ b/packages/wrangler/src/email-routing/client.ts @@ -320,6 +320,17 @@ export async function disableEmailSending( ); } +export async function getEmailSendingDns( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending/dns` + ); +} + export async function getEmailSendingSubdomainDns( config: Config, zoneId: string, diff --git a/packages/wrangler/src/email-routing/sending/disable.ts b/packages/wrangler/src/email-routing/sending/disable.ts index 0251cdbeea..b71bcca427 100644 --- a/packages/wrangler/src/email-routing/sending/disable.ts +++ b/packages/wrangler/src/email-routing/sending/disable.ts @@ -1,4 +1,5 @@ import { createCommand } from "../../core/create-command"; +import { confirm } from "../../dialogs"; import { logger } from "../../logger"; import { disableEmailSending } from "../client"; import { resolveDomain } from "../utils"; @@ -16,13 +17,37 @@ export const emailSendingDisableCommand = createCommand({ description: "Domain to disable sending for (e.g. example.com or notifications.example.com)", }, + "zone-id": { + type: "string", + description: + "Zone ID (optional, skips zone lookup if provided)", + }, + force: { + type: "boolean", + alias: "y", + description: "Skip confirmation", + default: false, + }, }, positionalArgs: ["domain"], async handler(args, { config }) { const { zoneId, isSubdomain, domain } = await resolveDomain( config, - args.domain + args.domain, + args.zoneId ); + + if (!args.force) { + const confirmed = await confirm( + `Are you sure you want to disable Email Sending for ${domain}?`, + { fallbackValue: false } + ); + if (!confirmed) { + logger.log("Not disabling."); + return; + } + } + const name = isSubdomain ? domain : undefined; const settings = await disableEmailSending(config, zoneId, name); diff --git a/packages/wrangler/src/email-routing/sending/dns-get.ts b/packages/wrangler/src/email-routing/sending/dns-get.ts index a80f685945..467b32f415 100644 --- a/packages/wrangler/src/email-routing/sending/dns-get.ts +++ b/packages/wrangler/src/email-routing/sending/dns-get.ts @@ -2,10 +2,12 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { + getEmailSendingDns, getEmailSendingSettings, getEmailSendingSubdomainDns, } from "../client"; import { resolveDomain } from "../utils"; +import type { EmailSendingDnsRecord } from "../index"; export const emailSendingDnsGetCommand = createCommand({ metadata: { @@ -20,38 +22,43 @@ export const emailSendingDnsGetCommand = createCommand({ description: "Domain to get DNS records for (e.g. example.com or notifications.example.com)", }, + "zone-id": { + type: "string", + description: + "Zone ID (optional, skips zone lookup if provided)", + }, }, positionalArgs: ["domain"], async handler(args, { config }) { - const { zoneId } = await resolveDomain(config, args.domain); + const { zoneId, isSubdomain } = await resolveDomain( + config, + args.domain, + args.zoneId + ); - // Find the sending domain tag by matching the domain name in settings. - // The domain may be the zone itself or a subdomain listed in settings.subdomains. - const settings = await getEmailSendingSettings(config, zoneId); - const subdomains = settings.subdomains; + let records: EmailSendingDnsRecord[]; - let tag: string | undefined; - if (settings.name === args.domain) { - // Zone-level sending domain — use the zone's own tag - tag = settings.tag; + if (!isSubdomain) { + // Zone-level sending domain uses /email/sending/dns + records = await getEmailSendingDns(config, zoneId); } else { - // Subdomain — look up in the subdomains list - const match = subdomains?.find((s) => s.name === args.domain); - tag = match?.tag; - } - - if (!tag) { - throw new UserError( - `No sending domain found for \`${args.domain}\`. Run \`wrangler email sending settings ${args.domain}\` to see configured domains.` + // Subdomain — find the tag from settings and use the subdomain DNS endpoint + const settings = await getEmailSendingSettings(config, zoneId); + const match = settings.subdomains?.find( + (s) => s.name === args.domain + ); + if (!match) { + throw new UserError( + `No sending subdomain found for \`${args.domain}\`. Run \`wrangler email sending settings ${args.domain}\` to see configured domains.` + ); + } + records = await getEmailSendingSubdomainDns( + config, + zoneId, + match.tag ); } - const records = await getEmailSendingSubdomainDns( - config, - zoneId, - tag - ); - if (records.length === 0) { logger.log("No DNS records found for this sending domain."); return; diff --git a/packages/wrangler/src/email-routing/sending/enable.ts b/packages/wrangler/src/email-routing/sending/enable.ts index a29e01ed60..41f20fd9f5 100644 --- a/packages/wrangler/src/email-routing/sending/enable.ts +++ b/packages/wrangler/src/email-routing/sending/enable.ts @@ -16,12 +16,18 @@ export const emailSendingEnableCommand = createCommand({ description: "Domain to enable sending for (e.g. example.com or notifications.example.com)", }, + "zone-id": { + type: "string", + description: + "Zone ID (optional, skips zone lookup if provided)", + }, }, positionalArgs: ["domain"], async handler(args, { config }) { const { zoneId, isSubdomain, domain } = await resolveDomain( config, - args.domain + args.domain, + args.zoneId ); const name = isSubdomain ? domain : undefined; const settings = await enableEmailSending(config, zoneId, name); diff --git a/packages/wrangler/src/email-routing/sending/settings.ts b/packages/wrangler/src/email-routing/sending/settings.ts index 9593ea34fa..4fbc6c922f 100644 --- a/packages/wrangler/src/email-routing/sending/settings.ts +++ b/packages/wrangler/src/email-routing/sending/settings.ts @@ -15,10 +15,15 @@ export const emailSendingSettingsCommand = createCommand({ demandOption: true, description: "Domain to get sending settings for (e.g. example.com)", }, + "zone-id": { + type: "string", + description: + "Zone ID (optional, skips zone lookup if provided)", + }, }, positionalArgs: ["domain"], async handler(args, { config }) { - const { zoneId } = await resolveDomain(config, args.domain); + const { zoneId } = await resolveDomain(config, args.domain, args.zoneId); const settings = await getEmailSendingSettings(config, zoneId); logger.log(`Email Sending for ${settings.name}:`); diff --git a/packages/wrangler/src/email-routing/utils.ts b/packages/wrangler/src/email-routing/utils.ts index f8baa31391..2a4a5e19d8 100644 --- a/packages/wrangler/src/email-routing/utils.ts +++ b/packages/wrangler/src/email-routing/utils.ts @@ -58,8 +58,22 @@ export interface ResolvedDomain { export async function resolveDomain( config: Config, - domain: string + domain: string, + zoneId?: string ): Promise { + // If zone ID is provided directly, skip the zone lookup + if (zoneId) { + // We don't know the zone name without a lookup, so approximate from the domain + const labels = domain.split("."); + const zoneName = labels.slice(-2).join("."); + return { + zoneId, + zoneName, + isSubdomain: domain !== zoneName, + domain, + }; + } + const accountId = await requireAuth(config); // Walk up the domain labels: try "sub.example.com", then "example.com" From 12883ab6b6600c797442fc99d22978516dc29f90 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 2 Apr 2026 00:14:25 -0400 Subject: [PATCH 25/32] refactor(wrangler): use positional domain arg for email routing commands Replace --zone/--zone-id flags with a positional domain argument for all email routing zone-scoped commands, matching the pattern already used by email sending commands. Before: wrangler email routing settings --zone example.com After: wrangler email routing settings example.com --zone-id is kept as an optional flag to skip zone lookup. Rules commands with rule-id use two positionals: domain then rule-id (e.g. wrangler email routing rules get example.com rule-id-1). --- .../src/__tests__/email-routing.test.ts | 58 +++++++------------ .../wrangler/src/email-routing/disable.ts | 5 +- .../wrangler/src/email-routing/dns-get.ts | 5 +- .../wrangler/src/email-routing/dns-unlock.ts | 5 +- packages/wrangler/src/email-routing/enable.ts | 5 +- packages/wrangler/src/email-routing/index.ts | 11 ++-- packages/wrangler/src/email-routing/list.ts | 1 - .../src/email-routing/rules/create.ts | 5 +- .../src/email-routing/rules/delete.ts | 6 +- .../wrangler/src/email-routing/rules/get.ts | 6 +- .../wrangler/src/email-routing/rules/list.ts | 43 +++++++------- .../src/email-routing/rules/update.ts | 13 ++++- .../src/email-routing/sending/list.ts | 1 - .../wrangler/src/email-routing/settings.ts | 5 +- packages/wrangler/src/email-routing/utils.ts | 8 +-- 15 files changed, 88 insertions(+), 89 deletions(-) diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index 116842fde2..112e3eca22 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -205,21 +205,7 @@ describe("email routing commands", () => { // --- zone validation --- describe("zone validation", () => { - it("should error when neither --zone nor --zone-id is provided", async ({ expect }) => { - await expect(runWrangler("email routing settings")).rejects.toThrow( - "You must provide either --zone (domain name) or --zone-id (zone ID)." - ); - }); - - it("should error when both --zone and --zone-id are provided", async ({ expect }) => { - await expect( - runWrangler( - "email routing settings --zone example.com --zone-id zone-id-1" - ) - ).rejects.toThrow(); - }); - - it("should error when --zone domain is not found", async ({ expect }) => { + it("should error when domain is not found", async ({ expect }) => { // Return empty zones list for the domain lookup msw.use( http.get( @@ -232,7 +218,7 @@ describe("email routing commands", () => { ); await expect( - runWrangler("email routing settings --zone notfound.com") + runWrangler("email routing settings notfound.com") ).rejects.toThrow("Could not find zone for `notfound.com`"); }); }); @@ -243,18 +229,18 @@ describe("email routing commands", () => { it("should get settings with --zone-id", async ({ expect }) => { mockGetSettings(mockSettings); - await runWrangler("email routing settings --zone-id zone-id-1"); + await runWrangler("email routing settings example.com --zone-id zone-id-1"); expect(std.out).toContain("Email Routing for example.com"); expect(std.out).toContain("Enabled: true"); expect(std.out).toContain("Status: ready"); }); - it("should get settings with --zone (domain resolution)", async ({ expect }) => { + it("should get settings with domain resolution", async ({ expect }) => { mockZoneLookup("example.com", "zone-id-1"); mockGetSettings(mockSettings); - await runWrangler("email routing settings --zone example.com"); + await runWrangler("email routing settings example.com"); expect(std.out).toContain("Email Routing for example.com"); }); @@ -266,7 +252,7 @@ describe("email routing commands", () => { it("should enable email routing", async ({ expect }) => { mockEnableEmailRouting(mockSettings); - await runWrangler("email routing enable --zone-id zone-id-1"); + await runWrangler("email routing enable example.com --zone-id zone-id-1"); expect(std.out).toContain("Email Routing enabled for example.com"); }); @@ -285,7 +271,7 @@ describe("email routing commands", () => { enabled: false, }); - await runWrangler("email routing disable --zone-id zone-id-1"); + await runWrangler("email routing disable example.com --zone-id zone-id-1"); expect(std.out).toContain("Email Routing disabled"); }); @@ -297,7 +283,7 @@ describe("email routing commands", () => { it("should show dns records", async ({ expect }) => { mockGetDns(mockDnsRecords); - await runWrangler("email routing dns get --zone-id zone-id-1"); + await runWrangler("email routing dns get example.com --zone-id zone-id-1"); expect(std.out).toContain("MX"); expect(std.out).toContain("route1.mx.cloudflare.net"); @@ -314,7 +300,7 @@ describe("email routing commands", () => { }); mockUnlockDns(mockSettings); - await runWrangler("email routing dns unlock --zone-id zone-id-1"); + await runWrangler("email routing dns unlock example.com --zone-id zone-id-1"); expect(std.out).toContain("MX records unlocked for example.com"); }); @@ -326,7 +312,7 @@ describe("email routing commands", () => { it("should list routing rules", async ({ expect }) => { mockListRules([mockRule]); - await runWrangler("email routing rules list --zone-id zone-id-1"); + await runWrangler("email routing rules list example.com --zone-id zone-id-1"); expect(std.out).toContain("rule-id-1"); expect(std.out).toContain("My Rule"); @@ -335,7 +321,7 @@ describe("email routing commands", () => { it("should handle no rules", async ({ expect }) => { mockListRules([]); - await runWrangler("email routing rules list --zone-id zone-id-1"); + await runWrangler("email routing rules list example.com --zone-id zone-id-1"); expect(std.out).toContain("No routing rules found."); }); @@ -348,7 +334,7 @@ describe("email routing commands", () => { mockGetRule(mockRule); await runWrangler( - "email routing rules get rule-id-1 --zone-id zone-id-1" + "email routing rules get example.com rule-id-1 --zone-id zone-id-1" ); expect(std.out).toContain("Rule: rule-id-1"); @@ -360,7 +346,7 @@ describe("email routing commands", () => { mockGetCatchAll(mockCatchAll); await runWrangler( - "email routing rules get catch-all --zone-id zone-id-1" + "email routing rules get example.com catch-all --zone-id zone-id-1" ); expect(std.out).toContain("Catch-all rule:"); @@ -376,7 +362,7 @@ describe("email routing commands", () => { const reqProm = mockCreateRule(); await runWrangler( - "email routing rules create --zone-id zone-id-1 --match-type literal --match-field to --match-value user@example.com --action-type forward --action-value dest@example.com --name 'My Rule'" + "email routing rules create example.com --zone-id zone-id-1 --match-type literal --match-field to --match-value user@example.com --action-type forward --action-value dest@example.com --name 'My Rule'" ); await expect(reqProm).resolves.toMatchObject({ @@ -392,7 +378,7 @@ describe("email routing commands", () => { const reqProm = mockCreateRule(); await runWrangler( - "email routing rules create --zone-id zone-id-1 --match-type literal --match-field to --match-value spam@example.com --action-type drop" + "email routing rules create example.com --zone-id zone-id-1 --match-type literal --match-field to --match-value spam@example.com --action-type drop" ); await expect(reqProm).resolves.toMatchObject({ @@ -406,7 +392,7 @@ describe("email routing commands", () => { it("should error when forward is used without --action-value", async ({ expect }) => { await expect( runWrangler( - "email routing rules create --zone-id zone-id-1 --match-type literal --match-field to --match-value user@example.com --action-type forward" + "email routing rules create example.com --zone-id zone-id-1 --match-type literal --match-field to --match-value user@example.com --action-type forward" ) ).rejects.toThrow( "--action-value is required when --action-type is not 'drop'" @@ -421,7 +407,7 @@ describe("email routing commands", () => { const reqProm = mockUpdateRule(); await runWrangler( - "email routing rules update rule-id-1 --zone-id zone-id-1 --match-type literal --match-field to --match-value updated@example.com --action-type forward --action-value newdest@example.com" + "email routing rules update example.com rule-id-1 --zone-id zone-id-1 --match-type literal --match-field to --match-value updated@example.com --action-type forward --action-value newdest@example.com" ); await expect(reqProm).resolves.toMatchObject({ @@ -438,7 +424,7 @@ describe("email routing commands", () => { const reqProm = mockUpdateCatchAll(); await runWrangler( - "email routing rules update catch-all --zone-id zone-id-1 --action-type drop --enabled true" + "email routing rules update example.com catch-all --zone-id zone-id-1 --action-type drop --enabled true" ); await expect(reqProm).resolves.toMatchObject({ @@ -454,7 +440,7 @@ describe("email routing commands", () => { const reqProm = mockUpdateCatchAll(); await runWrangler( - "email routing rules update catch-all --zone-id zone-id-1 --action-type forward --action-value catchall@example.com" + "email routing rules update example.com catch-all --zone-id zone-id-1 --action-type forward --action-value catchall@example.com" ); await expect(reqProm).resolves.toMatchObject({ @@ -468,7 +454,7 @@ describe("email routing commands", () => { it("should error when catch-all forward is used without --action-value", async ({ expect }) => { await expect( runWrangler( - "email routing rules update catch-all --zone-id zone-id-1 --action-type forward" + "email routing rules update example.com catch-all --zone-id zone-id-1 --action-type forward" ) ).rejects.toThrow( "--action-value is required when --action-type is 'forward'" @@ -478,7 +464,7 @@ describe("email routing commands", () => { it("should error when regular rule update is missing --match-type", async ({ expect }) => { await expect( runWrangler( - "email routing rules update rule-id-1 --zone-id zone-id-1 --action-type forward --action-value dest@example.com" + "email routing rules update example.com rule-id-1 --zone-id zone-id-1 --action-type forward --action-value dest@example.com" ) ).rejects.toThrow( "--match-type is required when updating a regular rule" @@ -497,7 +483,7 @@ describe("email routing commands", () => { mockDeleteRule(); await runWrangler( - "email routing rules delete rule-id-1 --zone-id zone-id-1" + "email routing rules delete example.com rule-id-1 --zone-id zone-id-1" ); expect(std.out).toContain("Deleted routing rule: rule-id-1"); diff --git a/packages/wrangler/src/email-routing/disable.ts b/packages/wrangler/src/email-routing/disable.ts index ffd8ec2f8a..f2383c5e98 100644 --- a/packages/wrangler/src/email-routing/disable.ts +++ b/packages/wrangler/src/email-routing/disable.ts @@ -2,7 +2,7 @@ import { createCommand } from "../core/create-command"; import { confirm } from "../dialogs"; import { logger } from "../logger"; import { disableEmailRouting } from "./client"; -import { zoneArgs } from "./index"; +import { domainArgs } from "./index"; import { resolveZoneId } from "./utils"; export const emailRoutingDisableCommand = createCommand({ @@ -12,7 +12,7 @@ export const emailRoutingDisableCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + ...domainArgs, force: { type: "boolean", alias: "y", @@ -20,6 +20,7 @@ export const emailRoutingDisableCommand = createCommand({ default: false, }, }, + positionalArgs: ["domain"], async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); diff --git a/packages/wrangler/src/email-routing/dns-get.ts b/packages/wrangler/src/email-routing/dns-get.ts index 18de657d50..c2c127d26c 100644 --- a/packages/wrangler/src/email-routing/dns-get.ts +++ b/packages/wrangler/src/email-routing/dns-get.ts @@ -1,7 +1,7 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { getEmailRoutingDns } from "./client"; -import { zoneArgs } from "./index"; +import { domainArgs } from "./index"; import { resolveZoneId } from "./utils"; export const emailRoutingDnsGetCommand = createCommand({ @@ -11,8 +11,9 @@ export const emailRoutingDnsGetCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + ...domainArgs, }, + positionalArgs: ["domain"], async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); const records = await getEmailRoutingDns(config, zoneId); diff --git a/packages/wrangler/src/email-routing/dns-unlock.ts b/packages/wrangler/src/email-routing/dns-unlock.ts index d607794e48..e8ae732c1c 100644 --- a/packages/wrangler/src/email-routing/dns-unlock.ts +++ b/packages/wrangler/src/email-routing/dns-unlock.ts @@ -2,7 +2,7 @@ import { createCommand } from "../core/create-command"; import { confirm } from "../dialogs"; import { logger } from "../logger"; import { unlockEmailRoutingDns } from "./client"; -import { zoneArgs } from "./index"; +import { domainArgs } from "./index"; import { resolveZoneId } from "./utils"; export const emailRoutingDnsUnlockCommand = createCommand({ @@ -12,7 +12,7 @@ export const emailRoutingDnsUnlockCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + ...domainArgs, force: { type: "boolean", alias: "y", @@ -20,6 +20,7 @@ export const emailRoutingDnsUnlockCommand = createCommand({ default: false, }, }, + positionalArgs: ["domain"], async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); diff --git a/packages/wrangler/src/email-routing/enable.ts b/packages/wrangler/src/email-routing/enable.ts index 2391239eff..35968e2010 100644 --- a/packages/wrangler/src/email-routing/enable.ts +++ b/packages/wrangler/src/email-routing/enable.ts @@ -1,7 +1,7 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { enableEmailRouting } from "./client"; -import { zoneArgs } from "./index"; +import { domainArgs } from "./index"; import { resolveZoneId } from "./utils"; export const emailRoutingEnableCommand = createCommand({ @@ -11,8 +11,9 @@ export const emailRoutingEnableCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + ...domainArgs, }, + positionalArgs: ["domain"], async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); const settings = await enableEmailRouting(config, zoneId); diff --git a/packages/wrangler/src/email-routing/index.ts b/packages/wrangler/src/email-routing/index.ts index ba6c3e051d..df141bea51 100644 --- a/packages/wrangler/src/email-routing/index.ts +++ b/packages/wrangler/src/email-routing/index.ts @@ -56,16 +56,15 @@ export const emailSendingDnsNamespace = createNamespace({ }, }); -export const zoneArgs = { - zone: { +export const domainArgs = { + domain: { type: "string", - description: "Domain name of the zone (e.g. example.com)", - conflicts: ["zone-id"], + demandOption: true, + description: "Domain name (e.g. example.com)", }, "zone-id": { type: "string", - description: "Zone ID", - conflicts: ["zone"], + description: "Zone ID (optional, skips zone lookup if provided)", }, } as const; diff --git a/packages/wrangler/src/email-routing/list.ts b/packages/wrangler/src/email-routing/list.ts index fc46509145..1e78a0c67e 100644 --- a/packages/wrangler/src/email-routing/list.ts +++ b/packages/wrangler/src/email-routing/list.ts @@ -21,7 +21,6 @@ export const emailRoutingListCommand = createCommand({ zone: zone.name, "zone id": zone.id, enabled: zone.enabled ? "yes" : "no", - status: zone.status ?? "", })); logger.table(results); diff --git a/packages/wrangler/src/email-routing/rules/create.ts b/packages/wrangler/src/email-routing/rules/create.ts index 1a0368fc67..e32660e272 100644 --- a/packages/wrangler/src/email-routing/rules/create.ts +++ b/packages/wrangler/src/email-routing/rules/create.ts @@ -2,7 +2,7 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { createEmailRoutingRule } from "../client"; -import { zoneArgs } from "../index"; +import { domainArgs } from "../index"; import { resolveZoneId } from "../utils"; export const emailRoutingRulesCreateCommand = createCommand({ @@ -12,7 +12,7 @@ export const emailRoutingRulesCreateCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + ...domainArgs, name: { type: "string", description: "Rule name", @@ -54,6 +54,7 @@ export const emailRoutingRulesCreateCommand = createCommand({ description: "Rule priority", }, }, + positionalArgs: ["domain"], validateArgs: (args) => { if ( args.actionType !== "drop" && diff --git a/packages/wrangler/src/email-routing/rules/delete.ts b/packages/wrangler/src/email-routing/rules/delete.ts index 634125e74f..d1b0d71274 100644 --- a/packages/wrangler/src/email-routing/rules/delete.ts +++ b/packages/wrangler/src/email-routing/rules/delete.ts @@ -2,7 +2,7 @@ import { createCommand } from "../../core/create-command"; import { confirm } from "../../dialogs"; import { logger } from "../../logger"; import { deleteEmailRoutingRule } from "../client"; -import { zoneArgs } from "../index"; +import { domainArgs } from "../index"; import { resolveZoneId } from "../utils"; export const emailRoutingRulesDeleteCommand = createCommand({ @@ -12,7 +12,7 @@ export const emailRoutingRulesDeleteCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + ...domainArgs, "rule-id": { type: "string", demandOption: true, @@ -25,7 +25,7 @@ export const emailRoutingRulesDeleteCommand = createCommand({ default: false, }, }, - positionalArgs: ["rule-id"], + positionalArgs: ["domain", "rule-id"], async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); diff --git a/packages/wrangler/src/email-routing/rules/get.ts b/packages/wrangler/src/email-routing/rules/get.ts index 499164a1ee..b087ee47be 100644 --- a/packages/wrangler/src/email-routing/rules/get.ts +++ b/packages/wrangler/src/email-routing/rules/get.ts @@ -2,7 +2,7 @@ import { APIError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { getEmailRoutingCatchAll, getEmailRoutingRule } from "../client"; -import { zoneArgs } from "../index"; +import { domainArgs } from "../index"; import { resolveZoneId } from "../utils"; export const emailRoutingRulesGetCommand = createCommand({ @@ -13,7 +13,7 @@ export const emailRoutingRulesGetCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + ...domainArgs, "rule-id": { type: "string", demandOption: true, @@ -21,7 +21,7 @@ export const emailRoutingRulesGetCommand = createCommand({ "The ID of the routing rule, or 'catch-all' for the catch-all rule", }, }, - positionalArgs: ["rule-id"], + positionalArgs: ["domain", "rule-id"], async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); diff --git a/packages/wrangler/src/email-routing/rules/list.ts b/packages/wrangler/src/email-routing/rules/list.ts index 7ee5c2d9a4..2b853d502d 100644 --- a/packages/wrangler/src/email-routing/rules/list.ts +++ b/packages/wrangler/src/email-routing/rules/list.ts @@ -1,7 +1,7 @@ import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { listEmailRoutingRules } from "../client"; -import { zoneArgs } from "../index"; +import { domainArgs } from "../index"; import { resolveZoneId } from "../utils"; export const emailRoutingRulesListCommand = createCommand({ @@ -11,8 +11,9 @@ export const emailRoutingRulesListCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + ...domainArgs, }, + positionalArgs: ["domain"], async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); const rules = await listEmailRoutingRules(config, zoneId); @@ -29,24 +30,26 @@ export const emailRoutingRulesListCommand = createCommand({ } else if (regularRules.length === 0) { logger.log("No custom routing rules found."); } else { - logger.table( - regularRules.map((r) => ({ - id: r.id, - name: r.name || "", - enabled: r.enabled ? "yes" : "no", - matchers: r.matchers - .map((m) => - m.field && m.value ? `${m.field}:${m.value}` : m.type - ) - .join(", "), - actions: r.actions - .map((a) => - a.value ? `${a.type}:${a.value.join(",")}` : a.type - ) - .join(", "), - priority: String(r.priority), - })) - ); + for (const r of regularRules) { + const matchers = r.matchers + .map((m) => + m.field && m.value ? `${m.field}:${m.value}` : m.type + ) + .join(", "); + const actions = r.actions + .map((a) => + a.value ? `${a.type}:${a.value.join(",")}` : a.type + ) + .join(", "); + + logger.log(`Rule: ${r.id}`); + logger.log(` Name: ${r.name || "(none)"}`); + logger.log(` Enabled: ${r.enabled}`); + logger.log(` Matchers: ${matchers}`); + logger.log(` Actions: ${actions}`); + logger.log(` Priority: ${r.priority}`); + logger.log(""); + } } if (catchAll) { diff --git a/packages/wrangler/src/email-routing/rules/update.ts b/packages/wrangler/src/email-routing/rules/update.ts index e3dab8d4a8..e760755c58 100644 --- a/packages/wrangler/src/email-routing/rules/update.ts +++ b/packages/wrangler/src/email-routing/rules/update.ts @@ -2,7 +2,7 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { updateEmailRoutingCatchAll, updateEmailRoutingRule } from "../client"; -import { zoneArgs } from "../index"; +import { domainArgs } from "../index"; import { resolveZoneId } from "../utils"; export const emailRoutingRulesUpdateCommand = createCommand({ @@ -13,7 +13,7 @@ export const emailRoutingRulesUpdateCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + ...domainArgs, "rule-id": { type: "string", demandOption: true, @@ -60,7 +60,7 @@ export const emailRoutingRulesUpdateCommand = createCommand({ description: "Rule priority (ignored for catch-all)", }, }, - positionalArgs: ["rule-id"], + positionalArgs: ["domain", "rule-id"], validateArgs: (args) => { if (args.ruleId === "catch-all") { // Catch-all only supports forward and drop @@ -133,6 +133,13 @@ export const emailRoutingRulesUpdateCommand = createCommand({ return; } + // validateArgs guarantees these are present for regular rules + if (!args.matchType || !args.matchField || !args.matchValue) { + throw new UserError( + "--match-type, --match-field, and --match-value are required when updating a regular rule" + ); + } + const rule = await updateEmailRoutingRule(config, zoneId, args.ruleId, { actions: [{ type: args.actionType, value: args.actionValue }], matchers: [ diff --git a/packages/wrangler/src/email-routing/sending/list.ts b/packages/wrangler/src/email-routing/sending/list.ts index f58c4c4f65..f27481be3c 100644 --- a/packages/wrangler/src/email-routing/sending/list.ts +++ b/packages/wrangler/src/email-routing/sending/list.ts @@ -21,7 +21,6 @@ export const emailSendingListCommand = createCommand({ zone: zone.name, "zone id": zone.id, enabled: zone.enabled ? "yes" : "no", - status: zone.status ?? "", })); logger.table(results); diff --git a/packages/wrangler/src/email-routing/settings.ts b/packages/wrangler/src/email-routing/settings.ts index d89c147f63..31bc55b1a2 100644 --- a/packages/wrangler/src/email-routing/settings.ts +++ b/packages/wrangler/src/email-routing/settings.ts @@ -1,7 +1,7 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { getEmailRoutingSettings } from "./client"; -import { zoneArgs } from "./index"; +import { domainArgs } from "./index"; import { resolveZoneId } from "./utils"; export const emailRoutingSettingsCommand = createCommand({ @@ -11,8 +11,9 @@ export const emailRoutingSettingsCommand = createCommand({ owner: "Product: Email Service", }, args: { - ...zoneArgs, + ...domainArgs, }, + positionalArgs: ["domain"], async handler(args, { config }) { const zoneId = await resolveZoneId(config, args); const settings = await getEmailRoutingSettings(config, zoneId); diff --git a/packages/wrangler/src/email-routing/utils.ts b/packages/wrangler/src/email-routing/utils.ts index 2a4a5e19d8..3ba7638350 100644 --- a/packages/wrangler/src/email-routing/utils.ts +++ b/packages/wrangler/src/email-routing/utils.ts @@ -6,19 +6,19 @@ import type { ComplianceConfig, Config } from "@cloudflare/workers-utils"; export async function resolveZoneId( config: Config, - args: { zone?: string; zoneId?: string } + args: { domain?: string; zoneId?: string } ): Promise { if (args.zoneId) { return args.zoneId; } - if (args.zone) { + if (args.domain) { const accountId = await requireAuth(config); - return await getZoneIdByDomain(config, args.zone, accountId); + return await getZoneIdByDomain(config, args.domain, accountId); } throw new UserError( - "You must provide either --zone (domain name) or --zone-id (zone ID)." + "You must provide a domain or --zone-id." ); } From 95f08caa39eb40abc7dacfad0c9158ab1e09b68d Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 2 Apr 2026 00:16:54 -0400 Subject: [PATCH 26/32] fix: update changeset to show positional for routing commands --- .changeset/email-service-commands.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/email-service-commands.md b/.changeset/email-service-commands.md index c9d7842f99..9b3e40eab1 100644 --- a/.changeset/email-service-commands.md +++ b/.changeset/email-service-commands.md @@ -7,10 +7,10 @@ feat: add `wrangler email routing` and `wrangler email sending` commands Email Routing commands: - `wrangler email routing list` - list zones with email routing status -- `wrangler email routing settings` - get email routing settings for a zone -- `wrangler email routing enable/disable` - enable or disable email routing -- `wrangler email routing dns get/unlock` - manage DNS records -- `wrangler email routing rules list/get/create/update/delete` - manage routing rules (use `catch-all` as the rule ID for the catch-all rule) +- `wrangler email routing settings ` - get email routing settings for a zone +- `wrangler email routing enable/disable ` - enable or disable email routing +- `wrangler email routing dns get/unlock ` - manage DNS records +- `wrangler email routing rules list/get/create/update/delete ` - manage routing rules (use `catch-all` as the rule ID for the catch-all rule) - `wrangler email routing addresses list/get/create/delete` - manage destination addresses Email Sending commands: From 6ccd53d977467d4b04d7dcec9ccfa03f1f1faee0 Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 2 Apr 2026 00:19:30 -0400 Subject: [PATCH 27/32] style: run oxfmt on email routing files --- .../src/__tests__/email-routing.test.ts | 113 ++++++++++-------- packages/wrangler/src/cfetch/index.ts | 2 +- .../wrangler/src/email-routing/disable.ts | 2 +- .../wrangler/src/email-routing/dns-get.ts | 2 +- .../wrangler/src/email-routing/dns-unlock.ts | 2 +- packages/wrangler/src/email-routing/enable.ts | 2 +- .../wrangler/src/email-routing/rules/list.ts | 8 +- .../src/email-routing/sending/disable.ts | 3 +- .../src/email-routing/sending/dns-get.ts | 13 +- .../src/email-routing/sending/enable.ts | 3 +- .../src/email-routing/sending/send.ts | 17 +-- .../src/email-routing/sending/settings.ts | 3 +- .../src/email-routing/sending/utils.ts | 4 +- .../wrangler/src/email-routing/settings.ts | 2 +- packages/wrangler/src/email-routing/utils.ts | 4 +- packages/wrangler/src/index.ts | 3 +- 16 files changed, 84 insertions(+), 99 deletions(-) diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index 112e3eca22..d546ff69d4 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -121,7 +121,9 @@ describe("email routing help", () => { expect(std.out).toContain("Manage Email Routing rules"); }); - it("should show help text for email routing addresses", async ({ expect }) => { + it("should show help text for email routing addresses", async ({ + expect, + }) => { await runWrangler("email routing addresses"); await endEventLoop(); @@ -229,7 +231,9 @@ describe("email routing commands", () => { it("should get settings with --zone-id", async ({ expect }) => { mockGetSettings(mockSettings); - await runWrangler("email routing settings example.com --zone-id zone-id-1"); + await runWrangler( + "email routing settings example.com --zone-id zone-id-1" + ); expect(std.out).toContain("Email Routing for example.com"); expect(std.out).toContain("Enabled: true"); @@ -271,7 +275,9 @@ describe("email routing commands", () => { enabled: false, }); - await runWrangler("email routing disable example.com --zone-id zone-id-1"); + await runWrangler( + "email routing disable example.com --zone-id zone-id-1" + ); expect(std.out).toContain("Email Routing disabled"); }); @@ -283,7 +289,9 @@ describe("email routing commands", () => { it("should show dns records", async ({ expect }) => { mockGetDns(mockDnsRecords); - await runWrangler("email routing dns get example.com --zone-id zone-id-1"); + await runWrangler( + "email routing dns get example.com --zone-id zone-id-1" + ); expect(std.out).toContain("MX"); expect(std.out).toContain("route1.mx.cloudflare.net"); @@ -300,7 +308,9 @@ describe("email routing commands", () => { }); mockUnlockDns(mockSettings); - await runWrangler("email routing dns unlock example.com --zone-id zone-id-1"); + await runWrangler( + "email routing dns unlock example.com --zone-id zone-id-1" + ); expect(std.out).toContain("MX records unlocked for example.com"); }); @@ -312,7 +322,9 @@ describe("email routing commands", () => { it("should list routing rules", async ({ expect }) => { mockListRules([mockRule]); - await runWrangler("email routing rules list example.com --zone-id zone-id-1"); + await runWrangler( + "email routing rules list example.com --zone-id zone-id-1" + ); expect(std.out).toContain("rule-id-1"); expect(std.out).toContain("My Rule"); @@ -321,7 +333,9 @@ describe("email routing commands", () => { it("should handle no rules", async ({ expect }) => { mockListRules([]); - await runWrangler("email routing rules list example.com --zone-id zone-id-1"); + await runWrangler( + "email routing rules list example.com --zone-id zone-id-1" + ); expect(std.out).toContain("No routing rules found."); }); @@ -342,7 +356,9 @@ describe("email routing commands", () => { expect(std.out).toContain("Enabled: true"); }); - it("should get the catch-all rule when rule-id is 'catch-all'", async ({ expect }) => { + it("should get the catch-all rule when rule-id is 'catch-all'", async ({ + expect, + }) => { mockGetCatchAll(mockCatchAll); await runWrangler( @@ -374,7 +390,9 @@ describe("email routing commands", () => { expect(std.out).toContain("Created routing rule:"); }); - it("should create a drop rule without --action-value", async ({ expect }) => { + it("should create a drop rule without --action-value", async ({ + expect, + }) => { const reqProm = mockCreateRule(); await runWrangler( @@ -389,7 +407,9 @@ describe("email routing commands", () => { expect(std.out).toContain("Created routing rule:"); }); - it("should error when forward is used without --action-value", async ({ expect }) => { + it("should error when forward is used without --action-value", async ({ + expect, + }) => { await expect( runWrangler( "email routing rules create example.com --zone-id zone-id-1 --match-type literal --match-field to --match-value user@example.com --action-type forward" @@ -451,7 +471,9 @@ describe("email routing commands", () => { expect(std.out).toContain("Updated catch-all rule:"); }); - it("should error when catch-all forward is used without --action-value", async ({ expect }) => { + it("should error when catch-all forward is used without --action-value", async ({ + expect, + }) => { await expect( runWrangler( "email routing rules update example.com catch-all --zone-id zone-id-1 --action-type forward" @@ -461,7 +483,9 @@ describe("email routing commands", () => { ); }); - it("should error when regular rule update is missing --match-type", async ({ expect }) => { + it("should error when regular rule update is missing --match-type", async ({ + expect, + }) => { await expect( runWrangler( "email routing rules update example.com rule-id-1 --zone-id zone-id-1 --action-type forward --action-value dest@example.com" @@ -635,11 +659,7 @@ describe("email sending commands", () => { it("should handle no dns records", async ({ expect }) => { mockZoneLookup("sub.example.com", "zone-id-1"); mockGetSendingSettings("zone-id-1"); - mockGetSendingDns( - "zone-id-1", - "aabbccdd11223344aabbccdd11223344", - [] - ); + mockGetSendingDns("zone-id-1", "aabbccdd11223344aabbccdd11223344", []); await runWrangler("email sending dns get sub.example.com"); @@ -722,7 +742,9 @@ describe("email sending commands", () => { }); }); - it("should error on malformed header with empty name", async ({ expect }) => { + it("should error on malformed header with empty name", async ({ + expect, + }) => { await expect( runWrangler( "email sending send --from sender@example.com --to recipient@example.com --subject 'Test' --text 'Hi' --header ':value'" @@ -738,14 +760,14 @@ describe("email sending commands", () => { ).rejects.toThrow("Expected 'Key:Value'"); }); - it("should error when neither --text nor --html is provided", async ({ expect }) => { + it("should error when neither --text nor --html is provided", async ({ + expect, + }) => { await expect( runWrangler( "email sending send --from sender@example.com --to recipient@example.com --subject 'Test'" ) - ).rejects.toThrow( - "At least one of --text or --html must be provided" - ); + ).rejects.toThrow("At least one of --text or --html must be provided"); }); it("should display queued and bounced recipients", async ({ expect }) => { @@ -785,7 +807,9 @@ describe("email sending commands", () => { expect(std.out).toContain("Delivered to: recipient@example.com"); }); - it("should error when neither --mime nor --mime-file is provided", async ({ expect }) => { + it("should error when neither --mime nor --mime-file is provided", async ({ + expect, + }) => { await expect( runWrangler( "email sending send-raw --from sender@example.com --to recipient@example.com" @@ -817,19 +841,16 @@ function mockZoneLookup(domain: string, zoneId: string) { const labels = domain.split("."); const zoneName = labels.slice(-2).join("."); msw.use( - http.get( - "*/zones", - ({ request }) => { - const url = new URL(request.url); - const name = url.searchParams.get("name"); - if (name === zoneName) { - return HttpResponse.json( - createFetchResult([{ id: zoneId, name: zoneName }], true) - ); - } - return HttpResponse.json(createFetchResult([], true)); + http.get("*/zones", ({ request }) => { + const url = new URL(request.url); + const name = url.searchParams.get("name"); + if (name === zoneName) { + return HttpResponse.json( + createFetchResult([{ id: zoneId, name: zoneName }], true) + ); } - ) + return HttpResponse.json(createFetchResult([], true)); + }) ); } @@ -923,8 +944,7 @@ function mockCreateRule(): Promise { http.post( "*/zones/:zoneId/email/routing/rules", async ({ request }) => { - const reqBody = - (await request.json()) as Record; + const reqBody = (await request.json()) as Record; resolve(reqBody); return HttpResponse.json( createFetchResult({ id: "new-rule-id", ...reqBody }, true) @@ -942,8 +962,7 @@ function mockUpdateRule(): Promise { http.put( "*/zones/:zoneId/email/routing/rules/:ruleId", async ({ request }) => { - const reqBody = - (await request.json()) as Record; + const reqBody = (await request.json()) as Record; resolve(reqBody); return HttpResponse.json( createFetchResult({ id: "rule-id-1", ...reqBody }, true) @@ -985,8 +1004,7 @@ function mockUpdateCatchAll(): Promise { http.put( "*/zones/:zoneId/email/routing/rules/catch_all", async ({ request }) => { - const reqBody = - (await request.json()) as Record; + const reqBody = (await request.json()) as Record; resolve(reqBody); return HttpResponse.json( createFetchResult({ id: "catch-all-id", ...reqBody }, true) @@ -1069,10 +1087,7 @@ function mockEnableSending(_zoneId: string) { const body = (await request.json()) as Record; const name = (body.name as string) || "example.com"; return HttpResponse.json( - createFetchResult( - { ...mockSettings, name, status: "ready" }, - true - ) + createFetchResult({ ...mockSettings, name, status: "ready" }, true) ); }, { once: true } @@ -1143,9 +1158,7 @@ function mockSendEmail(): Promise { async ({ request }) => { const reqBody = await request.json(); resolve(reqBody); - return HttpResponse.json( - createFetchResult(mockSendResult, true) - ); + return HttpResponse.json(createFetchResult(mockSendResult, true)); }, { once: true } ) @@ -1177,9 +1190,7 @@ function mockSendRawEmail(): Promise { async ({ request }) => { const reqBody = await request.json(); resolve(reqBody); - return HttpResponse.json( - createFetchResult(mockSendResult, true) - ); + return HttpResponse.json(createFetchResult(mockSendResult, true)); }, { once: true } ) diff --git a/packages/wrangler/src/cfetch/index.ts b/packages/wrangler/src/cfetch/index.ts index 4795e6918e..33b2653973 100644 --- a/packages/wrangler/src/cfetch/index.ts +++ b/packages/wrangler/src/cfetch/index.ts @@ -226,7 +226,7 @@ function throwFetchError( const notes = [ ...errors.map((err) => ({ text: renderError(err) })), ...(response.messages?.map((msg) => ({ - text: typeof msg === "string" ? msg : msg.message ?? String(msg), + text: typeof msg === "string" ? msg : (msg.message ?? String(msg)), })) ?? []), ]; if (notes.length === 0) { diff --git a/packages/wrangler/src/email-routing/disable.ts b/packages/wrangler/src/email-routing/disable.ts index f2383c5e98..c8f0141d6f 100644 --- a/packages/wrangler/src/email-routing/disable.ts +++ b/packages/wrangler/src/email-routing/disable.ts @@ -2,8 +2,8 @@ import { createCommand } from "../core/create-command"; import { confirm } from "../dialogs"; import { logger } from "../logger"; import { disableEmailRouting } from "./client"; -import { domainArgs } from "./index"; import { resolveZoneId } from "./utils"; +import { domainArgs } from "./index"; export const emailRoutingDisableCommand = createCommand({ metadata: { diff --git a/packages/wrangler/src/email-routing/dns-get.ts b/packages/wrangler/src/email-routing/dns-get.ts index c2c127d26c..78e9e04510 100644 --- a/packages/wrangler/src/email-routing/dns-get.ts +++ b/packages/wrangler/src/email-routing/dns-get.ts @@ -1,8 +1,8 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { getEmailRoutingDns } from "./client"; -import { domainArgs } from "./index"; import { resolveZoneId } from "./utils"; +import { domainArgs } from "./index"; export const emailRoutingDnsGetCommand = createCommand({ metadata: { diff --git a/packages/wrangler/src/email-routing/dns-unlock.ts b/packages/wrangler/src/email-routing/dns-unlock.ts index e8ae732c1c..8b010dbcd1 100644 --- a/packages/wrangler/src/email-routing/dns-unlock.ts +++ b/packages/wrangler/src/email-routing/dns-unlock.ts @@ -2,8 +2,8 @@ import { createCommand } from "../core/create-command"; import { confirm } from "../dialogs"; import { logger } from "../logger"; import { unlockEmailRoutingDns } from "./client"; -import { domainArgs } from "./index"; import { resolveZoneId } from "./utils"; +import { domainArgs } from "./index"; export const emailRoutingDnsUnlockCommand = createCommand({ metadata: { diff --git a/packages/wrangler/src/email-routing/enable.ts b/packages/wrangler/src/email-routing/enable.ts index 35968e2010..53b65624c9 100644 --- a/packages/wrangler/src/email-routing/enable.ts +++ b/packages/wrangler/src/email-routing/enable.ts @@ -1,8 +1,8 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { enableEmailRouting } from "./client"; -import { domainArgs } from "./index"; import { resolveZoneId } from "./utils"; +import { domainArgs } from "./index"; export const emailRoutingEnableCommand = createCommand({ metadata: { diff --git a/packages/wrangler/src/email-routing/rules/list.ts b/packages/wrangler/src/email-routing/rules/list.ts index 2b853d502d..9d3ca9606f 100644 --- a/packages/wrangler/src/email-routing/rules/list.ts +++ b/packages/wrangler/src/email-routing/rules/list.ts @@ -32,14 +32,10 @@ export const emailRoutingRulesListCommand = createCommand({ } else { for (const r of regularRules) { const matchers = r.matchers - .map((m) => - m.field && m.value ? `${m.field}:${m.value}` : m.type - ) + .map((m) => (m.field && m.value ? `${m.field}:${m.value}` : m.type)) .join(", "); const actions = r.actions - .map((a) => - a.value ? `${a.type}:${a.value.join(",")}` : a.type - ) + .map((a) => (a.value ? `${a.type}:${a.value.join(",")}` : a.type)) .join(", "); logger.log(`Rule: ${r.id}`); diff --git a/packages/wrangler/src/email-routing/sending/disable.ts b/packages/wrangler/src/email-routing/sending/disable.ts index b71bcca427..60f6702026 100644 --- a/packages/wrangler/src/email-routing/sending/disable.ts +++ b/packages/wrangler/src/email-routing/sending/disable.ts @@ -19,8 +19,7 @@ export const emailSendingDisableCommand = createCommand({ }, "zone-id": { type: "string", - description: - "Zone ID (optional, skips zone lookup if provided)", + description: "Zone ID (optional, skips zone lookup if provided)", }, force: { type: "boolean", diff --git a/packages/wrangler/src/email-routing/sending/dns-get.ts b/packages/wrangler/src/email-routing/sending/dns-get.ts index 467b32f415..711d503208 100644 --- a/packages/wrangler/src/email-routing/sending/dns-get.ts +++ b/packages/wrangler/src/email-routing/sending/dns-get.ts @@ -24,8 +24,7 @@ export const emailSendingDnsGetCommand = createCommand({ }, "zone-id": { type: "string", - description: - "Zone ID (optional, skips zone lookup if provided)", + description: "Zone ID (optional, skips zone lookup if provided)", }, }, positionalArgs: ["domain"], @@ -44,19 +43,13 @@ export const emailSendingDnsGetCommand = createCommand({ } else { // Subdomain — find the tag from settings and use the subdomain DNS endpoint const settings = await getEmailSendingSettings(config, zoneId); - const match = settings.subdomains?.find( - (s) => s.name === args.domain - ); + const match = settings.subdomains?.find((s) => s.name === args.domain); if (!match) { throw new UserError( `No sending subdomain found for \`${args.domain}\`. Run \`wrangler email sending settings ${args.domain}\` to see configured domains.` ); } - records = await getEmailSendingSubdomainDns( - config, - zoneId, - match.tag - ); + records = await getEmailSendingSubdomainDns(config, zoneId, match.tag); } if (records.length === 0) { diff --git a/packages/wrangler/src/email-routing/sending/enable.ts b/packages/wrangler/src/email-routing/sending/enable.ts index 41f20fd9f5..c0237e810a 100644 --- a/packages/wrangler/src/email-routing/sending/enable.ts +++ b/packages/wrangler/src/email-routing/sending/enable.ts @@ -18,8 +18,7 @@ export const emailSendingEnableCommand = createCommand({ }, "zone-id": { type: "string", - description: - "Zone ID (optional, skips zone lookup if provided)", + description: "Zone ID (optional, skips zone lookup if provided)", }, }, positionalArgs: ["domain"], diff --git a/packages/wrangler/src/email-routing/sending/send.ts b/packages/wrangler/src/email-routing/sending/send.ts index f0cdd48078..bb44253e33 100644 --- a/packages/wrangler/src/email-routing/sending/send.ts +++ b/packages/wrangler/src/email-routing/sending/send.ts @@ -1,6 +1,6 @@ -import { UserError } from "@cloudflare/workers-utils"; import { readFileSync } from "node:fs"; import path from "node:path"; +import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { sendEmail } from "../client"; import { logSendResult } from "./utils"; @@ -67,15 +67,12 @@ export const emailSendingSendCommand = createCommand({ attachment: { type: "string", array: true, - description: - "File path to attach. Can be specified multiple times.", + description: "File path to attach. Can be specified multiple times.", }, }, validateArgs: (args) => { if (!args.text && !args.html) { - throw new UserError( - "At least one of --text or --html must be provided" - ); + throw new UserError("At least one of --text or --html must be provided"); } }, async handler(args, { config }) { @@ -109,9 +106,7 @@ export const emailSendingSendCommand = createCommand({ }, }); -function parseHeaders( - headerArgs: string[] | undefined -): Map { +function parseHeaders(headerArgs: string[] | undefined): Map { const headers = new Map(); if (!headerArgs) { return headers; @@ -135,9 +130,7 @@ function parseHeaders( return headers; } -function parseAttachments( - attachmentPaths: string[] | undefined -): Array<{ +function parseAttachments(attachmentPaths: string[] | undefined): Array<{ content: string; filename: string; type: string; diff --git a/packages/wrangler/src/email-routing/sending/settings.ts b/packages/wrangler/src/email-routing/sending/settings.ts index 4fbc6c922f..e50abb15a2 100644 --- a/packages/wrangler/src/email-routing/sending/settings.ts +++ b/packages/wrangler/src/email-routing/sending/settings.ts @@ -17,8 +17,7 @@ export const emailSendingSettingsCommand = createCommand({ }, "zone-id": { type: "string", - description: - "Zone ID (optional, skips zone lookup if provided)", + description: "Zone ID (optional, skips zone lookup if provided)", }, }, positionalArgs: ["domain"], diff --git a/packages/wrangler/src/email-routing/sending/utils.ts b/packages/wrangler/src/email-routing/sending/utils.ts index 86e844ed53..9c97ae265f 100644 --- a/packages/wrangler/src/email-routing/sending/utils.ts +++ b/packages/wrangler/src/email-routing/sending/utils.ts @@ -9,9 +9,7 @@ export function logSendResult(result: EmailSendingSendResponse): void { logger.log(`Queued for: ${result.queued.join(", ")}`); } if (result.permanent_bounces.length > 0) { - logger.warn( - `Permanently bounced: ${result.permanent_bounces.join(", ")}` - ); + logger.warn(`Permanently bounced: ${result.permanent_bounces.join(", ")}`); } if ( result.delivered.length === 0 && diff --git a/packages/wrangler/src/email-routing/settings.ts b/packages/wrangler/src/email-routing/settings.ts index 31bc55b1a2..bcd220338f 100644 --- a/packages/wrangler/src/email-routing/settings.ts +++ b/packages/wrangler/src/email-routing/settings.ts @@ -1,8 +1,8 @@ import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { getEmailRoutingSettings } from "./client"; -import { domainArgs } from "./index"; import { resolveZoneId } from "./utils"; +import { domainArgs } from "./index"; export const emailRoutingSettingsCommand = createCommand({ metadata: { diff --git a/packages/wrangler/src/email-routing/utils.ts b/packages/wrangler/src/email-routing/utils.ts index 3ba7638350..27014eddec 100644 --- a/packages/wrangler/src/email-routing/utils.ts +++ b/packages/wrangler/src/email-routing/utils.ts @@ -17,9 +17,7 @@ export async function resolveZoneId( return await getZoneIdByDomain(config, args.domain, accountId); } - throw new UserError( - "You must provide a domain or --zone-id." - ); + throw new UserError("You must provide a domain or --zone-id."); } async function getZoneIdByDomain( diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 4d21e80f18..4fd02e213b 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -120,7 +120,6 @@ import { emailRoutingRulesDeleteCommand } from "./email-routing/rules/delete"; import { emailRoutingRulesGetCommand } from "./email-routing/rules/get"; import { emailRoutingRulesListCommand } from "./email-routing/rules/list"; import { emailRoutingRulesUpdateCommand } from "./email-routing/rules/update"; -import { emailRoutingSettingsCommand } from "./email-routing/settings"; import { emailSendingDisableCommand } from "./email-routing/sending/disable"; import { emailSendingDnsGetCommand } from "./email-routing/sending/dns-get"; import { emailSendingEnableCommand } from "./email-routing/sending/enable"; @@ -128,7 +127,7 @@ import { emailSendingListCommand } from "./email-routing/sending/list"; import { emailSendingSendCommand } from "./email-routing/sending/send"; import { emailSendingSendRawCommand } from "./email-routing/sending/send-raw"; import { emailSendingSettingsCommand } from "./email-routing/sending/settings"; - +import { emailRoutingSettingsCommand } from "./email-routing/settings"; import { helloWorldGetCommand, helloWorldNamespace, From ad68b075e4d8499e8629df160663e6e3f0dbb24d Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 2 Apr 2026 00:27:14 -0400 Subject: [PATCH 28/32] test: add missing test coverage for email routing commands Add 17 new tests covering: - --force flag for disable, dns-unlock, rules delete - User declining confirmation for disable, dns-unlock, rules delete - --mime-file and missing file error for send-raw - --attachment and missing file error for send - Error 2020 catch-all fallback for rules get - Catch-all rule in rules list output - Empty DNS records for routing dns get - Help text for email routing dns namespace - Sending list (happy + empty) and settings commands Also removes spurious status column assertion from routing list test (enabled yes/no already conveys the same info). --- .../src/__tests__/email-routing.test.ts | 251 +++++++++++++++++- 1 file changed, 250 insertions(+), 1 deletion(-) diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index d546ff69d4..720c524d6f 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -1,3 +1,4 @@ +import { writeFileSync } from "node:fs"; import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { endEventLoop } from "./helpers/end-event-loop"; @@ -131,6 +132,14 @@ describe("email routing help", () => { expect(std.out).toContain("Manage Email Routing destination addresses"); }); + it("should show help text for email routing dns", async ({ expect }) => { + await runWrangler("email routing dns"); + await endEventLoop(); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toContain("Manage Email Routing DNS settings"); + }); + it("should show help text for email sending", async ({ expect }) => { await runWrangler("email sending"); await endEventLoop(); @@ -200,7 +209,6 @@ describe("email routing commands", () => { expect(std.out).toContain("example.com"); expect(std.out).toContain("no"); - expect(std.out).toContain("disabled"); }); }); @@ -281,6 +289,32 @@ describe("email routing commands", () => { expect(std.out).toContain("Email Routing disabled"); }); + + it("should skip confirmation with --force", async ({ expect }) => { + mockDisableEmailRouting({ + ...mockSettings, + enabled: false, + }); + + await runWrangler( + "email routing disable example.com --zone-id zone-id-1 --force" + ); + + expect(std.out).toContain("Email Routing disabled"); + }); + + it("should abort when user declines confirmation", async ({ expect }) => { + mockConfirm({ + text: "Are you sure you want to disable Email Routing for this zone?", + result: false, + }); + + await runWrangler( + "email routing disable example.com --zone-id zone-id-1" + ); + + expect(std.out).toContain("Not disabling."); + }); }); // --- dns get --- @@ -296,6 +330,16 @@ describe("email routing commands", () => { expect(std.out).toContain("MX"); expect(std.out).toContain("route1.mx.cloudflare.net"); }); + + it("should handle no dns records", async ({ expect }) => { + mockGetDns([]); + + await runWrangler( + "email routing dns get example.com --zone-id zone-id-1" + ); + + expect(std.out).toContain("No DNS records found."); + }); }); // --- dns unlock --- @@ -314,6 +358,29 @@ describe("email routing commands", () => { expect(std.out).toContain("MX records unlocked for example.com"); }); + + it("should skip confirmation with --force", async ({ expect }) => { + mockUnlockDns(mockSettings); + + await runWrangler( + "email routing dns unlock example.com --zone-id zone-id-1 --force" + ); + + expect(std.out).toContain("MX records unlocked for example.com"); + }); + + it("should abort when user declines confirmation", async ({ expect }) => { + mockConfirm({ + text: "Are you sure you want to unlock MX records? This allows external MX records to be set.", + result: false, + }); + + await runWrangler( + "email routing dns unlock example.com --zone-id zone-id-1" + ); + + expect(std.out).toContain("Not unlocking."); + }); }); // --- rules list --- @@ -339,6 +406,29 @@ describe("email routing commands", () => { expect(std.out).toContain("No routing rules found."); }); + + it("should show catch-all rule separately", async ({ expect }) => { + mockListRules([ + mockRule, + { + id: "catch-all-id", + actions: [{ type: "forward", value: ["catchall@example.com"] }], + enabled: true, + matchers: [{ type: "all", field: "", value: "" }], + name: "catch-all", + tag: "catch-all-tag", + priority: 0, + }, + ]); + + await runWrangler( + "email routing rules list example.com --zone-id zone-id-1" + ); + + expect(std.out).toContain("rule-id-1"); + expect(std.out).toContain("Catch-all rule:"); + expect(std.out).toContain("wrangler email routing rules get catch-all"); + }); }); // --- rules get --- @@ -369,6 +459,32 @@ describe("email routing commands", () => { expect(std.out).toContain("Enabled: true"); expect(std.out).toContain("forward: catchall@example.com"); }); + + it("should fallback to catch-all endpoint on error 2020", async ({ + expect, + }) => { + // Mock the regular rules endpoint to return error 2020 + msw.use( + http.get( + "*/zones/:zoneId/email/routing/rules/:ruleId", + () => { + return HttpResponse.json( + createFetchResult(null, false, [ + { code: 2020, message: "Invalid rule operation" }, + ]) + ); + }, + { once: true } + ) + ); + mockGetCatchAll({ ...mockCatchAll, tag: "catch-all-tag" }); + + await runWrangler( + "email routing rules get example.com catch-all-tag --zone-id zone-id-1" + ); + + expect(std.out).toContain("Catch-all rule:"); + }); }); // --- rules create --- @@ -512,6 +628,29 @@ describe("email routing commands", () => { expect(std.out).toContain("Deleted routing rule: rule-id-1"); }); + + it("should skip confirmation with --force", async ({ expect }) => { + mockDeleteRule(); + + await runWrangler( + "email routing rules delete example.com rule-id-1 --zone-id zone-id-1 --force" + ); + + expect(std.out).toContain("Deleted routing rule: rule-id-1"); + }); + + it("should abort when user declines confirmation", async ({ expect }) => { + mockConfirm({ + text: "Are you sure you want to delete routing rule 'rule-id-1'?", + result: false, + }); + + await runWrangler( + "email routing rules delete example.com rule-id-1 --zone-id zone-id-1" + ); + + expect(std.out).toContain("Not deleting."); + }); }); // --- addresses list --- @@ -601,6 +740,44 @@ describe("email sending commands", () => { clearDialogs(); }); + // --- list --- + + describe("list", () => { + it("should list zones with email sending", async ({ expect }) => { + mockListEmailSendingZones([mockSettings]); + + await runWrangler("email sending list"); + + expect(std.out).toContain("example.com"); + expect(std.out).toContain("yes"); + }); + + it("should handle no zones", async ({ expect }) => { + mockListEmailSendingZones([]); + + await runWrangler("email sending list"); + + expect(std.out).toContain( + "No zones found with Email Sending in this account." + ); + }); + }); + + // --- settings --- + + describe("settings", () => { + it("should get sending settings", async ({ expect }) => { + mockZoneLookup("example.com", "zone-id-1"); + mockGetSendingSettings("zone-id-1"); + + await runWrangler("email sending settings example.com"); + + expect(std.out).toContain("Email Sending for example.com"); + expect(std.out).toContain("Enabled: true"); + expect(std.out).toContain("sub.example.com"); + }); + }); + // --- enable/disable --- describe("enable", () => { @@ -807,6 +984,33 @@ describe("email sending commands", () => { expect(std.out).toContain("Delivered to: recipient@example.com"); }); + it("should send a raw MIME email from file", async ({ expect }) => { + const reqProm = mockSendRawEmail(); + const mimeContent = + "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: File Test\r\n\r\nBody"; + writeFileSync("test.eml", mimeContent, "utf-8"); + + await runWrangler( + "email sending send-raw --from sender@example.com --to recipient@example.com --mime-file test.eml" + ); + + await expect(reqProm).resolves.toMatchObject({ + from: "sender@example.com", + recipients: ["recipient@example.com"], + mime_message: mimeContent, + }); + + expect(std.out).toContain("Delivered to: recipient@example.com"); + }); + + it("should error when --mime-file does not exist", async ({ expect }) => { + await expect( + runWrangler( + "email sending send-raw --from sender@example.com --to recipient@example.com --mime-file nonexistent.eml" + ) + ).rejects.toThrow("Failed to read MIME file 'nonexistent.eml'"); + }); + it("should error when neither --mime nor --mime-file is provided", async ({ expect, }) => { @@ -819,6 +1023,39 @@ describe("email sending commands", () => { ); }); }); + + // --- send with attachment --- + + describe("send with attachment", () => { + it("should send an email with a file attachment", async ({ expect }) => { + const reqProm = mockSendEmail(); + writeFileSync("hello.txt", "Hello, World!", "utf-8"); + + await runWrangler( + "email sending send --from sender@example.com --to recipient@example.com --subject 'Test' --text 'See attached' --attachment hello.txt" + ); + + const body = await reqProm; + expect(body).toMatchObject({ + from: "sender@example.com", + subject: "Test", + }); + expect( + (body as { attachments: { filename: string }[] }).attachments[0] + .filename + ).toBe("hello.txt"); + }); + + it("should error when attachment file does not exist", async ({ + expect, + }) => { + await expect( + runWrangler( + "email sending send --from sender@example.com --to recipient@example.com --subject 'Test' --text 'Hi' --attachment nonexistent.pdf" + ) + ).rejects.toThrow("Failed to read attachment file 'nonexistent.pdf'"); + }); + }); }); // --- Mock API handlers: Email Routing --- @@ -1079,6 +1316,18 @@ function mockDeleteAddress() { // --- Mock API handlers: Email Sending --- +function mockListEmailSendingZones(settings: (typeof mockSettings)[]) { + msw.use( + http.get( + "*/accounts/:accountId/email/sending/zones", + () => { + return HttpResponse.json(createFetchResult(settings, true)); + }, + { once: true } + ) + ); +} + function mockEnableSending(_zoneId: string) { msw.use( http.post( From 0384b13d61b6a5e033349dbdafcc8387a576468e Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 2 Apr 2026 00:39:01 -0400 Subject: [PATCH 29/32] fix: resolve multi-label TLD subdomain misclassification in resolveDomain When --zone-id is provided, resolveDomain previously used labels.slice(-2) to guess the zone name, which broke multi-label TLDs like .co.uk, .com.br. For example, example.co.uk was misclassified as a subdomain of co.uk. Now fetches GET /zones/{zoneId} to get the actual zone name, so subdomain detection is correct for all TLDs. Adds 7 tests covering domain + --zone-id combinations: - Zone-level with --zone-id (body has no name) - Subdomain with --zone-id (body has name) - Multi-label TLD zone-level with --zone-id (example.co.uk) - Multi-label TLD subdomain with --zone-id (notifications.example.co.uk) - Disable zone-level and subdomain with --zone-id - DNS get zone-level and subdomain paths with --zone-id --- .../src/__tests__/email-routing.test.ts | 194 ++++++++++++++++++ packages/wrangler/src/email-routing/utils.ts | 15 +- 2 files changed, 202 insertions(+), 7 deletions(-) diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index 720c524d6f..a78ae747d2 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -798,6 +798,65 @@ describe("email sending commands", () => { expect(std.out).toContain("Email Sending enabled for sub.example.com"); }); + + it("should not send name for zone-level domain with --zone-id", async ({ + expect, + }) => { + mockZoneDetails("zone-id-1", "example.com"); + const reqProm = mockEnableSendingCapture(); + + await runWrangler("email sending enable example.com --zone-id zone-id-1"); + + // Zone-level: body should be {} (no name) + await expect(reqProm).resolves.toMatchObject({}); + const body = await reqProm; + expect(body).not.toHaveProperty("name"); + }); + + it("should send name for subdomain with --zone-id", async ({ expect }) => { + mockZoneDetails("zone-id-1", "example.com"); + const reqProm = mockEnableSendingCapture(); + + await runWrangler( + "email sending enable sub.example.com --zone-id zone-id-1" + ); + + // Subdomain: body should have { name: "sub.example.com" } + await expect(reqProm).resolves.toMatchObject({ + name: "sub.example.com", + }); + }); + + it("should correctly handle multi-label TLD with --zone-id", async ({ + expect, + }) => { + mockZoneDetails("zone-id-1", "example.co.uk"); + const reqProm = mockEnableSendingCapture(); + + await runWrangler( + "email sending enable example.co.uk --zone-id zone-id-1" + ); + + // example.co.uk is the zone itself, not a subdomain + const body = await reqProm; + expect(body).not.toHaveProperty("name"); + }); + + it("should detect subdomain of multi-label TLD with --zone-id", async ({ + expect, + }) => { + mockZoneDetails("zone-id-1", "example.co.uk"); + const reqProm = mockEnableSendingCapture(); + + await runWrangler( + "email sending enable notifications.example.co.uk --zone-id zone-id-1" + ); + + // notifications.example.co.uk is a subdomain of example.co.uk + await expect(reqProm).resolves.toMatchObject({ + name: "notifications.example.co.uk", + }); + }); }); describe("disable", () => { @@ -813,6 +872,41 @@ describe("email sending commands", () => { expect(std.out).toContain("Email Sending disabled for example.com"); }); + + it("should not send name for zone-level domain with --zone-id", async ({ + expect, + }) => { + mockConfirm({ + text: "Are you sure you want to disable Email Sending for example.com?", + result: true, + }); + mockZoneDetails("zone-id-1", "example.com"); + const reqProm = mockDisableSendingCapture(); + + await runWrangler( + "email sending disable example.com --zone-id zone-id-1" + ); + + const body = await reqProm; + expect(body).not.toHaveProperty("name"); + }); + + it("should send name for subdomain with --zone-id", async ({ expect }) => { + mockConfirm({ + text: "Are you sure you want to disable Email Sending for sub.example.com?", + result: true, + }); + mockZoneDetails("zone-id-1", "example.com"); + const reqProm = mockDisableSendingCapture(); + + await runWrangler( + "email sending disable sub.example.com --zone-id zone-id-1" + ); + + await expect(reqProm).resolves.toMatchObject({ + name: "sub.example.com", + }); + }); }); // --- dns get --- @@ -844,6 +938,39 @@ describe("email sending commands", () => { "No DNS records found for this sending domain." ); }); + + it("should use zone-level dns endpoint for zone domain with --zone-id", async ({ + expect, + }) => { + mockZoneDetails("zone-id-1", "example.com"); + mockGetSendingZoneDns(mockSendingDnsRecords); + + await runWrangler( + "email sending dns get example.com --zone-id zone-id-1" + ); + + expect(std.out).toContain("TXT"); + expect(std.out).toContain("v=spf1"); + }); + + it("should use subdomain dns endpoint for subdomain with --zone-id", async ({ + expect, + }) => { + mockZoneDetails("zone-id-1", "example.com"); + mockGetSendingSettings("zone-id-1"); + mockGetSendingDns( + "zone-id-1", + "aabbccdd11223344aabbccdd11223344", + mockSendingDnsRecords + ); + + await runWrangler( + "email sending dns get sub.example.com --zone-id zone-id-1" + ); + + expect(std.out).toContain("TXT"); + expect(std.out).toContain("v=spf1"); + }); }); // --- send --- @@ -1072,6 +1199,20 @@ function mockListEmailRoutingZones(settings: (typeof mockSettings)[]) { ); } +function mockZoneDetails(zoneId: string, zoneName: string) { + msw.use( + http.get( + `*/zones/${zoneId}`, + () => { + return HttpResponse.json( + createFetchResult({ id: zoneId, name: zoneName }, true) + ); + }, + { once: true } + ) + ); +} + function mockZoneLookup(domain: string, zoneId: string) { // Extract the zone name (last two labels) to handle subdomain lookups // resolveDomain walks up labels, so "sub.example.com" tries "sub.example.com" then "example.com" @@ -1344,6 +1485,25 @@ function mockEnableSending(_zoneId: string) { ); } +function mockEnableSendingCapture(): Promise> { + return new Promise((resolve) => { + msw.use( + http.post( + "*/zones/:zoneId/email/sending/enable", + async ({ request }) => { + const body = (await request.json()) as Record; + resolve(body); + const name = (body.name as string) || "example.com"; + return HttpResponse.json( + createFetchResult({ ...mockSettings, name, status: "ready" }, true) + ); + }, + { once: true } + ) + ); + }); +} + function mockDisableSending(_zoneId: string) { msw.use( http.post( @@ -1363,6 +1523,40 @@ function mockDisableSending(_zoneId: string) { ); } +function mockDisableSendingCapture(): Promise> { + return new Promise((resolve) => { + msw.use( + http.post( + "*/zones/:zoneId/email/sending/disable", + async ({ request }) => { + const body = (await request.json()) as Record; + resolve(body); + const name = (body.name as string) || "example.com"; + return HttpResponse.json( + createFetchResult( + { ...mockSettings, name, enabled: false, status: "unconfigured" }, + true + ) + ); + }, + { once: true } + ) + ); + }); +} + +function mockGetSendingZoneDns(records: typeof mockSendingDnsRecords) { + msw.use( + http.get( + "*/zones/:zoneId/email/sending/dns", + () => { + return HttpResponse.json(createFetchResult(records, true)); + }, + { once: true } + ) + ); +} + function mockGetSendingSettings(_zoneId: string) { msw.use( http.get( diff --git a/packages/wrangler/src/email-routing/utils.ts b/packages/wrangler/src/email-routing/utils.ts index 27014eddec..32d511cecc 100644 --- a/packages/wrangler/src/email-routing/utils.ts +++ b/packages/wrangler/src/email-routing/utils.ts @@ -1,5 +1,5 @@ import { UserError } from "@cloudflare/workers-utils"; -import { fetchListResult } from "../cfetch"; +import { fetchListResult, fetchResult } from "../cfetch"; import { requireAuth } from "../user"; import { retryOnAPIFailure } from "../utils/retry"; import type { ComplianceConfig, Config } from "@cloudflare/workers-utils"; @@ -59,15 +59,16 @@ export async function resolveDomain( domain: string, zoneId?: string ): Promise { - // If zone ID is provided directly, skip the zone lookup + // If zone ID is provided directly, fetch the zone name to determine subdomain status if (zoneId) { - // We don't know the zone name without a lookup, so approximate from the domain - const labels = domain.split("."); - const zoneName = labels.slice(-2).join("."); + await requireAuth(config); + const zone = await retryOnAPIFailure(() => + fetchResult<{ id: string; name: string }>(config, `/zones/${zoneId}`) + ); return { zoneId, - zoneName, - isSubdomain: domain !== zoneName, + zoneName: zone.name, + isSubdomain: domain !== zone.name, domain, }; } From b661aa450631d3d2ef527ffef7cb29282880bc0e Mon Sep 17 00:00:00 2001 From: Thomas Gauvin Date: Thu, 2 Apr 2026 00:48:59 -0400 Subject: [PATCH 30/32] test: update snapshots for new email OAuth scopes and command --- packages/wrangler/src/__tests__/deploy/core.test.ts | 4 ++-- packages/wrangler/src/__tests__/index.test.ts | 4 ++++ packages/wrangler/src/__tests__/user.test.ts | 12 ++++++------ packages/wrangler/src/__tests__/whoami.test.ts | 6 ++++++ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/wrangler/src/__tests__/deploy/core.test.ts b/packages/wrangler/src/__tests__/deploy/core.test.ts index aa44463f6e..06af1ea59f 100644 --- a/packages/wrangler/src/__tests__/deploy/core.test.ts +++ b/packages/wrangler/src/__tests__/deploy/core.test.ts @@ -727,7 +727,7 @@ describe("deploy", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in. Total Upload: xx KiB / gzip: xx KiB Worker Startup Time: 100 ms @@ -773,7 +773,7 @@ describe("deploy", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.staging.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.staging.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in. Total Upload: xx KiB / gzip: xx KiB Worker Startup Time: 100 ms diff --git a/packages/wrangler/src/__tests__/index.test.ts b/packages/wrangler/src/__tests__/index.test.ts index e936e2ff37..86ea30d970 100644 --- a/packages/wrangler/src/__tests__/index.test.ts +++ b/packages/wrangler/src/__tests__/index.test.ts @@ -39,6 +39,8 @@ describe("wrangler", () => { wrangler docs [search..] 📚 Open Wrangler's command documentation in your browser wrangler complete [shell] ⌨️ Generate and handle shell completions + wrangler email Manage Cloudflare Email services [open beta] + ACCOUNT wrangler auth 🔐 Manage authentication wrangler login 🔓 Login to Cloudflare @@ -111,6 +113,8 @@ describe("wrangler", () => { wrangler docs [search..] 📚 Open Wrangler's command documentation in your browser wrangler complete [shell] ⌨️ Generate and handle shell completions + wrangler email Manage Cloudflare Email services [open beta] + ACCOUNT wrangler auth 🔐 Manage authentication wrangler login 🔓 Login to Cloudflare diff --git a/packages/wrangler/src/__tests__/user.test.ts b/packages/wrangler/src/__tests__/user.test.ts index f01c7463fe..55e3f1e6ed 100644 --- a/packages/wrangler/src/__tests__/user.test.ts +++ b/packages/wrangler/src/__tests__/user.test.ts @@ -80,7 +80,7 @@ describe("User", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); expect(readAuthConfigFile()).toEqual({ @@ -126,7 +126,7 @@ describe("User", () => { Temporary login server listening on 0.0.0.0:8976 Note that the OAuth login page will always redirect to \`localhost:8976\`. If you have changed the callback host or port because you are running in a container, then ensure that you have port forwarding set up correctly. - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); expect(readAuthConfigFile()).toEqual({ @@ -172,7 +172,7 @@ describe("User", () => { Temporary login server listening on mylocalhost.local:8976 Note that the OAuth login page will always redirect to \`localhost:8976\`. If you have changed the callback host or port because you are running in a container, then ensure that you have port forwarding set up correctly. - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); expect(readAuthConfigFile()).toEqual({ @@ -218,7 +218,7 @@ describe("User", () => { Temporary login server listening on localhost:8787 Note that the OAuth login page will always redirect to \`localhost:8976\`. If you have changed the callback host or port because you are running in a container, then ensure that you have port forwarding set up correctly. - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); expect(readAuthConfigFile()).toEqual({ @@ -260,7 +260,7 @@ describe("User", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.staging.cloudflare.com/oauth2/auth?response_type=code&client_id=4b2ea6cc-9421-4761-874b-ce550e0e3def&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.staging.cloudflare.com/oauth2/auth?response_type=code&client_id=4b2ea6cc-9421-4761-874b-ce550e0e3def&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); @@ -389,7 +389,7 @@ describe("User", () => { ⛅️ wrangler x.x.x ────────────────── Attempting to login via OAuth... - Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 + Opening a link in your default browser: https://dash.cloudflare.com/oauth2/auth?response_type=code&client_id=54d11594-84e4-41aa-b438-e81b8fa78ee7&redirect_uri=http%3A%2F%2Flocalhost%3A8976%2Foauth%2Fcallback&scope=account%3Aread%20user%3Aread%20workers%3Awrite%20workers_kv%3Awrite%20workers_routes%3Awrite%20workers_scripts%3Awrite%20workers_tail%3Aread%20d1%3Awrite%20pages%3Awrite%20zone%3Aread%20ssl_certs%3Awrite%20ai%3Awrite%20ai-search%3Awrite%20ai-search%3Arun%20queues%3Awrite%20pipelines%3Awrite%20secrets_store%3Awrite%20containers%3Awrite%20cloudchamber%3Awrite%20connectivity%3Aadmin%20email_routing%3Awrite%20email_sending%3Awrite%20offline_access&state=MOCK_STATE_PARAM&code_challenge=MOCK_CODE_CHALLENGE&code_challenge_method=S256 Successfully logged in." `); expect(std.warn).toMatchInlineSnapshot(`""`); diff --git a/packages/wrangler/src/__tests__/whoami.test.ts b/packages/wrangler/src/__tests__/whoami.test.ts index 40f7c2cea1..96623cb0f6 100644 --- a/packages/wrangler/src/__tests__/whoami.test.ts +++ b/packages/wrangler/src/__tests__/whoami.test.ts @@ -344,6 +344,8 @@ describe("whoami", () => { - containers:write - cloudchamber:write - connectivity:admin + - email_routing:write + - email_sending:write 🎢 Membership roles in "Account Two": Contact account super admin to change your permissions. @@ -407,6 +409,8 @@ describe("whoami", () => { - containers:write - cloudchamber:write - connectivity:admin + - email_routing:write + - email_sending:write 🎢 Membership roles in "Account Two": Contact account super admin to change your permissions. @@ -516,6 +520,8 @@ describe("whoami", () => { - containers:write - cloudchamber:write - connectivity:admin + - email_routing:write + - email_sending:write 🎢 Unable to get membership roles. Make sure you have permissions to read the account. Are you missing the \`User->Memberships->Read\` permission?" From 187a8d7a01a4ae0a9c1a8cc2b71e120d31d2f452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Duarte?= Date: Thu, 2 Apr 2026 11:37:13 +0100 Subject: [PATCH 31/32] fix: clarify DNS unlock confirmation message with consequences --- packages/wrangler/src/email-routing/dns-unlock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wrangler/src/email-routing/dns-unlock.ts b/packages/wrangler/src/email-routing/dns-unlock.ts index 8b010dbcd1..48b73244e9 100644 --- a/packages/wrangler/src/email-routing/dns-unlock.ts +++ b/packages/wrangler/src/email-routing/dns-unlock.ts @@ -26,7 +26,7 @@ export const emailRoutingDnsUnlockCommand = createCommand({ if (!args.force) { const confirmed = await confirm( - "Are you sure you want to unlock MX records? This allows external MX records to be set.", + `Are you sure you want to unlock DNS records for '${args.domain ?? zoneId}'? This can allow external records to override Email Routing, which may cause deliverability issues or stop emails from being delivered through Cloudflare.`, { fallbackValue: false } ); if (!confirmed) { From 0abedff9446c3f22465dbd1dda0ff8010d88a8bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Duarte?= Date: Thu, 2 Apr 2026 11:51:05 +0100 Subject: [PATCH 32/32] test: update dns unlock mockConfirm expectations to match new message --- packages/wrangler/src/__tests__/email-routing.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wrangler/src/__tests__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts index a78ae747d2..e8b4403e99 100644 --- a/packages/wrangler/src/__tests__/email-routing.test.ts +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -347,7 +347,7 @@ describe("email routing commands", () => { describe("dns unlock", () => { it("should unlock dns records", async ({ expect }) => { mockConfirm({ - text: "Are you sure you want to unlock MX records? This allows external MX records to be set.", + text: "Are you sure you want to unlock DNS records for 'example.com'? This can allow external records to override Email Routing, which may cause deliverability issues or stop emails from being delivered through Cloudflare.", result: true, }); mockUnlockDns(mockSettings); @@ -371,7 +371,7 @@ describe("email routing commands", () => { it("should abort when user declines confirmation", async ({ expect }) => { mockConfirm({ - text: "Are you sure you want to unlock MX records? This allows external MX records to be set.", + text: "Are you sure you want to unlock DNS records for 'example.com'? This can allow external records to override Email Routing, which may cause deliverability issues or stop emails from being delivered through Cloudflare.", result: false, });