diff --git a/skills/resend-cli/SKILL.md b/skills/resend-cli/SKILL.md index dccc030c..74b55e70 100644 --- a/skills/resend-cli/SKILL.md +++ b/skills/resend-cli/SKILL.md @@ -141,7 +141,7 @@ Auth resolves: `--api-key` flag > `RESEND_API_KEY` env > config file (`resend lo | Command Group | What it does | |--------------|-------------| -| `emails` | send, get, list, batch, cancel, update | +| `emails` | send, get, list, batch, cancel, update, metrics | | `emails receiving` | list, get, attachments, forward, listen | | `domains` | create, verify, get, claim, update, delete, list | | `logs` | list, get, open | diff --git a/skills/resend-cli/references/emails.md b/skills/resend-cli/references/emails.md index 44a8fb98..0b30380d 100644 --- a/skills/resend-cli/references/emails.md +++ b/skills/resend-cli/references/emails.md @@ -138,6 +138,28 @@ Update a scheduled email. --- +## emails metrics + +Retrieve account-level email metrics for a date range, with optional breakdowns. + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--start-date ` | string | 6 days before `--end-date` | ISO 8601 date or datetime | +| `--end-date ` | string | now | ISO 8601 date or datetime | +| `--timezone ` | string | UTC | IANA timezone used to bucket periods | +| `--granularity ` | string | daily | `hourly`, `daily`, `weekly`, or `monthly` | +| `--metrics ` | string | all | Comma-separated metrics to include | +| `--dimensions ` | string | — | Comma-separated breakdowns: `period`, `domain`, `email`, `broadcast` | +| `--domain-id ` | string | — | Comma-separated sending domain IDs (max 100) | +| `--email-id ` | string | — | Comma-separated email IDs (max 100) | +| `--broadcast-id ` | string | — | Comma-separated broadcast IDs (max 100) | + +The `email` and `broadcast` dimensions/filters cannot be combined. Without `--dimensions`, the response has totals only and no `data` array. + +**Output:** `{"object":"metrics","start_date":"...","end_date":"...","metrics":["sent",...],"dimensions":["period"],"granularity":"daily","totals":{"sent":100,...},"data":[{"period":"2026-07-01","sent":10,...}]}` + +--- + ## emails receiving list List received (inbound) emails. Requires domain receiving enabled. diff --git a/src/commands/emails/index.ts b/src/commands/emails/index.ts index 302ebb5e..2fbaf82f 100644 --- a/src/commands/emails/index.ts +++ b/src/commands/emails/index.ts @@ -6,6 +6,7 @@ import { batchCommand } from './batch'; import { cancelCommand } from './cancel'; import { getEmailCommand } from './get'; import { listEmailsCommand } from './list'; +import { metricsCommand } from './metrics'; import { receivingCommand } from './receiving/index'; import { sendCommand } from './send'; import { shareCommand } from './share'; @@ -22,6 +23,7 @@ export const emailsCommand = new Command('emails') 'resend emails batch --file ./emails.json', 'resend emails cancel ', 'resend emails share ', + 'resend emails metrics', 'resend emails attachments ', 'resend emails attachment ', 'resend emails receiving list', @@ -36,6 +38,7 @@ export const emailsCommand = new Command('emails') .addCommand(cancelCommand) .addCommand(updateCommand) .addCommand(shareCommand) + .addCommand(metricsCommand) .addCommand(listAttachmentsCommand) .addCommand(getAttachmentCommand) .addCommand(receivingCommand); diff --git a/src/commands/emails/metrics.ts b/src/commands/emails/metrics.ts new file mode 100644 index 00000000..c7faa003 --- /dev/null +++ b/src/commands/emails/metrics.ts @@ -0,0 +1,146 @@ +import { Command, Option } from '@commander-js/extra-typings'; +import type { EmailMetricsDataRow, EmailMetricsTotals } from 'resend'; +import { runGet } from '../../lib/actions'; +import type { GlobalOpts } from '../../lib/client'; +import { buildHelpText } from '../../lib/help-text'; +import { outputError } from '../../lib/output'; +import { renderTable } from '../../lib/table'; + +function parseList(value: string | undefined): string[] | undefined { + if (value === undefined) { + return undefined; + } + return value + .split(',') + .map((v) => v.trim()) + .filter((v) => v.length > 0); +} + +function renderTotalsTable(totals: EmailMetricsTotals): string { + const rows = Object.entries(totals).map(([metric, value]) => [ + metric, + String(value), + ]); + return renderTable(['Metric', 'Value'], rows, '(no metrics)'); +} + +function renderBreakdownTable( + data: EmailMetricsDataRow[], + metrics: string[], +): string { + const dimensionKeys = Object.keys(data[0] ?? {}).filter( + (key) => !metrics.includes(key), + ); + const headers = [...dimensionKeys, ...metrics]; + const rows = data.map((row) => + headers.map((key) => String(row[key as keyof EmailMetricsDataRow] ?? '')), + ); + return renderTable(headers, rows, '(no breakdown rows)'); +} + +export const metricsCommand = new Command('metrics') + .description('Retrieve account-level email metrics') + .option( + '--start-date ', + 'ISO 8601 date/datetime, defaults to 6 days before --end-date', + ) + .option('--end-date ', 'ISO 8601 date/datetime, defaults to now') + .option( + '--timezone ', + 'IANA timezone, e.g. America/New_York, defaults to UTC', + ) + .addOption( + new Option( + '--granularity ', + 'Bucket size used when "period" is a dimension (default: daily)', + ).choices(['hourly', 'daily', 'weekly', 'monthly'] as const), + ) + .option( + '--metrics ', + 'Comma-separated metrics to include, defaults to all', + ) + .option( + '--dimensions ', + 'Comma-separated dimensions to break down by: period, domain, email, broadcast', + ) + .option('--domain-id ', 'Comma-separated sending domain IDs (max 100)') + .option( + '--email-id ', + 'Comma-separated email IDs (max 100). Cannot be combined with the "broadcast" dimension or --broadcast-id', + ) + .option( + '--broadcast-id ', + 'Comma-separated broadcast IDs (max 100). Cannot be combined with the "email" dimension or --email-id', + ) + .addHelpText( + 'after', + buildHelpText({ + output: + ' {"object":"metrics","start_date":"...","end_date":"...","metrics":["sent","delivered"],"dimensions":[],"granularity":"daily","totals":{"sent":100,"delivered":95}}', + errorCodes: ['auth_error', 'invalid_options', 'fetch_error'], + examples: [ + 'resend emails metrics', + 'resend emails metrics --start-date 2026-07-01 --end-date 2026-07-08', + 'resend emails metrics --dimensions period,broadcast --broadcast-id ', + 'resend emails metrics --json', + ], + }), + ) + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals() as GlobalOpts; + + const dimensions = parseList(opts.dimensions); + const emailId = parseList(opts.emailId); + const broadcastId = parseList(opts.broadcastId); + + const hasEmail = + (dimensions?.includes('email') ?? false) || opts.emailId !== undefined; + const hasBroadcast = + (dimensions?.includes('broadcast') ?? false) || + opts.broadcastId !== undefined; + + if (hasEmail && hasBroadcast) { + outputError( + { + message: + 'The "broadcast" dimension/--broadcast-id cannot be combined with the "email" dimension/--email-id.', + code: 'invalid_options', + }, + { json: globalOpts.json }, + ); + } + + const metrics = parseList(opts.metrics); + + await runGet( + { + loading: 'Fetching email metrics...', + sdkCall: (resend) => + resend.emails.metrics({ + startDate: opts.startDate, + endDate: opts.endDate, + timezone: opts.timezone, + granularity: opts.granularity, + metrics, + dimensions, + domainId: parseList(opts.domainId), + emailId, + broadcastId, + } as Parameters[0]), + onInteractive: (data) => { + console.log( + `Metrics for ${data.start_date} to ${data.end_date} (${data.granularity} granularity)`, + ); + console.log(); + console.log('Totals:'); + console.log(renderTotalsTable(data.totals)); + if (data.data && data.data.length > 0) { + console.log(); + console.log(`Breakdown by ${data.dimensions.join(', ')}:`); + console.log(renderBreakdownTable(data.data, data.metrics)); + } + }, + }, + globalOpts, + ); + }); diff --git a/tests/commands/emails/metrics.test.ts b/tests/commands/emails/metrics.test.ts new file mode 100644 index 00000000..8a32d96c --- /dev/null +++ b/tests/commands/emails/metrics.test.ts @@ -0,0 +1,178 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from 'vitest'; +import { + captureTestEnv, + expectExit1, + mockExitThrow, + setNonInteractive, + setupOutputSpies, +} from '../../helpers'; + +const mockMetrics = vi.fn(async () => ({ + data: { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent', 'delivered'], + dimensions: [], + granularity: 'daily', + totals: { sent: 100, delivered: 95 }, + }, + error: null, +})); + +vi.mock('resend', () => ({ + Resend: class MockResend { + constructor(public key: string) {} + emails = { metrics: mockMetrics }; + }, +})); + +describe('emails metrics 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'; + mockMetrics.mockClear(); + }); + + afterEach(() => { + restoreEnv(); + errorSpy?.mockRestore(); + stderrSpy?.mockRestore(); + exitSpy?.mockRestore(); + spies = undefined; + errorSpy = undefined; + stderrSpy = undefined; + exitSpy = undefined; + }); + + it('calls SDK metrics with no options by default', async () => { + spies = setupOutputSpies(); + + const { metricsCommand } = await import( + '../../../src/commands/emails/metrics' + ); + await metricsCommand.parseAsync([], { from: 'user' }); + + expect(mockMetrics).toHaveBeenCalledWith({ + startDate: undefined, + endDate: undefined, + timezone: undefined, + granularity: undefined, + metrics: undefined, + dimensions: undefined, + domainId: undefined, + emailId: undefined, + broadcastId: undefined, + }); + }); + + it('parses comma-separated dimensions and a filter into arrays', async () => { + spies = setupOutputSpies(); + + const { metricsCommand } = await import( + '../../../src/commands/emails/metrics' + ); + await metricsCommand.parseAsync( + ['--dimensions', 'period,broadcast', '--broadcast-id', 'b1,b2'], + { from: 'user' }, + ); + + expect(mockMetrics).toHaveBeenCalledWith( + expect.objectContaining({ + dimensions: ['period', 'broadcast'], + broadcastId: ['b1', 'b2'], + }), + ); + }); + + it('outputs JSON in non-interactive mode', async () => { + spies = setupOutputSpies(); + + const { metricsCommand } = await import( + '../../../src/commands/emails/metrics' + ); + await metricsCommand.parseAsync([], { from: 'user' }); + + const output = spies.logSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + expect(parsed.object).toBe('metrics'); + expect(parsed.totals.sent).toBe(100); + }); + + it('rejects combining the email and broadcast dimensions, without calling the SDK', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = mockExitThrow(); + + const { metricsCommand } = await import( + '../../../src/commands/emails/metrics' + ); + await expectExit1(() => + metricsCommand.parseAsync(['--dimensions', 'email,broadcast'], { + from: 'user', + }), + ); + + expect(mockMetrics).not.toHaveBeenCalled(); + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_options'); + }); + + it('rejects emailId and broadcastId combined, without calling the SDK', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = mockExitThrow(); + + const { metricsCommand } = await import( + '../../../src/commands/emails/metrics' + ); + await expectExit1(() => + metricsCommand.parseAsync(['--email-id', 'e1', '--broadcast-id', 'b1'], { + from: 'user', + }), + ); + + expect(mockMetrics).not.toHaveBeenCalled(); + }); + + it('rejects an empty --email-id combined with --broadcast-id, without calling the SDK', async () => { + setNonInteractive(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + exitSpy = mockExitThrow(); + + const { metricsCommand } = await import( + '../../../src/commands/emails/metrics' + ); + await expectExit1(() => + metricsCommand.parseAsync(['--email-id', '', '--broadcast-id', 'b1'], { + from: 'user', + }), + ); + + expect(mockMetrics).not.toHaveBeenCalled(); + const output = errorSpy.mock.calls.map((c) => c[0]).join(' '); + expect(output).toContain('invalid_options'); + }); +});