diff --git a/CHANGELOG.md b/CHANGELOG.md index 092d16a..40ba225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,18 @@ +## [Unreleased] + +- Add StatsApi with get, byDomain, byCategory, byEmailServiceProvider, byDate endpoints + ## [4.4.0] - 2025-12-08 + - Add ES module support by specifying import path in package.json by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/112 - Bump new minor version as previous changes were pretty huge and shouldn't be released under patch version ## [4.3.2] - 2025-11-27 + - Rollback to v4.3.0 ## [4.3.1] - 2025-11-25 + - Contact fields by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/89 - Fix optional account by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/91 - build(deps-dev): bump nodemailer from 6.9.9 to 7.0.7 by @dependabot[bot] in https://github.com/mailtrap/mailtrap-nodejs/pull/92 @@ -14,21 +21,22 @@ - Fix #84 by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/96 - Fix #82 by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/97 - Contact imports by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/95 -- Contact exports by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/98 +- Contact exports by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/98 - Create contact event by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/99 - Billing api by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/103 - Fix axios error parsing by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/104 - Update README.md to enhance installation and usage instructions, impr… by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/107 - Fix 106 by @narekhovhannisyan in https://github.com/mailtrap/mailtrap-nodejs/pull/108 - ## [4.3.0] - 2025-10-02 + - feat: add Permissions type to projects API for enhanced access control by @narekhovhannisyan in https://github.com/railsware/mailtrap-nodejs/pull/80 - update readme by @yanchuk in https://github.com/railsware/mailtrap-nodejs/pull/66 - build(deps): bump axios from 1.8.2 to 1.12.0 by @dependabot[bot] in https://github.com/railsware/mailtrap-nodejs/pull/86 - Dynamic user agent by @narekhovhannisyan in https://github.com/railsware/mailtrap-nodejs/pull/85 ## [4.2.0] - 2025-07-08 + - Add support for [Batch Sending API](https://github.com/railsware/mailtrap-nodejs/pull/63). - Add support for [Contacts API](https://github.com/railsware/mailtrap-nodejs/pull/64). - Add support for [Contact Lists API](https://github.com/railsware/mailtrap-nodejs/pull/65). @@ -37,9 +45,11 @@ - Make `testInboxId` optional in the `MailtrapClient` configuration (https://github.com/railsware/mailtrap-nodejs/pull/70). ## [4.1.0] - 2025-04-18 + - Add support for `reply_to` in Sending API (in https://github.com/railsware/mailtrap-nodejs/pull/58, thanks to @aolamide). ## [4.0.0] - 2025-02-28 + - BREAKING CHANGE: Missing params for the Testing API (here) are treated as errors (throw new Error(...)), not warnings. - BREAKING CHANGE: Removes send methods from the `BulkSendingAPI` and `TestingAPI` classes. There should be only one send method on the `MailtrapClient`. - The `general` and `testing` APIs are created lazily, after the first access to the corresponding getters. @@ -47,17 +57,20 @@ - Security updates for dependencies ## [3.4.0] - 2024-06-10 + - Add support for Bulk product API. - Refer to the [`examples/bulk`](examples/bulk) folder for code examples. - Restructure examples folder. ## [3.3.0] - 2024-01-31 + - Add support for Testing product API. - All public routes from API V2 are now available in SDK. - Refer to the [`examples`](examples) folder for code examples. - Security updates. ## [3.2.0] - 2023-08-30 + - Add `mailtrap-nodemailer-transporter` for sending emails using HTTP API via `nodemailer`. - Security updates. diff --git a/examples/stats/everything.ts b/examples/stats/everything.ts new file mode 100644 index 0000000..19d467a --- /dev/null +++ b/examples/stats/everything.ts @@ -0,0 +1,100 @@ +import { MailtrapClient } from "mailtrap"; + +const TOKEN = ""; +const ACCOUNT_ID = 123456; + +const client = new MailtrapClient({ token: TOKEN, accountId: ACCOUNT_ID }); + +const statsClient = client.stats; + +const testGetStats = async () => { + try { + const result = await statsClient.get({ + start_date: "2026-01-01", + end_date: "2026-01-31", + }); + console.log("Stats:", JSON.stringify(result, null, 2)); + } catch (error) { + console.error(error); + } +}; + +const testGetStatsWithFilters = async () => { + try { + const result = await statsClient.get({ + start_date: "2026-01-01", + end_date: "2026-01-31", + sending_domain_ids: [1, 2], + sending_streams: ["transactional"], + categories: ["Welcome email"], + email_service_providers: ["Gmail", "Yahoo"], + }); + console.log("Filtered stats:", JSON.stringify(result, null, 2)); + } catch (error) { + console.error(error); + } +}; + +const testGetStatsByDomains = async () => { + try { + const result = await statsClient.byDomain({ + start_date: "2026-01-01", + end_date: "2026-01-31", + }); + console.log("Stats by domains:", JSON.stringify(result, null, 2)); + } catch (error) { + console.error(error); + } +}; + +const testGetStatsByCategories = async () => { + try { + const result = await statsClient.byCategory({ + start_date: "2026-01-01", + end_date: "2026-01-31", + }); + console.log("Stats by categories:", JSON.stringify(result, null, 2)); + } catch (error) { + console.error(error); + } +}; + +const testGetStatsByEmailServiceProviders = async () => { + try { + const result = await statsClient.byEmailServiceProvider({ + start_date: "2026-01-01", + end_date: "2026-01-31", + }); + console.log( + "Stats by email service providers:", + JSON.stringify(result, null, 2) + ); + } catch (error) { + console.error(error); + } +}; + +const testGetStatsByDate = async () => { + try { + const result = await statsClient.byDate({ + start_date: "2026-01-01", + end_date: "2026-01-31", + }); + console.log("Stats by date:", JSON.stringify(result, null, 2)); + } catch (error) { + console.error(error); + } +}; + +(async () => { + try { + await testGetStats(); + await testGetStatsWithFilters(); + await testGetStatsByDomains(); + await testGetStatsByCategories(); + await testGetStatsByEmailServiceProviders(); + await testGetStatsByDate(); + } catch (error) { + console.error("Error running stats examples:", error); + } +})(); diff --git a/src/__tests__/lib/api/resources/Stats.test.ts b/src/__tests__/lib/api/resources/Stats.test.ts new file mode 100644 index 0000000..859406f --- /dev/null +++ b/src/__tests__/lib/api/resources/Stats.test.ts @@ -0,0 +1,312 @@ +import axios from "axios"; +import AxiosMockAdapter from "axios-mock-adapter"; + +import Stats from "../../../../lib/api/resources/Stats"; +import handleSendingError from "../../../../lib/axios-logger"; +import MailtrapError from "../../../../lib/MailtrapError"; + +import CONFIG from "../../../../config"; + +const { CLIENT_SETTINGS } = CONFIG; +const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; + +describe("lib/api/resources/Stats: ", () => { + let mock: AxiosMockAdapter; + const accountId = 100; + const statsAPI = new Stats(axios, accountId); + const baseURL = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/stats`; + + const defaultParams = { + start_date: "2026-01-01", + end_date: "2026-01-31", + }; + + const sampleStatsResponse = { + delivery_count: 150, + delivery_rate: 0.95, + bounce_count: 8, + bounce_rate: 0.05, + open_count: 120, + open_rate: 0.8, + click_count: 60, + click_rate: 0.5, + spam_count: 2, + spam_rate: 0.013, + }; + + const sampleGroupedByDomainResponse = [ + { + sending_domain_id: 1, + stats: { + delivery_count: 100, + delivery_rate: 0.96, + bounce_count: 4, + bounce_rate: 0.04, + open_count: 80, + open_rate: 0.8, + click_count: 40, + click_rate: 0.5, + spam_count: 1, + spam_rate: 0.01, + }, + }, + { + sending_domain_id: 2, + stats: { + delivery_count: 50, + delivery_rate: 0.93, + bounce_count: 4, + bounce_rate: 0.07, + open_count: 40, + open_rate: 0.8, + click_count: 20, + click_rate: 0.5, + spam_count: 1, + spam_rate: 0.02, + }, + }, + ]; + + beforeAll(() => { + /** + * Init Axios interceptors for handling response.data, errors. + */ + axios.interceptors.response.use( + (response) => response.data, + handleSendingError + ); + mock = new AxiosMockAdapter(axios); + }); + + describe("class Stats(): ", () => { + describe("init: ", () => { + it("initializes with all necessary params.", () => { + expect(statsAPI).toHaveProperty("get"); + expect(statsAPI).toHaveProperty("byDomain"); + expect(statsAPI).toHaveProperty("byCategory"); + expect(statsAPI).toHaveProperty("byEmailServiceProvider"); + expect(statsAPI).toHaveProperty("byDate"); + }); + }); + }); + + afterEach(() => { + mock.reset(); + }); + + describe("get(): ", () => { + it("successfully gets aggregated sending stats.", async () => { + expect.assertions(2); + + mock.onGet(baseURL).reply(200, sampleStatsResponse); + const result = await statsAPI.get(defaultParams); + + expect(mock.history.get[0].url).toEqual(baseURL); + expect(result).toEqual(sampleStatsResponse); + }); + + it("correctly serializes array filters in the query string.", async () => { + expect.assertions(4); + + mock.onGet(baseURL).reply(200, sampleStatsResponse); + await statsAPI.get({ + ...defaultParams, + sending_domain_ids: [1, 2], + sending_streams: ["transactional"], + categories: ["Welcome email"], + email_service_providers: ["Gmail"], + }); + + const { url, params } = mock.history.get[0]; + // Reconstruct what axios actually puts on the wire + const serializedUrl = decodeURIComponent( + axios.getUri({ url: url!, params }) + ); + + // Must be single-bracketed, NOT double-bracketed + expect(serializedUrl).toMatch(/sending_domain_ids\[\]=1/); + expect(serializedUrl).not.toMatch(/sending_domain_ids\[\]\[\]/); + expect(serializedUrl).toMatch(/sending_streams\[\]=transactional/); + expect(serializedUrl).toMatch(/categories\[\]=Welcome(\+|%20| )email/); + }); + + it("fails with error when accountId is invalid.", async () => { + const expectedErrorMessage = "Account not found"; + const statusCode = 404; + + expect.assertions(3); + + mock.onGet(baseURL).reply(statusCode, { error: expectedErrorMessage }); + + try { + await statsAPI.get(defaultParams); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + // @ts-expect-error ES5 types don't know about cause property + expect(error.cause?.response?.status).toEqual(statusCode); + } + } + }); + + it("fails with error when unauthorized.", async () => { + const expectedErrorMessage = "Incorrect API token"; + const statusCode = 401; + + expect.assertions(3); + + mock.onGet(baseURL).reply(statusCode, { error: expectedErrorMessage }); + + try { + await statsAPI.get(defaultParams); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + // @ts-expect-error ES5 types don't know about cause property + expect(error.cause?.response?.status).toEqual(statusCode); + } + } + }); + }); + + describe("byDomain(): ", () => { + it("successfully gets stats grouped by domain.", async () => { + const endpoint = `${baseURL}/domains`; + + expect.assertions(3); + + mock.onGet(endpoint).reply(200, sampleGroupedByDomainResponse); + const result = await statsAPI.byDomain(defaultParams); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(result).toHaveLength(2); + expect(result).toEqual([ + { + name: "sending_domain_id", + value: 1, + stats: sampleGroupedByDomainResponse[0].stats, + }, + { + name: "sending_domain_id", + value: 2, + stats: sampleGroupedByDomainResponse[1].stats, + }, + ]); + }); + }); + + describe("byCategory(): ", () => { + it("successfully gets stats grouped by category.", async () => { + const endpoint = `${baseURL}/categories`; + const responseData = [ + { + category: "Transactional", + stats: { + delivery_count: 100, + delivery_rate: 0.97, + bounce_count: 3, + bounce_rate: 0.03, + open_count: 85, + open_rate: 0.85, + click_count: 45, + click_rate: 0.53, + spam_count: 0, + spam_rate: 0.0, + }, + }, + ]; + + expect.assertions(2); + + mock.onGet(endpoint).reply(200, responseData); + const result = await statsAPI.byCategory(defaultParams); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(result).toEqual([ + { + name: "category", + value: "Transactional", + stats: responseData[0].stats, + }, + ]); + }); + }); + + describe("byEmailServiceProvider(): ", () => { + it("successfully gets stats grouped by email service provider.", async () => { + const endpoint = `${baseURL}/email_service_providers`; + const responseData = [ + { + email_service_provider: "Gmail", + stats: { + delivery_count: 80, + delivery_rate: 0.97, + bounce_count: 2, + bounce_rate: 0.03, + open_count: 70, + open_rate: 0.88, + click_count: 35, + click_rate: 0.5, + spam_count: 1, + spam_rate: 0.013, + }, + }, + ]; + + expect.assertions(2); + + mock.onGet(endpoint).reply(200, responseData); + const result = await statsAPI.byEmailServiceProvider(defaultParams); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(result).toEqual([ + { + name: "email_service_provider", + value: "Gmail", + stats: responseData[0].stats, + }, + ]); + }); + }); + + describe("byDate(): ", () => { + it("successfully gets stats grouped by date.", async () => { + const endpoint = `${baseURL}/date`; + const responseData = [ + { + date: "2026-01-01", + stats: { + delivery_count: 5, + delivery_rate: 1.0, + bounce_count: 0, + bounce_rate: 0.0, + open_count: 4, + open_rate: 0.8, + click_count: 2, + click_rate: 0.5, + spam_count: 0, + spam_rate: 0.0, + }, + }, + ]; + + expect.assertions(2); + + mock.onGet(endpoint).reply(200, responseData); + const result = await statsAPI.byDate(defaultParams); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(result).toEqual([ + { + name: "date", + value: "2026-01-01", + stats: responseData[0].stats, + }, + ]); + }); + }); +}); diff --git a/src/lib/MailtrapClient.ts b/src/lib/MailtrapClient.ts index 08bf646..c1544ff 100644 --- a/src/lib/MailtrapClient.ts +++ b/src/lib/MailtrapClient.ts @@ -17,6 +17,7 @@ import GeneralAPI from "./api/General"; import TemplatesBaseAPI from "./api/Templates"; import SuppressionsBaseAPI from "./api/Suppressions"; import SendingDomainsBaseAPI from "./api/SendingDomains"; +import StatsBaseAPI from "./api/Stats"; import TestingAPI from "./api/Testing"; import CONFIG from "../config"; @@ -189,6 +190,14 @@ export default class MailtrapClient { return new SuppressionsBaseAPI(this.axios, accountId); } + /** + * Getter for Stats API. + */ + get stats() { + const accountId = this.validateAccountIdPresence(); + return new StatsBaseAPI(this.axios, accountId); + } + /** * Getter for Sending Domains API. */ diff --git a/src/lib/api/Stats.ts b/src/lib/api/Stats.ts new file mode 100644 index 0000000..fdaafb1 --- /dev/null +++ b/src/lib/api/Stats.ts @@ -0,0 +1,24 @@ +import { AxiosInstance } from "axios"; + +import StatsApi from "./resources/Stats"; + +export default class StatsBaseAPI { + public get: StatsApi["get"]; + + public byDomain: StatsApi["byDomain"]; + + public byCategory: StatsApi["byCategory"]; + + public byEmailServiceProvider: StatsApi["byEmailServiceProvider"]; + + public byDate: StatsApi["byDate"]; + + constructor(client: AxiosInstance, accountId: number) { + const stats = new StatsApi(client, accountId); + this.get = stats.get.bind(stats); + this.byDomain = stats.byDomain.bind(stats); + this.byCategory = stats.byCategory.bind(stats); + this.byEmailServiceProvider = stats.byEmailServiceProvider.bind(stats); + this.byDate = stats.byDate.bind(stats); + } +} diff --git a/src/lib/api/resources/Stats.ts b/src/lib/api/resources/Stats.ts new file mode 100644 index 0000000..6e902a7 --- /dev/null +++ b/src/lib/api/resources/Stats.ts @@ -0,0 +1,123 @@ +import { AxiosInstance } from "axios"; + +import CONFIG from "../../../config"; + +import { + SendingStatGroup, + SendingStats, + StatsFilterParams, +} from "../../../types/api/stats"; + +const { CLIENT_SETTINGS } = CONFIG; +const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; + +const GROUP_KEYS: Record = { + domains: "sending_domain_id", + categories: "category", + email_service_providers: "email_service_provider", + date: "date", +}; + +type RawGroupedStatsItem = { + stats: SendingStats; + [key: string]: unknown; +}; + +export default class StatsApi { + private client: AxiosInstance; + + private statsURL: string; + + constructor(client: AxiosInstance, accountId: number) { + this.client = client; + this.statsURL = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/stats`; + } + + /** + * Get aggregated sending stats. + */ + public async get(params: StatsFilterParams) { + const url = this.statsURL; + + return this.client.get(url, { + params: StatsApi.buildQueryParams(params), + }); + } + + /** + * Get sending stats grouped by domain. + */ + public async byDomain(params: StatsFilterParams) { + return this.groupedStats("domains", params); + } + + /** + * Get sending stats grouped by category. + */ + public async byCategory(params: StatsFilterParams) { + return this.groupedStats("categories", params); + } + + /** + * Get sending stats grouped by email service provider. + */ + public async byEmailServiceProvider(params: StatsFilterParams) { + return this.groupedStats("email_service_providers", params); + } + + /** + * Get sending stats grouped by date. + */ + public async byDate(params: StatsFilterParams) { + return this.groupedStats("date", params); + } + + private async groupedStats( + group: string, + params: StatsFilterParams + ): Promise { + const url = `${this.statsURL}/${group}`; + const groupKey = GROUP_KEYS[group]; + + if (!groupKey) { + throw new Error(`Unknown stats group: ${group}`); + } + + const response = await this.client.get< + RawGroupedStatsItem[], + RawGroupedStatsItem[] + >(url, { + params: StatsApi.buildQueryParams(params), + }); + + return response.map((item) => ({ + name: groupKey, + value: item[groupKey] as string | number, + stats: item.stats, + })); + } + + private static buildQueryParams( + params: StatsFilterParams + ): Record { + const query: Record = { + start_date: params.start_date, + end_date: params.end_date, + }; + + if (params.sending_domain_ids) { + query.sending_domain_ids = params.sending_domain_ids; + } + if (params.sending_streams) { + query.sending_streams = params.sending_streams; + } + if (params.categories) { + query.categories = params.categories; + } + if (params.email_service_providers) { + query.email_service_providers = params.email_service_providers; + } + + return query; + } +} diff --git a/src/types/api/stats.ts b/src/types/api/stats.ts new file mode 100644 index 0000000..7319b09 --- /dev/null +++ b/src/types/api/stats.ts @@ -0,0 +1,27 @@ +export type SendingStats = { + delivery_count: number; + delivery_rate: number; + bounce_count: number; + bounce_rate: number; + open_count: number; + open_rate: number; + click_count: number; + click_rate: number; + spam_count: number; + spam_rate: number; +}; + +export type SendingStatGroup = { + name: string; + value: string | number; + stats: SendingStats; +}; + +export type StatsFilterParams = { + start_date: string; + end_date: string; + sending_domain_ids?: number[]; + sending_streams?: string[]; + categories?: string[]; + email_service_providers?: string[]; +};