Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.21.0"
"resend": "6.22.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.

14 changes: 14 additions & 0 deletions skills/resend-cli/references/broadcasts.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,17 @@ Cancelling a queued broadcast stops it mid-send — emails already sent are not
Open a broadcast (or the broadcasts list) in the Resend dashboard.

**Argument:** `[id]` — Broadcast ID (omit to open the list)

---

## broadcasts clicked-links

List the links clicked in a broadcast, ranked by total clicks.

**Argument:** `[id]` — Broadcast ID

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--limit <n>` | number | 10 | Max results (1-100) |
| `--after <cursor>` | string | — | Forward pagination |
| `--before <cursor>` | string | — | Backward pagination |
67 changes: 67 additions & 0 deletions src/commands/broadcasts/clicked-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Command } from '@commander-js/extra-typings';
import { runList } from '../../lib/actions';
import type { GlobalOpts } from '../../lib/client';
import { buildHelpText } from '../../lib/help-text';
import {
buildPaginationOpts,
parseLimitOpt,
printPaginationHint,
} from '../../lib/pagination';
import { pickId } from '../../lib/prompts';
import {
broadcastPickerConfig,
renderBroadcastClickedLinksTable,
} from './utils';

export const clickedLinksBroadcastCommand = new Command('clicked-links')
.description('List the links clicked in a broadcast, ranked by total clicks')
.argument('[id]', 'Broadcast ID')
.option('--limit <n>', 'Maximum number of results to return (1-100)', '10')
.option(
'--after <cursor>',
'Cursor for forward pagination — list items after this ID',
)
.option(
'--before <cursor>',
'Cursor for backward pagination — list items before this ID',
)
.addHelpText(
'after',
buildHelpText({
output: ` {"object":"list","has_more":false,"data":[{"id":"...","url":"...","clicks":42,"unique_clicks":30}]}`,
errorCodes: ['auth_error', 'invalid_limit', 'list_error'],
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
examples: [
'resend broadcasts clicked-links d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
'resend broadcasts clicked-links d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --limit 5',
'resend broadcasts clicked-links d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --after b2Zmc2V0OjA',
'resend broadcasts clicked-links d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --json',
],
}),
)
.action(async (idArg, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
const id = await pickId(idArg, broadcastPickerConfig, globalOpts);
const limit = parseLimitOpt(opts.limit, globalOpts);
const paginationOpts = buildPaginationOpts(
limit,
opts.after,
opts.before,
globalOpts,
);
await runList(
{
loading: 'Fetching clicked links...',
sdkCall: (resend) => resend.broadcasts.clickedLinks(id, paginationOpts),
onInteractive: (list) => {
console.log(renderBroadcastClickedLinksTable(list.data));
printPaginationHint(list, `broadcasts clicked-links ${id}`, {
limit,
before: opts.before,
apiKey: globalOpts.apiKey,
profile: globalOpts.profile,
});
},
},
globalOpts,
);
});
5 changes: 4 additions & 1 deletion src/commands/broadcasts/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from '@commander-js/extra-typings';
import { buildHelpText } from '../../lib/help-text';
import { cancelBroadcastCommand } from './cancel';
import { clickedLinksBroadcastCommand } from './clicked-links';
import { createBroadcastCommand } from './create';
import { deleteBroadcastCommand } from './delete';
import { getBroadcastCommand } from './get';
Expand Down Expand Up @@ -40,6 +41,7 @@ Scheduling:
'resend broadcasts delete d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6 --yes',
'resend broadcasts open',
'resend broadcasts open d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
'resend broadcasts clicked-links d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
],
}),
)
Expand All @@ -50,4 +52,5 @@ Scheduling:
.addCommand(listBroadcastsCommand, { isDefault: true })
.addCommand(updateBroadcastCommand)
.addCommand(cancelBroadcastCommand)
.addCommand(deleteBroadcastCommand);
.addCommand(deleteBroadcastCommand)
.addCommand(clickedLinksBroadcastCommand);
19 changes: 19 additions & 0 deletions src/commands/broadcasts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,22 @@ export function renderBroadcastsTable(
'(no broadcasts)',
);
}

export function renderBroadcastClickedLinksTable(
links: Array<{
url: string;
clicks: number;
unique_clicks: number;
}>,
): string {
const rows = links.map((l) => [
l.url,
String(l.clicks),
String(l.unique_clicks),
]);
return renderTable(
['URL', 'Clicks', 'Unique Clicks'],
rows,
'(no clicked links)',
);
}
212 changes: 212 additions & 0 deletions tests/commands/broadcasts/clicked-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
type MockInstance,
vi,
} from 'vitest';
import {
captureTestEnv,
expectExit1,
mockExitThrow,
mockSdkError,
setNonInteractive,
setupOutputSpies,
} from '../../helpers';

const mockClickedLinks = vi.fn(async () => ({
data: {
object: 'list' as const,
has_more: false,
data: [
{
id: 'b2Zmc2V0OjA',
url: 'https://resend.com/pricing',
clicks: 42,
unique_clicks: 30,
},
],
},
error: null,
}));

vi.mock('resend', () => ({
Resend: class MockResend {
constructor(public key: string) {}
broadcasts = { clickedLinks: mockClickedLinks };
},
}));

describe('broadcasts clicked-links 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';
mockClickedLinks.mockClear();
});

afterEach(() => {
restoreEnv();
errorSpy?.mockRestore();
stderrSpy?.mockRestore();
exitSpy?.mockRestore();
spies = undefined;
errorSpy = undefined;
stderrSpy = undefined;
exitSpy = undefined;
});

it('lists clicked links for a broadcast id', async () => {
spies = setupOutputSpies();

const { clickedLinksBroadcastCommand } = await import(
'../../../src/commands/broadcasts/clicked-links'
);
await clickedLinksBroadcastCommand.parseAsync(
['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'],
{ from: 'user' },
);

expect(mockClickedLinks).toHaveBeenCalledTimes(1);
expect(mockClickedLinks.mock.calls[0][0]).toBe(
'd1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6',
);
});

it('outputs JSON list when non-interactive', async () => {
spies = setupOutputSpies();

const { clickedLinksBroadcastCommand } = await import(
'../../../src/commands/broadcasts/clicked-links'
);
await clickedLinksBroadcastCommand.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.object).toBe('list');
expect(parsed.data).toHaveLength(1);
expect(parsed.data[0].url).toBe('https://resend.com/pricing');
});

it('passes --limit to SDK', async () => {
spies = setupOutputSpies();

const { clickedLinksBroadcastCommand } = await import(
'../../../src/commands/broadcasts/clicked-links'
);
await clickedLinksBroadcastCommand.parseAsync(
['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', '--limit', '5'],
{ from: 'user' },
);

const opts = mockClickedLinks.mock.calls[0][1] as Record<string, unknown>;
expect(opts.limit).toBe(5);
});

it('passes --after cursor to SDK', async () => {
spies = setupOutputSpies();

const { clickedLinksBroadcastCommand } = await import(
'../../../src/commands/broadcasts/clicked-links'
);
await clickedLinksBroadcastCommand.parseAsync(
['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', '--after', 'b2Zmc2V0OjA'],
{ from: 'user' },
);

const opts = mockClickedLinks.mock.calls[0][1] as Record<string, unknown>;
expect(opts.after).toBe('b2Zmc2V0OjA');
});

it('passes --before cursor to SDK', async () => {
spies = setupOutputSpies();

const { clickedLinksBroadcastCommand } = await import(
'../../../src/commands/broadcasts/clicked-links'
);
await clickedLinksBroadcastCommand.parseAsync(
['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', '--before', 'b2Zmc2V0OjA'],
{ from: 'user' },
);

const opts = mockClickedLinks.mock.calls[0][1] as Record<string, unknown>;
expect(opts.before).toBe('b2Zmc2V0OjA');
});

it('errors with invalid_limit when --limit is out of range', async () => {
setNonInteractive();
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
exitSpy = mockExitThrow();

const { clickedLinksBroadcastCommand } = await import(
'../../../src/commands/broadcasts/clicked-links'
);
await expectExit1(() =>
clickedLinksBroadcastCommand.parseAsync(
['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6', '--limit', '999'],
{ from: 'user' },
),
);

const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
expect(output).toContain('invalid_limit');
});

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 { clickedLinksBroadcastCommand } = await import(
'../../../src/commands/broadcasts/clicked-links'
);
await expectExit1(() =>
clickedLinksBroadcastCommand.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 list_error when SDK returns an error', async () => {
setNonInteractive();
mockClickedLinks.mockResolvedValueOnce(
mockSdkError('Broadcast not found', 'not_found'),
);
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
exitSpy = mockExitThrow();

const { clickedLinksBroadcastCommand } = await import(
'../../../src/commands/broadcasts/clicked-links'
);
await expectExit1(() =>
clickedLinksBroadcastCommand.parseAsync(
['d1c2b3a4-5e6f-7a8b-9c0d-e1f2a3b4c5d6'],
{ from: 'user' },
),
);

const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
expect(output).toContain('list_error');
});
});