diff --git a/.changeset/email-service-commands.md b/.changeset/email-service-commands.md new file mode 100644 index 0000000000..9b3e40eab1 --- /dev/null +++ b/.changeset/email-service-commands.md @@ -0,0 +1,26 @@ +--- +"wrangler": minor +--- + +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 addresses list/get/create/delete` - manage destination addresses + +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 + +Also adds `email_routing:write` and `email_sending:write` OAuth scopes to `wrangler login`. diff --git a/packages/wrangler/src/__tests__/deploy/core.test.ts b/packages/wrangler/src/__tests__/deploy/core.test.ts index a23e4cf713..1d19733b76 100644 --- a/packages/wrangler/src/__tests__/deploy/core.test.ts +++ b/packages/wrangler/src/__tests__/deploy/core.test.ts @@ -726,7 +726,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 @@ -772,7 +772,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__/email-routing.test.ts b/packages/wrangler/src/__tests__/email-routing.test.ts new file mode 100644 index 0000000000..e8b4403e99 --- /dev/null +++ b/packages/wrangler/src/__tests__/email-routing.test.ts @@ -0,0 +1,1642 @@ +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"; +import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { clearDialogs, mockConfirm } from "./helpers/mock-dialogs"; +import { useMockIsTTY } from "./helpers/mock-istty"; +import { createFetchResult, msw } from "./helpers/msw"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import { runWrangler } from "./helpers/run-wrangler"; + +// --- Mock data --- + +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", +}; + +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", () => { + const std = mockConsoleMethods(); + runInTempDir(); + + it("should show help text for email routing", async ({ expect }) => { + 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 ({ expect }) => { + 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 ({ + expect, + }) => { + await runWrangler("email routing addresses"); + await endEventLoop(); + + expect(std.err).toMatchInlineSnapshot(`""`); + 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(); + + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toContain("Manage Email Sending"); + }); + + 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 DNS records"); + }); +}); + +// --- Email Routing 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 ({ expect }) => { + mockListEmailRoutingZones([mockSettings]); + + await runWrangler("email routing list"); + + expect(std.out).toContain("example.com"); + expect(std.out).toContain("yes"); + }); + + it("should handle no zones", async ({ expect }) => { + mockListEmailRoutingZones([]); + + await runWrangler("email routing list"); + + expect(std.out).toContain( + "No zones found with Email Routing in this account." + ); + }); + + 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("no"); + }); + }); + + // --- zone validation --- + + describe("zone validation", () => { + it("should error when domain is not found", async ({ expect }) => { + // 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 notfound.com") + ).rejects.toThrow("Could not find zone for `notfound.com`"); + }); + }); + + // --- settings --- + + describe("settings", () => { + it("should get settings with --zone-id", async ({ expect }) => { + mockGetSettings(mockSettings); + + 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 domain resolution", async ({ expect }) => { + mockZoneLookup("example.com", "zone-id-1"); + mockGetSettings(mockSettings); + + await runWrangler("email routing settings example.com"); + + expect(std.out).toContain("Email Routing for example.com"); + }); + }); + + // --- enable --- + + describe("enable", () => { + it("should enable email routing", async ({ expect }) => { + mockEnableEmailRouting(mockSettings); + + await runWrangler("email routing enable example.com --zone-id zone-id-1"); + + expect(std.out).toContain("Email Routing enabled for example.com"); + }); + }); + + // --- disable --- + + describe("disable", () => { + it("should disable email routing", async ({ expect }) => { + mockConfirm({ + text: "Are you sure you want to disable Email Routing for this zone?", + result: true, + }); + mockDisableEmailRouting({ + ...mockSettings, + enabled: false, + }); + + await runWrangler( + "email routing disable example.com --zone-id zone-id-1" + ); + + 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 --- + + describe("dns get", () => { + it("should show dns records", async ({ expect }) => { + mockGetDns(mockDnsRecords); + + 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"); + }); + + 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 --- + + describe("dns unlock", () => { + it("should unlock dns records", async ({ expect }) => { + mockConfirm({ + 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); + + await runWrangler( + "email routing dns unlock example.com --zone-id zone-id-1" + ); + + 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 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, + }); + + await runWrangler( + "email routing dns unlock example.com --zone-id zone-id-1" + ); + + expect(std.out).toContain("Not unlocking."); + }); + }); + + // --- rules list --- + + describe("rules list", () => { + it("should list routing rules", async ({ expect }) => { + mockListRules([mockRule]); + + 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"); + }); + + it("should handle no rules", async ({ expect }) => { + mockListRules([]); + + await runWrangler( + "email routing rules list example.com --zone-id zone-id-1" + ); + + 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 --- + + describe("rules get", () => { + it("should get a specific rule", async ({ expect }) => { + mockGetRule(mockRule); + + await runWrangler( + "email routing rules get example.com 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"); + }); + + it("should get the catch-all rule when rule-id is 'catch-all'", async ({ + expect, + }) => { + mockGetCatchAll(mockCatchAll); + + await runWrangler( + "email routing rules get example.com 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"); + }); + + 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 --- + + describe("rules create", () => { + it("should create a forwarding rule", async ({ expect }) => { + const reqProm = mockCreateRule(); + + await 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 --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 ({ + expect, + }) => { + const reqProm = mockCreateRule(); + + await runWrangler( + "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({ + 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 ({ + 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" + ) + ).rejects.toThrow( + "--action-value is required when --action-type is not 'drop'" + ); + }); + }); + + // --- rules update --- + + describe("rules update", () => { + it("should update a routing rule", async ({ expect }) => { + const reqProm = mockUpdateRule(); + + await runWrangler( + "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({ + matchers: [ + { type: "literal", field: "to", value: "updated@example.com" }, + ], + actions: [{ type: "forward", value: ["newdest@example.com"] }], + }); + + expect(std.out).toContain("Updated routing rule:"); + }); + + it("should update the catch-all rule to drop", async ({ expect }) => { + const reqProm = mockUpdateCatchAll(); + + await runWrangler( + "email routing rules update example.com catch-all --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 ({ expect }) => { + const reqProm = mockUpdateCatchAll(); + + await runWrangler( + "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({ + actions: [{ type: "forward", value: ["catchall@example.com"] }], + matchers: [{ type: "all" }], + }); + + expect(std.out).toContain("Updated catch-all rule:"); + }); + + 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" + ) + ).rejects.toThrow( + "--action-value is required when --action-type is 'forward'" + ); + }); + + 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" + ) + ).rejects.toThrow( + "--match-type is required when updating a regular rule" + ); + }); + }); + + // --- rules delete --- + + describe("rules delete", () => { + it("should delete a routing rule", async ({ expect }) => { + mockConfirm({ + text: "Are you sure you want to delete routing rule 'rule-id-1'?", + result: true, + }); + mockDeleteRule(); + + await runWrangler( + "email routing rules delete example.com rule-id-1 --zone-id zone-id-1" + ); + + 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 --- + + describe("addresses list", () => { + it("should list destination addresses", async ({ expect }) => { + 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 ({ expect }) => { + 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 ({ expect }) => { + mockGetAddress(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 ({ expect }) => { + 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 ({ expect }) => { + 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"); + + expect(std.out).toContain("Deleted destination address: addr-id-1"); + }); + }); +}); + +// --- 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(); + }); + + // --- 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", () => { + it("should enable sending for a zone", async ({ expect }) => { + mockZoneLookup("example.com", "zone-id-1"); + mockEnableSending("zone-id-1"); + + await runWrangler("email sending enable example.com"); + + expect(std.out).toContain("Email Sending enabled for example.com"); + }); + + it("should enable sending for a subdomain", async ({ expect }) => { + mockZoneLookup("sub.example.com", "zone-id-1"); + mockEnableSending("zone-id-1"); + + await runWrangler("email sending enable sub.example.com"); + + 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", () => { + 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"); + + await runWrangler("email sending disable example.com"); + + 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 --- + + describe("dns get", () => { + 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 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 sub.example.com"); + + expect(std.out).toContain( + "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 --- + + describe("send", () => { + it("should send an email with text body", async ({ expect }) => { + 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 ({ expect }) => { + 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 ({ expect }) => { + 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 ({ expect }) => { + 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 ({ expect }) => { + 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 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( + "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 ({ expect }) => { + 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 ({ expect }) => { + 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 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, + }) => { + 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)" + ); + }); + }); + + // --- 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 --- + +function mockListEmailRoutingZones(settings: (typeof mockSettings)[]) { + msw.use( + http.get( + "*/accounts/:accountId/email/routing/zones", + () => { + return HttpResponse.json(createFetchResult(settings, true)); + }, + { once: true } + ) + ); +} + +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" + 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)); + }) + ); +} + +function mockGetSettings(settings: typeof mockSettings) { + msw.use( + http.get( + "*/zones/:zoneId/email/routing", + () => { + return HttpResponse.json(createFetchResult(settings, true)); + }, + { once: true } + ) + ); +} + +function mockEnableEmailRouting(settings: typeof mockSettings) { + msw.use( + http.post( + "*/zones/:zoneId/email/routing/enable", + () => { + return HttpResponse.json(createFetchResult(settings, true)); + }, + { once: true } + ) + ); +} + +function mockDisableEmailRouting(settings: typeof mockSettings) { + msw.use( + http.post( + "*/zones/:zoneId/email/routing/disable", + () => { + return HttpResponse.json(createFetchResult(settings, true)); + }, + { once: true } + ) + ); +} + +function mockGetDns(records: typeof mockDnsRecords) { + msw.use( + http.get( + "*/zones/:zoneId/email/routing/dns", + () => { + return HttpResponse.json(createFetchResult(records, true)); + }, + { once: true } + ) + ); +} + +function mockUnlockDns(settings: typeof mockSettings) { + msw.use( + http.post( + "*/zones/:zoneId/email/routing/unlock", + () => { + return HttpResponse.json(createFetchResult(settings, true)); + }, + { once: true } + ) + ); +} + +function mockListRules(rules: (typeof mockRule)[]) { + msw.use( + http.get( + "*/zones/:zoneId/email/routing/rules", + () => { + return HttpResponse.json(createFetchResult(rules, true)); + }, + { once: true } + ) + ); +} + +function mockGetRule(rule: typeof mockRule) { + msw.use( + http.get( + "*/zones/:zoneId/email/routing/rules/:ruleId", + () => { + return HttpResponse.json(createFetchResult(rule, true)); + }, + { once: true } + ) + ); +} + +function mockCreateRule(): Promise { + return new Promise((resolve) => { + msw.use( + http.post( + "*/zones/:zoneId/email/routing/rules", + async ({ request }) => { + const reqBody = (await request.json()) as Record; + resolve(reqBody); + return HttpResponse.json( + createFetchResult({ id: "new-rule-id", ...reqBody }, true) + ); + }, + { once: true } + ) + ); + }); +} + +function mockUpdateRule(): Promise { + return new Promise((resolve) => { + msw.use( + http.put( + "*/zones/:zoneId/email/routing/rules/:ruleId", + async ({ request }) => { + const reqBody = (await request.json()) as Record; + resolve(reqBody); + return HttpResponse.json( + createFetchResult({ id: "rule-id-1", ...reqBody }, true) + ); + }, + { once: true } + ) + ); + }); +} + +function mockDeleteRule() { + msw.use( + http.delete( + "*/zones/:zoneId/email/routing/rules/:ruleId", + () => { + return HttpResponse.json(createFetchResult(mockRule, true)); + }, + { once: true } + ) + ); +} + +function mockGetCatchAll(catchAll: typeof mockCatchAll) { + msw.use( + http.get( + "*/zones/:zoneId/email/routing/rules/catch_all", + () => { + return HttpResponse.json(createFetchResult(catchAll, true)); + }, + { once: true } + ) + ); +} + +function mockUpdateCatchAll(): Promise { + return new Promise((resolve) => { + msw.use( + http.put( + "*/zones/:zoneId/email/routing/rules/catch_all", + async ({ request }) => { + const reqBody = (await request.json()) as Record; + 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(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() { + msw.use( + http.delete( + "*/accounts/:accountId/email/routing/addresses/:addressId", + () => { + return HttpResponse.json(createFetchResult(mockAddress, true)); + }, + { once: true } + ) + ); +} + +// --- 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( + "*/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 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( + "*/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 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( + "*/zones/:zoneId/email/sending", + () => { + return HttpResponse.json( + createFetchResult( + { + ...mockSettings, + subdomains: [mockSubdomain], + }, + 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/__tests__/index.test.ts b/packages/wrangler/src/__tests__/index.test.ts index bb4bf3733b..e6c1158e13 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 @@ -112,6 +114,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?" diff --git a/packages/wrangler/src/cfetch/index.ts b/packages/wrangler/src/cfetch/index.ts index b4b55db190..33b2653973 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/core/teams.d.ts b/packages/wrangler/src/core/teams.d.ts index 96e4ed1e44..b366da6cef 100644 --- a/packages/wrangler/src/core/teams.d.ts +++ b/packages/wrangler/src/core/teams.d.ts @@ -22,4 +22,5 @@ export type Teams = | "Product: Cloudchamber" | "Product: SSL" | "Product: WVPC" - | "Product: Tunnels"; + | "Product: Tunnels" + | "Product: Email Service"; 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..8f64191a41 --- /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 Service", + }, + 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..361b08732a --- /dev/null +++ b/packages/wrangler/src/email-routing/addresses/delete.ts @@ -0,0 +1,42 @@ +import { createCommand } from "../../core/create-command"; +import { confirm } from "../../dialogs"; +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 Service", + }, + args: { + "address-id": { + type: "string", + 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/addresses/get.ts b/packages/wrangler/src/email-routing/addresses/get.ts new file mode 100644 index 0000000000..ca8b144273 --- /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 Service", + }, + 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..be32b58a49 --- /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 Service", + }, + 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..5c4c1a0bad --- /dev/null +++ b/packages/wrangler/src/email-routing/client.ts @@ -0,0 +1,397 @@ +import { fetchPagedListResult, fetchResult } from "../cfetch"; +import { requireAuth } from "../user"; +import type { + EmailRoutingAddress, + EmailRoutingCatchAllRule, + EmailRoutingDnsRecord, + EmailRoutingRule, + EmailRoutingSettings, + EmailSendingDnsRecord, + EmailSendingSendResponse, + EmailSendingSettings, +} from "./index"; +import type { Config } from "@cloudflare/workers-utils"; + +export async function listEmailRoutingZones( + config: Config +): Promise { + const accountId = await requireAuth(config); + return await fetchPagedListResult( + config, + `/accounts/${accountId}/email/routing/zones` + ); +} + +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 +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing` + ); +} + +export async function enableEmailRouting( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/routing/enable`, + { + 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/disable`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ); +} + +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/unlock`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ); +} + +export async function listEmailRoutingRules( + config: Config, + zoneId: string +): Promise { + await requireAuth(config); + return await fetchPagedListResult( + config, + `/zones/${zoneId}/email/routing/rules`, + {}, + new URLSearchParams({ order: "created", direction: "asc" }) + ); +} + +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", + } + ); +} + +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), + } + ); +} + +export async function listEmailRoutingAddresses( + config: Config +): Promise { + const accountId = await requireAuth(config); + return await fetchPagedListResult( + config, + `/accounts/${accountId}/email/routing/addresses`, + {}, + new URLSearchParams({ order: "created", direction: "asc" }) + ); +} + +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", + } + ); +} + +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, + name?: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending/enable`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(name ? { name } : {}), + } + ); +} + +export async function disableEmailSending( + config: Config, + zoneId: string, + name?: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending/disable`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(name ? { name } : {}), + } + ); +} + +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, + subdomainId: string +): Promise { + await requireAuth(config); + return await fetchResult( + config, + `/zones/${zoneId}/email/sending/subdomains/${subdomainId}/dns` + ); +} + +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/disable.ts b/packages/wrangler/src/email-routing/disable.ts new file mode 100644 index 0000000000..c8f0141d6f --- /dev/null +++ b/packages/wrangler/src/email-routing/disable.ts @@ -0,0 +1,44 @@ +import { createCommand } from "../core/create-command"; +import { confirm } from "../dialogs"; +import { logger } from "../logger"; +import { disableEmailRouting } from "./client"; +import { resolveZoneId } from "./utils"; +import { domainArgs } from "./index"; + +export const emailRoutingDisableCommand = createCommand({ + metadata: { + description: "Disable Email Routing for a zone", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...domainArgs, + force: { + type: "boolean", + alias: "y", + description: "Skip confirmation", + default: false, + }, + }, + positionalArgs: ["domain"], + 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( + `Email Routing disabled for ${settings.name} (status: ${settings.status})` + ); + }, +}); 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..78e9e04510 --- /dev/null +++ b/packages/wrangler/src/email-routing/dns-get.ts @@ -0,0 +1,37 @@ +import { createCommand } from "../core/create-command"; +import { logger } from "../logger"; +import { getEmailRoutingDns } from "./client"; +import { resolveZoneId } from "./utils"; +import { domainArgs } from "./index"; + +export const emailRoutingDnsGetCommand = createCommand({ + metadata: { + description: "Show DNS records required for Email Routing", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...domainArgs, + }, + positionalArgs: ["domain"], + 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; + } + + 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/dns-unlock.ts b/packages/wrangler/src/email-routing/dns-unlock.ts new file mode 100644 index 0000000000..48b73244e9 --- /dev/null +++ b/packages/wrangler/src/email-routing/dns-unlock.ts @@ -0,0 +1,44 @@ +import { createCommand } from "../core/create-command"; +import { confirm } from "../dialogs"; +import { logger } from "../logger"; +import { unlockEmailRoutingDns } from "./client"; +import { resolveZoneId } from "./utils"; +import { domainArgs } from "./index"; + +export const emailRoutingDnsUnlockCommand = createCommand({ + metadata: { + description: "Unlock MX records for Email Routing", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...domainArgs, + force: { + type: "boolean", + alias: "y", + description: "Skip confirmation", + default: false, + }, + }, + positionalArgs: ["domain"], + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + + if (!args.force) { + const confirmed = await confirm( + `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) { + logger.log("Not unlocking."); + return; + } + } + + const settings = await unlockEmailRoutingDns(config, zoneId); + + logger.log( + `MX records unlocked for ${settings.name} (enabled: ${settings.enabled})` + ); + }, +}); diff --git a/packages/wrangler/src/email-routing/enable.ts b/packages/wrangler/src/email-routing/enable.ts new file mode 100644 index 0000000000..53b65624c9 --- /dev/null +++ b/packages/wrangler/src/email-routing/enable.ts @@ -0,0 +1,25 @@ +import { createCommand } from "../core/create-command"; +import { logger } from "../logger"; +import { enableEmailRouting } from "./client"; +import { resolveZoneId } from "./utils"; +import { domainArgs } from "./index"; + +export const emailRoutingEnableCommand = createCommand({ + metadata: { + description: "Enable Email Routing for a zone", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...domainArgs, + }, + positionalArgs: ["domain"], + 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..df141bea51 --- /dev/null +++ b/packages/wrangler/src/email-routing/index.ts @@ -0,0 +1,152 @@ +import { createNamespace } from "../core/create-command"; + +export const emailNamespace = createNamespace({ + metadata: { + description: "Manage Cloudflare Email services", + status: "open beta", + owner: "Product: Email Service", + }, +}); + +export const emailRoutingNamespace = createNamespace({ + metadata: { + description: "Manage Email Routing", + status: "open beta", + owner: "Product: Email Service", + }, +}); + +export const emailRoutingDnsNamespace = createNamespace({ + metadata: { + description: "Manage Email Routing DNS settings", + status: "open beta", + owner: "Product: Email Service", + }, +}); + +export const emailRoutingRulesNamespace = createNamespace({ + metadata: { + description: "Manage Email Routing rules", + status: "open beta", + owner: "Product: Email Service", + }, +}); + +export const emailRoutingAddressesNamespace = createNamespace({ + metadata: { + description: "Manage Email Routing destination addresses", + status: "open beta", + owner: "Product: Email Service", + }, +}); + +export const emailSendingNamespace = createNamespace({ + metadata: { + description: "Manage Email Sending", + status: "open beta", + owner: "Product: Email Service", + }, +}); + +export const emailSendingDnsNamespace = createNamespace({ + metadata: { + description: "Manage Email Sending DNS records", + status: "open beta", + owner: "Product: Email Service", + }, +}); + +export const domainArgs = { + domain: { + type: "string", + demandOption: true, + description: "Domain name (e.g. example.com)", + }, + "zone-id": { + type: "string", + description: "Zone ID (optional, skips zone lookup if provided)", + }, +} as const; + +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: EmailRoutingAction[]; + enabled: boolean; + matchers: { type: string }[]; + name: string; + tag: string; +} + +export interface EmailRoutingAddress { + id: string; + created: string; + email: string; + modified: string; + tag: string; + 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; + priority?: number; + ttl?: number; + type?: string; +} + +export interface EmailSendingSendResponse { + delivered: string[]; + permanent_bounces: string[]; + queued: 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..1e78a0c67e --- /dev/null +++ b/packages/wrangler/src/email-routing/list.ts @@ -0,0 +1,28 @@ +import { createCommand } from "../core/create-command"; +import { logger } from "../logger"; +import { listEmailRoutingZones } from "./client"; + +export const emailRoutingListCommand = createCommand({ + metadata: { + description: "List zones with Email Routing", + status: "open beta", + owner: "Product: Email Service", + }, + args: {}, + async handler(_args, { config }) { + const zones = await listEmailRoutingZones(config); + + if (zones.length === 0) { + logger.log("No zones found with Email Routing in this account."); + return; + } + + const results = zones.map((zone) => ({ + zone: zone.name, + "zone id": zone.id, + enabled: zone.enabled ? "yes" : "no", + })); + + logger.table(results); + }, +}); 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..e32660e272 --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/create.ts @@ -0,0 +1,88 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { createEmailRoutingRule } from "../client"; +import { domainArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailRoutingRulesCreateCommand = createCommand({ + metadata: { + description: "Create an Email Routing rule", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...domainArgs, + 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", + }, + }, + positionalArgs: ["domain"], + 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..d1b0d71274 --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/delete.ts @@ -0,0 +1,47 @@ +import { createCommand } from "../../core/create-command"; +import { confirm } from "../../dialogs"; +import { logger } from "../../logger"; +import { deleteEmailRoutingRule } from "../client"; +import { domainArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailRoutingRulesDeleteCommand = createCommand({ + metadata: { + description: "Delete an Email Routing rule", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...domainArgs, + "rule-id": { + type: "string", + demandOption: true, + description: "The ID of the routing rule to delete", + }, + force: { + type: "boolean", + alias: "y", + description: "Skip confirmation", + default: false, + }, + }, + positionalArgs: ["domain", "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 new file mode 100644 index 0000000000..b087ee47be --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/get.ts @@ -0,0 +1,94 @@ +import { APIError } from "@cloudflare/workers-utils"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { getEmailRoutingCatchAll, getEmailRoutingRule } from "../client"; +import { domainArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +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", + owner: "Product: Email Service", + }, + args: { + ...domainArgs, + "rule-id": { + type: "string", + demandOption: true, + description: + "The ID of the routing rule, or 'catch-all' for the catch-all rule", + }, + }, + positionalArgs: ["domain", "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; + } + + 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 || catchAllRule.id === 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)"}`); + 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..9d3ca9606f --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/list.ts @@ -0,0 +1,64 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { listEmailRoutingRules } from "../client"; +import { domainArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +export const emailRoutingRulesListCommand = createCommand({ + metadata: { + description: "List Email Routing rules", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...domainArgs, + }, + positionalArgs: ["domain"], + async handler(args, { config }) { + const zoneId = await resolveZoneId(config, args); + const rules = await listEmailRoutingRules(config, zoneId); + + 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 && !catchAll) { + logger.log("No routing rules found."); + } else if (regularRules.length === 0) { + logger.log("No custom routing rules found."); + } else { + 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) { + 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/rules/update.ts b/packages/wrangler/src/email-routing/rules/update.ts new file mode 100644 index 0000000000..e760755c58 --- /dev/null +++ b/packages/wrangler/src/email-routing/rules/update.ts @@ -0,0 +1,161 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { updateEmailRoutingCatchAll, updateEmailRoutingRule } from "../client"; +import { domainArgs } from "../index"; +import { resolveZoneId } from "../utils"; + +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", + owner: "Product: Email Service", + }, + args: { + ...domainArgs, + "rule-id": { + type: "string", + demandOption: true, + description: + "The ID of the routing rule to update, or 'catch-all' for the catch-all rule", + }, + name: { + type: "string", + description: "Rule name", + }, + enabled: { + type: "boolean", + description: "Whether the rule is enabled", + }, + "match-type": { + type: "string", + description: + "Matcher type (e.g. literal). Required for regular rules, ignored for catch-all.", + }, + "match-field": { + type: "string", + description: + "Matcher field (e.g. to). Required for regular rules, ignored for catch-all.", + }, + "match-value": { + type: "string", + description: + "Matcher value (e.g. user@example.com). Required for regular rules, ignored for catch-all.", + }, + "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 (ignored for catch-all)", + }, + }, + positionalArgs: ["domain", "rule-id"], + validateArgs: (args) => { + 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, + name: args.name, + }); + + 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; + } + + // 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: [ + { + 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/sending/disable.ts b/packages/wrangler/src/email-routing/sending/disable.ts new file mode 100644 index 0000000000..60f6702026 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/disable.ts @@ -0,0 +1,57 @@ +import { createCommand } from "../../core/create-command"; +import { confirm } from "../../dialogs"; +import { logger } from "../../logger"; +import { disableEmailSending } from "../client"; +import { resolveDomain } from "../utils"; + +export const emailSendingDisableCommand = createCommand({ + metadata: { + description: "Disable Email Sending for a zone or subdomain", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + domain: { + type: "string", + demandOption: true, + 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.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); + + 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 new file mode 100644 index 0000000000..711d503208 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/dns-get.ts @@ -0,0 +1,73 @@ +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: { + description: "Get DNS records for an Email Sending domain", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + domain: { + type: "string", + demandOption: true, + 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, isSubdomain } = await resolveDomain( + config, + args.domain, + args.zoneId + ); + + let records: EmailSendingDnsRecord[]; + + if (!isSubdomain) { + // Zone-level sending domain uses /email/sending/dns + records = await getEmailSendingDns(config, zoneId); + } 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); + 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); + } + + if (records.length === 0) { + logger.log("No DNS records found for this sending domain."); + return; + } + + 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(""); + } + }, +}); 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..c0237e810a --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/enable.ts @@ -0,0 +1,38 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { enableEmailSending } from "../client"; +import { resolveDomain } from "../utils"; + +export const emailSendingEnableCommand = createCommand({ + metadata: { + description: "Enable Email Sending for a zone or subdomain", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + domain: { + type: "string", + demandOption: true, + 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.zoneId + ); + 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/list.ts b/packages/wrangler/src/email-routing/sending/list.ts new file mode 100644 index 0000000000..f27481be3c --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/list.ts @@ -0,0 +1,28 @@ +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", + })); + + 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 new file mode 100644 index 0000000000..5432c2a820 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/send-raw.ts @@ -0,0 +1,68 @@ +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"; + +export const emailSendingSendRawCommand = createCommand({ + metadata: { + description: "Send a raw MIME email message", + status: "open beta", + owner: "Product: Email Service", + }, + 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) { + 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 ?? ""; + } + + const result = await sendRawEmail(config, { + from: args.from, + recipients: args.to, + mime_message: mimeMessage, + }); + + logSendResult(result); + }, +}); 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..bb44253e33 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/send.ts @@ -0,0 +1,191 @@ +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"; + +export const emailSendingSendCommand = createCommand({ + metadata: { + description: "Send an email using the Email Sending builder", + status: "open beta", + owner: "Product: Email Service", + }, + 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, + }); + + logSendResult(result); + }, +}); + +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'.` + ); + } + 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; +} + +function parseAttachments(attachmentPaths: string[] | undefined): Array<{ + content: string; + filename: string; + type: string; + disposition: "attachment"; +}> { + if (!attachmentPaths) { + return []; + } + return attachmentPaths.map((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 { + 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/settings.ts b/packages/wrangler/src/email-routing/sending/settings.ts new file mode 100644 index 0000000000..e50abb15a2 --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/settings.ts @@ -0,0 +1,44 @@ +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { getEmailSendingSettings } from "../client"; +import { resolveDomain } from "../utils"; + +export const emailSendingSettingsCommand = createCommand({ + metadata: { + description: "Get Email Sending settings for a zone", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + domain: { + type: "string", + 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, args.zoneId); + 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}`); + + const subdomains = settings.subdomains; + if (Array.isArray(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/utils.ts b/packages/wrangler/src/email-routing/sending/utils.ts new file mode 100644 index 0000000000..9c97ae265f --- /dev/null +++ b/packages/wrangler/src/email-routing/sending/utils.ts @@ -0,0 +1,21 @@ +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/settings.ts b/packages/wrangler/src/email-routing/settings.ts new file mode 100644 index 0000000000..bcd220338f --- /dev/null +++ b/packages/wrangler/src/email-routing/settings.ts @@ -0,0 +1,28 @@ +import { createCommand } from "../core/create-command"; +import { logger } from "../logger"; +import { getEmailRoutingSettings } from "./client"; +import { resolveZoneId } from "./utils"; +import { domainArgs } from "./index"; + +export const emailRoutingSettingsCommand = createCommand({ + metadata: { + description: "Get Email Routing settings for a zone", + status: "open beta", + owner: "Product: Email Service", + }, + args: { + ...domainArgs, + }, + positionalArgs: ["domain"], + 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..32d511cecc --- /dev/null +++ b/packages/wrangler/src/email-routing/utils.ts @@ -0,0 +1,106 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { fetchListResult, fetchResult } from "../cfetch"; +import { requireAuth } from "../user"; +import { retryOnAPIFailure } from "../utils/retry"; +import type { ComplianceConfig, Config } from "@cloudflare/workers-utils"; + +export async function resolveZoneId( + config: Config, + args: { domain?: string; zoneId?: string } +): Promise { + if (args.zoneId) { + return args.zoneId; + } + + if (args.domain) { + const accountId = await requireAuth(config); + return await getZoneIdByDomain(config, args.domain, accountId); + } + + throw new UserError("You must provide a domain or --zone-id."); +} + +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; +} + +export interface ResolvedDomain { + zoneId: string; + zoneName: string; + isSubdomain: boolean; + domain: string; +} + +export async function resolveDomain( + config: Config, + domain: string, + zoneId?: string +): Promise { + // If zone ID is provided directly, fetch the zone name to determine subdomain status + if (zoneId) { + await requireAuth(config); + const zone = await retryOnAPIFailure(() => + fetchResult<{ id: string; name: string }>(config, `/zones/${zoneId}`) + ); + return { + zoneId, + zoneName: zone.name, + isSubdomain: domain !== zone.name, + domain, + }; + } + + 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 c1fdc073b0..0e5820f5bc 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -105,6 +105,37 @@ 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, + emailRoutingDnsNamespace, + emailRoutingNamespace, + emailRoutingRulesNamespace, + emailSendingDnsNamespace, + emailSendingNamespace, +} from "./email-routing/index"; +import { emailRoutingListCommand } from "./email-routing/list"; +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 { 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"; +import { emailRoutingSettingsCommand } from "./email-routing/settings"; import { helloWorldGetCommand, helloWorldNamespace, @@ -1873,6 +1904,117 @@ 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 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, + }, + { command: "wrangler email sending", definition: emailSendingNamespace }, + { + command: "wrangler email sending list", + definition: emailSendingListCommand, + }, + { + 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, + }, + { + command: "wrangler email sending send-raw", + definition: emailSendingSendRawCommand, + }, + { + command: "wrangler email sending dns", + definition: emailSendingDnsNamespace, + }, + { + command: "wrangler email sending dns get", + definition: emailSendingDnsGetCommand, + }, + ]); + 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..9c2f6b126f 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -377,7 +377,11 @@ 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.", + "email_sending:write": + "See and change Email Sending settings and configuration.", } as const; /**