diff --git a/src/emails/emails.spec.ts b/src/emails/emails.spec.ts index 3149b1f9..ae80c5cc 100644 --- a/src/emails/emails.spec.ts +++ b/src/emails/emails.spec.ts @@ -7,6 +7,7 @@ import type { CreateEmailResponseSuccess, } from './interfaces/create-email-options.interface'; import type { GetEmailResponseSuccess } from './interfaces/get-email-options.interface'; +import type { GetEmailsMetricsResponseSuccess } from './interfaces/get-metrics.interface'; import type { ListEmailsResponseSuccess } from './interfaces/list-emails-options.interface'; import type { ShareEmailResponseSuccess } from './interfaces/share-email-options.interface'; @@ -1003,4 +1004,214 @@ describe('Emails', () => { }); }); }); + + describe('metrics', () => { + it('calls endpoint with no options and returns the response', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: [], + granularity: 'daily', + totals: { + sent: 1204, + delivered: 1180, + open_rate: 50.0, + }, + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics(); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics', + ); + }); + + it('calls endpoint passing date range, metrics, dimensions and domain_id', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: ['period', 'domain'], + granularity: 'daily', + totals: { + sent: 1204, + delivered: 1180, + open_rate: 50.0, + }, + data: [ + { + period: '2026-07-01', + domain_id: 'd91cd9bd-1176-4f47-2a4b-fce2d5399cbf', + domain_name: 'example.com', + sent: 172, + delivered: 169, + open_rate: 49.7, + }, + ], + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics({ + startDate: '2026-07-01', + endDate: '2026-07-08', + timezone: 'America/New_York', + granularity: 'daily', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: ['period', 'domain'], + domainId: ['d91cd9bd-1176-4f47-2a4b-fce2d5399cbf'], + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics?start_date=2026-07-01&end_date=2026-07-08&timezone=America%2FNew_York&granularity=daily&metrics=sent%2Cdelivered%2Copen_rate&dimensions=period%2Cdomain&domain_id=d91cd9bd-1176-4f47-2a4b-fce2d5399cbf', + ); + }); + + it('calls endpoint passing the email dimension and email_id', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: ['period', 'email'], + granularity: 'daily', + totals: { + sent: 1204, + delivered: 1180, + open_rate: 50.0, + }, + data: [ + { + period: '2026-07-01', + email_id: '4dd369bc-aa82-4ff3-97de-514ae3000ee0', + sent: 172, + delivered: 169, + open_rate: 49.7, + }, + ], + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics({ + startDate: '2026-07-01', + endDate: '2026-07-08', + timezone: 'America/New_York', + granularity: 'daily', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: ['period', 'email'], + emailId: ['4dd369bc-aa82-4ff3-97de-514ae3000ee0'], + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics?start_date=2026-07-01&end_date=2026-07-08&timezone=America%2FNew_York&granularity=daily&metrics=sent%2Cdelivered%2Copen_rate&dimensions=period%2Cemail&email_id=4dd369bc-aa82-4ff3-97de-514ae3000ee0', + ); + }); + + it('calls endpoint passing the broadcast dimension and broadcast_id', async () => { + const response: GetEmailsMetricsResponseSuccess = { + object: 'metrics', + start_date: '2026-07-01T00:00:00.000Z', + end_date: '2026-07-08T00:00:00.000Z', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: ['period', 'broadcast'], + granularity: 'daily', + totals: { + sent: 1204, + delivered: 1180, + open_rate: 50.0, + }, + data: [ + { + period: '2026-07-01', + broadcast_id: '5a5a3b1e-3b1a-4b1a-8b1a-3b1a4b1a8b1a', + broadcast_name: 'July Newsletter', + sent: 172, + delivered: 169, + open_rate: 49.7, + }, + ], + }; + + mockSuccessResponse(response); + + const result = await resend.emails.metrics({ + startDate: '2026-07-01', + endDate: '2026-07-08', + timezone: 'America/New_York', + granularity: 'daily', + metrics: ['sent', 'delivered', 'open_rate'], + dimensions: ['period', 'broadcast'], + broadcastId: ['5a5a3b1e-3b1a-4b1a-8b1a-3b1a4b1a8b1a'], + }); + + expect(result).toEqual({ + data: response, + error: null, + headers: { + 'content-type': 'application/json', + }, + }); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://api.resend.com/emails/metrics?start_date=2026-07-01&end_date=2026-07-08&timezone=America%2FNew_York&granularity=daily&metrics=sent%2Cdelivered%2Copen_rate&dimensions=period%2Cbroadcast&broadcast_id=5a5a3b1e-3b1a-4b1a-8b1a-3b1a4b1a8b1a', + ); + }); + + it('returns error when request fails', async () => { + const response: ErrorResponse = { + name: 'validation_error', + message: 'Invalid `start_date`.', + statusCode: 422, + }; + + fetchMock.mockOnce(JSON.stringify(response), { + status: 422, + headers: { + 'content-type': 'application/json', + }, + }); + + const result = await resend.emails.metrics({ startDate: 'not-a-date' }); + + expect(result).toMatchInlineSnapshot(` + { + "data": null, + "error": { + "message": "Invalid \`start_date\`.", + "name": "validation_error", + "statusCode": 422, + }, + "headers": { + "content-type": "application/json", + }, + } + `); + }); + }); }); diff --git a/src/emails/emails.ts b/src/emails/emails.ts index b6e321c8..ad6141cc 100644 --- a/src/emails/emails.ts +++ b/src/emails/emails.ts @@ -17,6 +17,11 @@ import type { GetEmailResponse, GetEmailResponseSuccess, } from './interfaces/get-email-options.interface'; +import type { + GetEmailsMetricsOptions, + GetEmailsMetricsResponse, + GetEmailsMetricsResponseSuccess, +} from './interfaces/get-metrics.interface'; import type { ListEmailsOptions, ListEmailsResponse, @@ -112,4 +117,39 @@ export class Emails { ); return data; } + + async metrics( + options: GetEmailsMetricsOptions = {}, + ): Promise { + const queryString = buildMetricsQuery(options); + const url = queryString + ? `/emails/metrics?${queryString}` + : '/emails/metrics'; + + const data = await this.resend.get(url); + return data; + } +} + +function buildMetricsQuery(options: GetEmailsMetricsOptions) { + const params: Record = { + start_date: options.startDate, + end_date: options.endDate, + timezone: options.timezone, + granularity: options.granularity, + metrics: options.metrics?.join(','), + dimensions: options.dimensions?.join(','), + domain_id: options.domainId?.join(','), + email_id: options.emailId?.join(','), + broadcast_id: options.broadcastId?.join(','), + }; + + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== '') { + searchParams.set(key, value); + } + } + + return searchParams.toString(); } diff --git a/src/emails/interfaces/get-metrics.interface.ts b/src/emails/interfaces/get-metrics.interface.ts new file mode 100644 index 00000000..bcd88783 --- /dev/null +++ b/src/emails/interfaces/get-metrics.interface.ts @@ -0,0 +1,128 @@ +import type { Response } from '../../interfaces'; + +export type EmailMetric = + | 'received' + | 'delivered' + | 'complained' + | 'suppressed' + | 'bounced' + | 'bounced_transient' + | 'bounced_permanent' + | 'bounced_undetermined' + | 'opened' + | 'clicked' + | 'unsubscribed' + | 'delivery_delayed' + | 'failed' + | 'sent' + | 'unique_opened' + | 'unique_clicked' + | 'delivery_rate' + | 'open_rate' + | 'click_rate' + | 'bounce_rate' + | 'complaint_rate' + | 'unsubscribe_rate'; + +export type EmailMetricsDimension = 'period' | 'domain' | 'email' | 'broadcast'; + +export type EmailMetricsGranularity = 'hourly' | 'daily' | 'weekly' | 'monthly'; + +type EmailMetricsCommonOptions = { + /** + * The start of the date range, as an ISO 8601 date or datetime. + * Defaults to 6 days before `endDate`. + * + * @link https://resend.com/docs/api-reference/emails/get-metrics#query-parameters + */ + startDate?: string; + + /** + * The end of the date range, as an ISO 8601 date or datetime. + * Defaults to now. + * + * @link https://resend.com/docs/api-reference/emails/get-metrics#query-parameters + */ + endDate?: string; + + /** + * The IANA timezone used to bucket periods when `period` is in `dimensions`. + * Defaults to `UTC`. + */ + timezone?: string; + + /** + * The bucket size used when `period` is in `dimensions`. + * Defaults to `daily`. + */ + granularity?: EmailMetricsGranularity; + + /** + * The metrics to include in the response. Defaults to all metrics. + */ + metrics?: EmailMetric[]; + + /** + * Restrict the response to these sending domain IDs. + */ + domainId?: string[]; +}; + +export type GetEmailsMetricsOptions = EmailMetricsCommonOptions & + ( + | { + /** + * The dimensions to break the response down by. Defaults to `[]`, + * which returns a single `totals` row for the whole range, with no + * `data`. Cannot combine `broadcast` with `email`/`emailId`. + */ + dimensions?: Exclude[]; + + /** + * Restrict the response to these email IDs. Cannot be combined with + * the `broadcast` dimension or `broadcastId`. + */ + emailId?: string[]; + broadcastId?: never; + } + | { + /** + * The dimensions to break the response down by. Defaults to `[]`, + * which returns a single `totals` row for the whole range, with no + * `data`. Cannot combine `email` with `broadcast`/`broadcastId`. + */ + dimensions?: Exclude[]; + + emailId?: never; + /** + * Restrict the response to these broadcast IDs. Cannot be combined + * with the `email` dimension or `emailId`. + */ + broadcastId?: string[]; + } + ); + +export type EmailMetricsTotals = Partial>; + +export type EmailMetricsDataRow = EmailMetricsTotals & { + period?: string; + domain_id?: string; + domain_name?: string; + email_id?: string; + broadcast_id?: string; + broadcast_name?: string; +}; + +export interface GetEmailsMetricsResponseSuccess { + object: 'metrics'; + start_date: string; + end_date: string; + metrics: EmailMetric[]; + dimensions: EmailMetricsDimension[]; + granularity: EmailMetricsGranularity; + totals: EmailMetricsTotals; + data?: EmailMetricsDataRow[]; +} + +export type GetEmailsMetricsResponse = + Response; diff --git a/src/emails/interfaces/index.ts b/src/emails/interfaces/index.ts index c11aeb02..750bfb66 100644 --- a/src/emails/interfaces/index.ts +++ b/src/emails/interfaces/index.ts @@ -1,6 +1,7 @@ export * from './cancel-email-options.interface'; export * from './create-email-options.interface'; export * from './get-email-options.interface'; +export * from './get-metrics.interface'; export * from './list-emails-options.interface'; export * from './share-email-options.interface'; export * from './update-email-options.interface';