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.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.

2 changes: 1 addition & 1 deletion skills/resend-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ Auth resolves: `--api-key` flag > `RESEND_API_KEY` env > config file (`resend lo
| `api-keys` | create, list, update, delete |
| `automations` | create, get, list, update, delete, duplicate, stop, open, runs |
| `events` | create, get, list, update, delete, send, open |
| `broadcasts` | create, send, update, delete, list |
| `broadcasts` | create, send, update, delete, list, clicked-links |
| `contacts` | create, update, delete, segments, topics, imports |
| `contact-properties` | create, update, delete, list |
| `segments` | create, get, list, delete, contacts |
Expand Down
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 |
72 changes: 72 additions & 0 deletions src/commands/broadcasts/clicked-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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',
'invalid_pagination',
'list_error',
],
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');
});
});
Loading