From 8b2994e6fb7abf615cb9b6a96399302579bf9093 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Fri, 21 Aug 2026 23:34:01 -0300 Subject: [PATCH 1/3] feat: add broadcasts clicked-links command --- package.json | 2 +- pnpm-lock.yaml | 10 +- skills/resend-cli/references/broadcasts.md | 14 ++ src/commands/broadcasts/clicked-links.ts | 67 ++++++ src/commands/broadcasts/index.ts | 5 +- src/commands/broadcasts/utils.ts | 19 ++ .../commands/broadcasts/clicked-links.test.ts | 212 ++++++++++++++++++ 7 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 src/commands/broadcasts/clicked-links.ts create mode 100644 tests/commands/broadcasts/clicked-links.test.ts diff --git a/package.json b/package.json index cd71a8bb..f70ec064 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.21.0" + "resend": "6.22.0" }, "pkg": { "scripts": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 607381fc..c36f3a45 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.21.0 - version: 6.21.0 + specifier: 6.22.0 + version: 6.22.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.21.0: - resolution: {integrity: sha512-XT1eP2iA8rZ51NSO2CJlL1ZE2baBoPpOGMgz2p0o2uluz/YU3XfItpGymXDlL0Sp2Mc+y9+Xxsfdccfi4f2FzQ==} + resend@6.22.0: + resolution: {integrity: sha512-dP1jyl0n1Dx7GYDb0bcsbv9Yx0oBRLhUtSyNFRIOsSzV9MvCVbua/3WP6YUP2JptAU5BciEWGInV+hFjUuYLUw==} engines: {node: '>=20'} peerDependencies: '@react-email/render': '*' @@ -2431,7 +2431,7 @@ snapshots: require-directory@2.1.1: {} - resend@6.21.0: + resend@6.22.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 8320e4a8..43e39161 100644 --- a/skills/resend-cli/references/broadcasts.md +++ b/skills/resend-cli/references/broadcasts.md @@ -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 ` | number | 10 | Max results (1-100) | +| `--after ` | string | — | Forward pagination | +| `--before ` | string | — | Backward pagination | diff --git a/src/commands/broadcasts/clicked-links.ts b/src/commands/broadcasts/clicked-links.ts new file mode 100644 index 00000000..c18c91e7 --- /dev/null +++ b/src/commands/broadcasts/clicked-links.ts @@ -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 ', 'Maximum number of results to return (1-100)', '10') + .option( + '--after ', + 'Cursor for forward pagination — list items after this ID', + ) + .option( + '--before ', + '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'], + 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, + ); + }); diff --git a/src/commands/broadcasts/index.ts b/src/commands/broadcasts/index.ts index b8ea07c1..f9f9d8be 100644 --- a/src/commands/broadcasts/index.ts +++ b/src/commands/broadcasts/index.ts @@ -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'; @@ -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', ], }), ) @@ -50,4 +52,5 @@ Scheduling: .addCommand(listBroadcastsCommand, { isDefault: true }) .addCommand(updateBroadcastCommand) .addCommand(cancelBroadcastCommand) - .addCommand(deleteBroadcastCommand); + .addCommand(deleteBroadcastCommand) + .addCommand(clickedLinksBroadcastCommand); diff --git a/src/commands/broadcasts/utils.ts b/src/commands/broadcasts/utils.ts index b7c2fb8e..7428d86e 100644 --- a/src/commands/broadcasts/utils.ts +++ b/src/commands/broadcasts/utils.ts @@ -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)', + ); +} diff --git a/tests/commands/broadcasts/clicked-links.test.ts b/tests/commands/broadcasts/clicked-links.test.ts new file mode 100644 index 00000000..0ad031b1 --- /dev/null +++ b/tests/commands/broadcasts/clicked-links.test.ts @@ -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 | 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; + 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; + 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; + 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'); + }); +}); From 2fe2ac934d09f4b41750a3f552bf927c96dbedeb Mon Sep 17 00:00:00 2001 From: dielduarte Date: Sun, 23 Aug 2026 00:17:32 -0300 Subject: [PATCH 2/3] fix: document invalid_pagination error code in help text --- src/commands/broadcasts/clicked-links.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/commands/broadcasts/clicked-links.ts b/src/commands/broadcasts/clicked-links.ts index c18c91e7..d1c37d59 100644 --- a/src/commands/broadcasts/clicked-links.ts +++ b/src/commands/broadcasts/clicked-links.ts @@ -29,7 +29,12 @@ export const clickedLinksBroadcastCommand = new Command('clicked-links') 'after', buildHelpText({ output: ` {"object":"list","has_more":false,"data":[{"id":"...","url":"...","clicks":42,"unique_clicks":30}]}`, - errorCodes: ['auth_error', 'invalid_limit', 'list_error'], + 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', From b6f38f0c845a0187183d5177e4b37e8708d0ab79 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 24 Aug 2026 10:51:51 -0300 Subject: [PATCH 3/3] docs: add clicked-links to the broadcasts command table --- skills/resend-cli/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/resend-cli/SKILL.md b/skills/resend-cli/SKILL.md index ad075a4f..dccc030c 100644 --- a/skills/resend-cli/SKILL.md +++ b/skills/resend-cli/SKILL.md @@ -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 |