From 8d17851b88c45538234b14b7b0b808030462db9d Mon Sep 17 00:00:00 2001 From: dielduarte Date: Tue, 11 Aug 2026 11:11:49 -0300 Subject: [PATCH 1/3] feat(broadcasts): add cancel command Cancels a queued or scheduled broadcast via the SDK's new broadcasts.cancel(), which bumps the resend dependency to 6.19.0. Adds a 'scheduled' case to the status indicator, used by the new picker that filters to cancelable (queued/scheduled) broadcasts. Co-Authored-By: Claude Sonnet 5 --- package.json | 2 +- pnpm-lock.yaml | 10 +- skills/resend-cli/references/broadcasts.md | 10 ++ src/commands/broadcasts/cancel.ts | 38 ++++++ src/commands/broadcasts/index.ts | 8 +- src/commands/broadcasts/utils.ts | 18 +++ tests/commands/broadcasts/cancel.test.ts | 136 +++++++++++++++++++++ 7 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 src/commands/broadcasts/cancel.ts create mode 100644 tests/commands/broadcasts/cancel.test.ts diff --git a/package.json b/package.json index 8c46a05b..ebb71053 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "esbuild": "0.28.1", "esbuild-wasm": "0.28.0", "picocolors": "1.1.1", - "resend": "6.18.1" + "resend": "6.19.0" }, "pkg": { "scripts": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa369486..412b8c75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: 1.1.1 version: 1.1.1 resend: - specifier: 6.18.1 - version: 6.18.1 + specifier: 6.19.0 + version: 6.19.0 devDependencies: '@biomejs/biome': specifier: 2.4.11 @@ -1226,8 +1226,8 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - resend@6.18.1: - resolution: {integrity: sha512-XN8XIaDdKF+ziSQ3K23ndUcyhP7U3ze2gky6SPgYkuAOq54mH4Wdhwm7QylEQ3zlz0NzdX7/l1AgmJUZbdPI/Q==} + resend@6.19.0: + resolution: {integrity: sha512-JnEdYnd9WyBDIzunsEZtUF8n3cBKM9SlmySaos/7wwi4sEXKG1Zz4M64VFAyk+278erX01Fq2wHQ3p7OIdPriA==} engines: {node: '>=20'} peerDependencies: '@react-email/render': '*' @@ -2431,7 +2431,7 @@ snapshots: require-directory@2.1.1: {} - resend@6.18.1: + resend@6.19.0: dependencies: postal-mime: 2.7.5 standardwebhooks: 1.0.0 diff --git a/skills/resend-cli/references/broadcasts.md b/skills/resend-cli/references/broadcasts.md index 61618c70..8320e4a8 100644 --- a/skills/resend-cli/references/broadcasts.md +++ b/skills/resend-cli/references/broadcasts.md @@ -73,6 +73,16 @@ Send a draft broadcast. --- +## broadcasts cancel + +Cancel a queued or scheduled broadcast without removing it. + +**Argument:** `` — Broadcast ID + +Cancelling a queued broadcast stops it mid-send — emails already sent are not affected. Cancelling a scheduled broadcast reverts it to draft. Draft and sent broadcasts cannot be cancelled. + +--- + ## broadcasts delete **Argument:** `` — Broadcast ID diff --git a/src/commands/broadcasts/cancel.ts b/src/commands/broadcasts/cancel.ts new file mode 100644 index 00000000..fdffbde9 --- /dev/null +++ b/src/commands/broadcasts/cancel.ts @@ -0,0 +1,38 @@ +import { Command } from '@commander-js/extra-typings'; +import { runWrite } from '../../lib/actions'; +import type { GlobalOpts } from '../../lib/client'; +import { buildHelpText } from '../../lib/help-text'; +import { pickId } from '../../lib/prompts'; +import { cancelBroadcastPickerConfig } from './utils'; + +export const cancelBroadcastCommand = new Command('cancel') + .description('Cancel a queued or scheduled broadcast') + .argument('[id]', 'Broadcast ID') + .addHelpText( + 'after', + buildHelpText({ + context: `Only queued or scheduled broadcasts can be cancelled; draft and sent broadcasts cannot. +Cancelling a scheduled broadcast stops the scheduled delivery. Cancelling a queued +broadcast stops it mid-send — emails already sent are not affected.`, + output: ` {"object":"broadcast","id":""}`, + errorCodes: ['auth_error', 'cancel_error'], + examples: [ + 'resend broadcasts cancel d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', + 'resend broadcasts cancel d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --json', + ], + }), + ) + .action(async (idArg, _opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + const id = await pickId(idArg, cancelBroadcastPickerConfig, globalOpts); + + await runWrite( + { + loading: 'Cancelling broadcast...', + sdkCall: (resend) => resend.broadcasts.cancel(id), + errorCode: 'cancel_error', + successMsg: 'Broadcast cancelled', + }, + globalOpts, + ); + }); diff --git a/src/commands/broadcasts/index.ts b/src/commands/broadcasts/index.ts index 41389b75..b8ea07c1 100644 --- a/src/commands/broadcasts/index.ts +++ b/src/commands/broadcasts/index.ts @@ -1,5 +1,6 @@ import { Command } from '@commander-js/extra-typings'; import { buildHelpText } from '../../lib/help-text'; +import { cancelBroadcastCommand } from './cancel'; import { createBroadcastCommand } from './create'; import { deleteBroadcastCommand } from './delete'; import { getBroadcastCommand } from './get'; @@ -17,7 +18,10 @@ export const broadcastsCommand = new Command('broadcasts') Broadcasts follow a draft → send flow: 1. create — creates a draft (or sends immediately with --send) 2. send — sends an API-created draft (dashboard broadcasts cannot be sent via API) - Scheduled broadcasts can be deleted to cancel delivery; sent broadcasts are immutable. + cancel stops a queued broadcast mid-send, or reverts a scheduled broadcast to + draft, without removing it. + delete removes a broadcast entirely (and cancels delivery first, if scheduled). + Sent broadcasts are immutable — neither cancel nor delete can be used on them. Template variables: HTML bodies support triple-brace interpolation for contact properties. @@ -32,6 +36,7 @@ Scheduling: 'resend broadcasts send d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --scheduled-at "in 1 hour"', 'resend broadcasts get d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', 'resend broadcasts update d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --subject "Updated Subject"', + 'resend broadcasts cancel d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', 'resend broadcasts delete d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --yes', 'resend broadcasts open', 'resend broadcasts open d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', @@ -44,4 +49,5 @@ Scheduling: .addCommand(getBroadcastCommand) .addCommand(listBroadcastsCommand, { isDefault: true }) .addCommand(updateBroadcastCommand) + .addCommand(cancelBroadcastCommand) .addCommand(deleteBroadcastCommand); diff --git a/src/commands/broadcasts/utils.ts b/src/commands/broadcasts/utils.ts index 476ae149..7eaac6cd 100644 --- a/src/commands/broadcasts/utils.ts +++ b/src/commands/broadcasts/utils.ts @@ -7,6 +7,8 @@ export function broadcastStatusIndicator(status: string): string { return '○ Draft'; case 'queued': return '⏳ Queued'; + case 'scheduled': + return '📅 Scheduled'; case 'sent': return '✓ Sent'; default: @@ -41,6 +43,22 @@ export const sendBroadcastPickerConfig: PickerConfig<{ filter: (b) => b.status === 'draft', }; +export const cancelBroadcastPickerConfig: PickerConfig<{ + id: string; + name: string | null; + status: string; +}> = { + resource: 'broadcast', + resourcePlural: 'broadcasts', + fetchItems: (resend, { limit, after }) => + resend.broadcasts.list({ limit, ...(after && { after }) }), + display: (b) => ({ + label: b.name ?? '(untitled)', + hint: `${broadcastStatusIndicator(b.status)} ${b.id}`, + }), + filter: (b) => b.status === 'queued' || b.status === 'scheduled', +}; + export function renderBroadcastsTable( broadcasts: Array<{ id: string; diff --git a/tests/commands/broadcasts/cancel.test.ts b/tests/commands/broadcasts/cancel.test.ts new file mode 100644 index 00000000..c6d183ec --- /dev/null +++ b/tests/commands/broadcasts/cancel.test.ts @@ -0,0 +1,136 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from 'vitest'; +import { + captureTestEnv, + expectExit1, + mockExitThrow, + mockSdkError, + setNonInteractive, + setupOutputSpies, +} from '../../helpers'; + +const mockCancel = vi.fn(async () => ({ + data: { object: 'broadcast', id: 'd1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6' }, + error: null, +})); + +vi.mock('resend', () => ({ + Resend: class MockResend { + constructor(public key: string) {} + broadcasts = { cancel: mockCancel }; + }, +})); + +describe('broadcasts cancel command', () => { + const restoreEnv = captureTestEnv(); + let spies: ReturnType | undefined; + let errorSpy: MockInstance | undefined; + let stderrSpy: MockInstance | undefined; + let exitSpy: MockInstance | undefined; + + beforeEach(() => { + process.env.RESEND_API_KEY = 're_test_key'; + mockCancel.mockClear(); + }); + + afterEach(() => { + restoreEnv(); + errorSpy?.mockRestore(); + stderrSpy?.mockRestore(); + exitSpy?.mockRestore(); + spies = undefined; + errorSpy = undefined; + stderrSpy = undefined; + exitSpy = undefined; + }); + + it('cancels broadcast by id', async () => { + spies = setupOutputSpies(); + + const { cancelBroadcastCommand } = await import( + '../../../src/commands/broadcasts/cancel' + ); + await cancelBroadcastCommand.parseAsync( + ['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'], + { from: 'user' }, + ); + + expect(mockCancel).toHaveBeenCalledTimes(1); + expect(mockCancel.mock.calls[0][0]).toBe( + 'd1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', + ); + }); + + it('outputs JSON id when non-interactive', async () => { + spies = setupOutputSpies(); + + const { cancelBroadcastCommand } = await import( + '../../../src/commands/broadcasts/cancel' + ); + await cancelBroadcastCommand.parseAsync( + ['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'], + { from: 'user' }, + ); + + const output = spies.logSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + expect(parsed.id).toBe('d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'); + expect(parsed.object).toBe('broadcast'); + }); + + it('errors with auth_error when no API key', async () => { + setNonInteractive(); + delete process.env.RESEND_API_KEY; + process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend'; + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = mockExitThrow(); + + const { cancelBroadcastCommand } = await import( + '../../../src/commands/broadcasts/cancel' + ); + await expectExit1(() => + cancelBroadcastCommand.parseAsync( + ['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('auth_error'); + }); + + it('errors with cancel_error when SDK returns an error', async () => { + setNonInteractive(); + mockCancel.mockResolvedValueOnce( + mockSdkError( + 'Only queued or scheduled broadcasts can be canceled', + 'validation_error', + ), + ); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = mockExitThrow(); + + const { cancelBroadcastCommand } = await import( + '../../../src/commands/broadcasts/cancel' + ); + await expectExit1(() => + cancelBroadcastCommand.parseAsync( + ['00000000-0000-0000-0000-00000000bad0'], + { from: 'user' }, + ), + ); + + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('cancel_error'); + }); +}); From ba0d64d09a516807b841c690ff3b01556fcb3327 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Tue, 11 Aug 2026 13:12:47 -0300 Subject: [PATCH 2/3] fix(broadcasts): address cubic review on cancel command - Require sending_access instead of the full_access default, so a send-only key can cancel a broadcast it's allowed to send. - Extract statusFilteredBroadcastPickerConfig, deduplicating sendBroadcastPickerConfig and cancelBroadcastPickerConfig down to their differing filter predicate. Co-Authored-By: Claude Sonnet 5 --- src/commands/broadcasts/cancel.ts | 1 + src/commands/broadcasts/utils.ts | 50 +++++++++++++++---------------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/commands/broadcasts/cancel.ts b/src/commands/broadcasts/cancel.ts index fdffbde9..d2b0d495 100644 --- a/src/commands/broadcasts/cancel.ts +++ b/src/commands/broadcasts/cancel.ts @@ -32,6 +32,7 @@ broadcast stops it mid-send — emails already sent are not affected.`, sdkCall: (resend) => resend.broadcasts.cancel(id), errorCode: 'cancel_error', successMsg: 'Broadcast cancelled', + permission: 'sending_access', }, globalOpts, ); diff --git a/src/commands/broadcasts/utils.ts b/src/commands/broadcasts/utils.ts index 7eaac6cd..b7c2fb8e 100644 --- a/src/commands/broadcasts/utils.ts +++ b/src/commands/broadcasts/utils.ts @@ -27,37 +27,35 @@ export const broadcastPickerConfig: PickerConfig<{ display: (b) => ({ label: b.name ?? '(untitled)', hint: b.id }), }; -export const sendBroadcastPickerConfig: PickerConfig<{ +type StatusFilteredBroadcast = { id: string; name: string | null; status: string; -}> = { - resource: 'broadcast', - resourcePlural: 'broadcasts', - fetchItems: (resend, { limit, after }) => - resend.broadcasts.list({ limit, ...(after && { after }) }), - display: (b) => ({ - label: b.name ?? '(untitled)', - hint: `${broadcastStatusIndicator(b.status)} ${b.id}`, - }), - filter: (b) => b.status === 'draft', }; -export const cancelBroadcastPickerConfig: PickerConfig<{ - id: string; - name: string | null; - status: string; -}> = { - resource: 'broadcast', - resourcePlural: 'broadcasts', - fetchItems: (resend, { limit, after }) => - resend.broadcasts.list({ limit, ...(after && { after }) }), - display: (b) => ({ - label: b.name ?? '(untitled)', - hint: `${broadcastStatusIndicator(b.status)} ${b.id}`, - }), - filter: (b) => b.status === 'queued' || b.status === 'scheduled', -}; +function statusFilteredBroadcastPickerConfig( + filter: (b: StatusFilteredBroadcast) => boolean, +): PickerConfig { + return { + resource: 'broadcast', + resourcePlural: 'broadcasts', + fetchItems: (resend, { limit, after }) => + resend.broadcasts.list({ limit, ...(after && { after }) }), + display: (b) => ({ + label: b.name ?? '(untitled)', + hint: `${broadcastStatusIndicator(b.status)} ${b.id}`, + }), + filter, + }; +} + +export const sendBroadcastPickerConfig = statusFilteredBroadcastPickerConfig( + (b) => b.status === 'draft', +); + +export const cancelBroadcastPickerConfig = statusFilteredBroadcastPickerConfig( + (b) => b.status === 'queued' || b.status === 'scheduled', +); export function renderBroadcastsTable( broadcasts: Array<{ From 2697b6dd8c80fc5dc271d60f26c2167e7e01ce4b Mon Sep 17 00:00:00 2001 From: dielduarte Date: Tue, 11 Aug 2026 13:15:47 -0300 Subject: [PATCH 3/3] revert: drop incorrect sending_access permission on cancel cubic's suggestion to mirror send's sending_access permission was wrong: apps/public-api's SEND_ROUTES allowlist (is-authenticated.ts) only exempts /broadcasts/:id/send for sending_access keys, not /cancel. A sending_access key hitting cancel gets a 401 restricted_api_key from the real API. cancel requires full_access, which is the default this restores. Co-Authored-By: Claude Sonnet 5 --- src/commands/broadcasts/cancel.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/broadcasts/cancel.ts b/src/commands/broadcasts/cancel.ts index d2b0d495..fdffbde9 100644 --- a/src/commands/broadcasts/cancel.ts +++ b/src/commands/broadcasts/cancel.ts @@ -32,7 +32,6 @@ broadcast stops it mid-send — emails already sent are not affected.`, sdkCall: (resend) => resend.broadcasts.cancel(id), errorCode: 'cancel_error', successMsg: 'Broadcast cancelled', - permission: 'sending_access', }, globalOpts, );