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 skills/resend-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

@cubic-dev-ai cubic-dev-ai Bot Aug 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Because this adds command documentation without changing the skill metadata version, consumers may continue treating version 2.8.0 as current and miss the metrics capability. Bump the skill version in the frontmatter with this content change.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At skills/resend-cli/SKILL.md, line 144:

<comment>Because this adds command documentation without changing the skill metadata version, consumers may continue treating version 2.8.0 as current and miss the `metrics` capability. Bump the skill version in the frontmatter with this content change.</comment>

<file context>
@@ -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 |
</file context>
Fix with cubic

| `emails receiving` | list, get, attachments, forward, listen |
| `domains` | create, verify, get, claim, update, delete, list |
| `logs` | list, get, open |
Expand Down
22 changes: 22 additions & 0 deletions skills/resend-cli/references/emails.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <date>` | string | 6 days before `--end-date` | ISO 8601 date or datetime |
| `--end-date <date>` | string | now | ISO 8601 date or datetime |
| `--timezone <tz>` | string | UTC | IANA timezone used to bucket periods |
| `--granularity <granularity>` | string | daily | `hourly`, `daily`, `weekly`, or `monthly` |
| `--metrics <list>` | string | all | Comma-separated metrics to include |
| `--dimensions <list>` | string | — | Comma-separated breakdowns: `period`, `domain`, `email`, `broadcast` |
| `--domain-id <list>` | string | — | Comma-separated sending domain IDs (max 100) |
| `--email-id <list>` | string | — | Comma-separated email IDs (max 100) |
| `--broadcast-id <list>` | 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.
Expand Down
3 changes: 3 additions & 0 deletions src/commands/emails/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,6 +23,7 @@ export const emailsCommand = new Command('emails')
'resend emails batch --file ./emails.json',
'resend emails cancel <email-id>',
'resend emails share <email-id>',
'resend emails metrics',
'resend emails attachments <email-id>',
'resend emails attachment <email-id> <attachment-id>',
'resend emails receiving list',
Expand All @@ -36,6 +38,7 @@ export const emailsCommand = new Command('emails')
.addCommand(cancelCommand)
.addCommand(updateCommand)
.addCommand(shareCommand)
.addCommand(metricsCommand)
.addCommand(listAttachmentsCommand)
.addCommand(getAttachmentCommand)
.addCommand(receivingCommand);
146 changes: 146 additions & 0 deletions src/commands/emails/metrics.ts
Original file line number Diff line number Diff line change
@@ -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 <date>',
'ISO 8601 date/datetime, defaults to 6 days before --end-date',
)
.option('--end-date <date>', 'ISO 8601 date/datetime, defaults to now')
.option(
'--timezone <tz>',
'IANA timezone, e.g. America/New_York, defaults to UTC',
)
.addOption(
new Option(
'--granularity <granularity>',
'Bucket size used when "period" is a dimension (default: daily)',
).choices(['hourly', 'daily', 'weekly', 'monthly'] as const),
)
.option(
'--metrics <list>',
'Comma-separated metrics to include, defaults to all',
)
.option(
'--dimensions <list>',
'Comma-separated dimensions to break down by: period, domain, email, broadcast',
)
.option('--domain-id <list>', 'Comma-separated sending domain IDs (max 100)')
.option(
'--email-id <list>',
'Comma-separated email IDs (max 100). Cannot be combined with the "broadcast" dimension or --broadcast-id',
)
.option(
'--broadcast-id <list>',
'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 <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<typeof resend.emails.metrics>[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,
);
});
178 changes: 178 additions & 0 deletions tests/commands/emails/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Comment thread
dielduarte marked this conversation as resolved.
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<typeof setupOutputSpies> | 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();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
expect(output).toContain('invalid_options');
});
});