Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions skills/resend-cli/references/broadcasts.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ Send a draft broadcast.

---

## broadcasts cancel

Cancel a queued or scheduled broadcast without removing it.

**Argument:** `<id>` — 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:** `<id>` — Broadcast ID
Expand Down
38 changes: 38 additions & 0 deletions src/commands/broadcasts/cancel.ts
Original file line number Diff line number Diff line change
@@ -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":"<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',
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
},
globalOpts,
);
});
8 changes: 7 additions & 1 deletion src/commands/broadcasts/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand All @@ -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',
Expand All @@ -44,4 +49,5 @@ Scheduling:
.addCommand(getBroadcastCommand)
.addCommand(listBroadcastsCommand, { isDefault: true })
.addCommand(updateBroadcastCommand)
.addCommand(cancelBroadcastCommand)
.addCommand(deleteBroadcastCommand);
38 changes: 27 additions & 11 deletions src/commands/broadcasts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -25,22 +27,36 @@ 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',
};

function statusFilteredBroadcastPickerConfig(
filter: (b: StatusFilteredBroadcast) => boolean,
): PickerConfig<StatusFilteredBroadcast> {
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<{
id: string;
Expand Down
136 changes: 136 additions & 0 deletions tests/commands/broadcasts/cancel.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setupOutputSpies> | 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');
});
});