From 4fffd2a227ff1e0b0b2e44c4ccd5e792689defd8 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Tue, 13 Oct 2026 10:29:06 +0200 Subject: [PATCH 1/3] Add Email Marketing stats (client.marketingStats.get) Decisions: - Token-scoped like emailCampaigns: GET /api/marketing/stats takes no account id, so the getter needs no accountId - One filter object mirroring stats.get (start_date, end_date, sending_domain_ids, sources, email_service_providers); axios serializes arrays as key[] - Response keeps the { data } envelope, as emailCampaigns.getStats does - MarketingStats stays its own type rather than an alias of EmailCampaignStats: rates use different denominators and the OpenAPI spec keeps separate schemas Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + examples/marketing-stats/everything.ts | 51 ++++++++ src/__tests__/lib/api/MarketingStats.test.ts | 15 +++ .../lib/api/resources/MarketingStats.test.ts | 123 ++++++++++++++++++ src/__tests__/lib/mailtrap-client.test.ts | 13 ++ src/lib/MailtrapClient.ts | 8 ++ src/lib/api/MarketingStats.ts | 12 ++ src/lib/api/resources/MarketingStats.ts | 47 +++++++ src/types/api/marketing-stats.ts | 40 ++++++ 9 files changed, 310 insertions(+) create mode 100644 examples/marketing-stats/everything.ts create mode 100644 src/__tests__/lib/api/MarketingStats.test.ts create mode 100644 src/__tests__/lib/api/resources/MarketingStats.test.ts create mode 100644 src/lib/api/MarketingStats.ts create mode 100644 src/lib/api/resources/MarketingStats.ts create mode 100644 src/types/api/marketing-stats.ts diff --git a/README.md b/README.md index f831fb9..238fc99 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ Contact management: Email Marketing: - Email campaigns CRUD, lifecycle & stats – [`email-campaigns/everything.ts`](examples/email-campaigns/everything.ts) +- Email Marketing stats (campaigns and automations, aggregated) – [`marketing-stats/everything.ts`](examples/marketing-stats/everything.ts) General API: diff --git a/examples/marketing-stats/everything.ts b/examples/marketing-stats/everything.ts new file mode 100644 index 0000000..21f6fa3 --- /dev/null +++ b/examples/marketing-stats/everything.ts @@ -0,0 +1,51 @@ +import { MailtrapClient } from "mailtrap"; + +const TOKEN = process.env.MAILTRAP_API_KEY ?? ""; + +// The Email Marketing Stats API is token-scoped — no `accountId` is needed. +const client = new MailtrapClient({ token: TOKEN }); + +const toDateParam = (date: Date) => date.toISOString().slice(0, 10); + +// The last 30 days. +const endDate = new Date(); +const startDate = new Date(endDate.getTime() - 30 * 24 * 60 * 60 * 1000); +const dateRange = { + start_date: toDateParam(startDate), + end_date: toDateParam(endDate), +}; + +async function marketingStatsFlow() { + try { + // Campaigns and automations together. The response is a `{ data }` + // envelope; every count and rate is `0` when nothing matched. + const all = await client.marketingStats.get(dateRange); + console.log("Email Marketing stats:", JSON.stringify(all.data, null, 2)); + + // One source narrows the results to campaigns or to automations. + const campaigns = await client.marketingStats.get({ + ...dateRange, + sources: ["campaigns"], + }); + console.log("Campaigns only:", JSON.stringify(campaigns.data, null, 2)); + + // Narrow further by recipient email service provider; add + // `sending_domain_ids` (from the Sending Domains endpoints) to pick domains. + const automationsAtGoogle = await client.marketingStats.get({ + ...dateRange, + sources: ["automations"], + email_service_providers: ["Google", "Google Workspace"], + }); + console.log( + "Automations delivered to Google:", + JSON.stringify(automationsAtGoogle.data, null, 2) + ); + } catch (error) { + console.error( + "Error in marketingStatsFlow:", + error instanceof Error ? error.message : String(error) + ); + } +} + +marketingStatsFlow(); diff --git a/src/__tests__/lib/api/MarketingStats.test.ts b/src/__tests__/lib/api/MarketingStats.test.ts new file mode 100644 index 0000000..69894f4 --- /dev/null +++ b/src/__tests__/lib/api/MarketingStats.test.ts @@ -0,0 +1,15 @@ +import axios from "axios"; + +import MarketingStatsBaseAPI from "../../../lib/api/MarketingStats"; + +describe("lib/api/MarketingStats: ", () => { + const marketingStatsAPI = new MarketingStatsBaseAPI(axios); + + describe("class MarketingStatsBaseAPI(): ", () => { + describe("init: ", () => { + it("initializes with all necessary params.", () => { + expect(marketingStatsAPI).toHaveProperty("get"); + }); + }); + }); +}); diff --git a/src/__tests__/lib/api/resources/MarketingStats.test.ts b/src/__tests__/lib/api/resources/MarketingStats.test.ts new file mode 100644 index 0000000..4757e8a --- /dev/null +++ b/src/__tests__/lib/api/resources/MarketingStats.test.ts @@ -0,0 +1,123 @@ +import axios from "axios"; +import AxiosMockAdapter from "axios-mock-adapter"; + +import MarketingStatsApi from "../../../../lib/api/resources/MarketingStats"; +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/MarketingStats: ", () => { + let mock: AxiosMockAdapter; + const marketingStatsAPI = new MarketingStatsApi(axios); + const endpoint = `${GENERAL_ENDPOINT}/api/marketing/stats`; + + const defaultParams = { + start_date: "2026-05-01", + end_date: "2026-05-31", + }; + + const responseData = { + data: { + delivery_count: 1200, + delivery_rate: 0.96, + bounce_count: 50, + bounce_rate: 0.04, + open_count: 600, + open_rate: 0.5, + click_count: 240, + click_rate: 0.2, + spam_count: 3, + spam_rate: 0.0025, + unsubscription_count: 12, + unsubscription_rate: 0.01, + sent_count: 1250, + }, + }; + + describe("class MarketingStatsApi(): ", () => { + describe("init: ", () => { + it("initializes with all necessary params.", () => { + expect(marketingStatsAPI).toHaveProperty("get"); + }); + }); + }); + + beforeAll(() => { + /** + * Init Axios interceptors for handling response.data, errors. + */ + axios.interceptors.response.use( + (response) => response.data, + handleSendingError + ); + mock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + mock.reset(); + }); + + describe("get(): ", () => { + it("gets marketing stats wrapped in a data envelope.", async () => { + expect.assertions(3); + + mock.onGet(endpoint).reply(200, responseData); + const result = await marketingStatsAPI.get(defaultParams); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(mock.history.get[0].params).toEqual(defaultParams); + expect(result).toEqual(responseData); + }); + + it("serializes array filters as single-bracketed query params.", async () => { + expect.assertions(1); + + mock.onGet(endpoint).reply(200, responseData); + await marketingStatsAPI.get({ + ...defaultParams, + sending_domain_ids: [3938, 3939], + sources: ["campaigns"], + email_service_providers: ["Google", "Google Workspace"], + }); + + const { url, params } = mock.history.get[0]; + // Reconstruct what axios actually puts on the wire + const serializedUrl = decodeURIComponent( + axios.getUri({ url: url!, params }) + ); + + expect(serializedUrl).toBe( + `${endpoint}?start_date=2026-05-01&end_date=2026-05-31` + + "&sending_domain_ids[]=3938&sending_domain_ids[]=3939" + + "&sources[]=campaigns" + + "&email_service_providers[]=Google&email_service_providers[]=Google+Workspace" + ); + }); + + it.each([ + [400, { errors: "Invalid source: 'newsletters'" }], + [401, { error: "Incorrect API token" }], + [403, { errors: "Access forbidden" }], + ])("fails with error on %i.", async (statusCode, body) => { + expect.assertions(3); + + mock.onGet(endpoint).reply(statusCode, body); + + try { + await marketingStatsAPI.get(defaultParams); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(Object.values(body)[0]); + // @ts-expect-error ES5 types don't know about cause property + expect(error.cause?.response?.status).toEqual(statusCode); + } + } + }); + }); +}); diff --git a/src/__tests__/lib/mailtrap-client.test.ts b/src/__tests__/lib/mailtrap-client.test.ts index 9174f1f..c972d3f 100644 --- a/src/__tests__/lib/mailtrap-client.test.ts +++ b/src/__tests__/lib/mailtrap-client.test.ts @@ -18,6 +18,7 @@ import SuppressionsBaseAPI from "../../lib/api/Suppressions"; import SendingDomainsBaseAPI from "../../lib/api/SendingDomains"; import EmailLogsBaseAPI from "../../lib/api/EmailLogs"; import ContactEventsBaseAPI from "../../lib/api/ContactEvents"; +import MarketingStatsBaseAPI from "../../lib/api/MarketingStats"; const { ERRORS, CLIENT_SETTINGS } = CONFIG; const { TESTING_ENDPOINT, BULK_ENDPOINT, SENDING_ENDPOINT } = CLIENT_SETTINGS; @@ -1008,5 +1009,17 @@ describe("lib/mailtrap-client: ", () => { expect(contactEventsClient).toBeInstanceOf(ContactEventsBaseAPI); }); }); + + describe("get marketingStats(): ", () => { + it("returns marketing stats API object without an accountId.", () => { + expect.assertions(1); + + const client = new MailtrapClient({ + token: "MY_API_TOKEN", + }); + + expect(client.marketingStats).toBeInstanceOf(MarketingStatsBaseAPI); + }); + }); }); }); diff --git a/src/lib/MailtrapClient.ts b/src/lib/MailtrapClient.ts index 54663af..9638a5a 100644 --- a/src/lib/MailtrapClient.ts +++ b/src/lib/MailtrapClient.ts @@ -18,6 +18,7 @@ import EmailCampaignsBaseAPI from "./api/EmailCampaigns"; import EmailLogsBaseAPI from "./api/EmailLogs"; import GeneralAPI from "./api/General"; import InboundAPI from "./api/Inbound"; +import MarketingStatsBaseAPI from "./api/MarketingStats"; import SendingDomainsBaseAPI from "./api/SendingDomains"; import StatsBaseAPI from "./api/Stats"; import SuppressionsBaseAPI from "./api/Suppressions"; @@ -273,6 +274,13 @@ export default class MailtrapClient { return new EmailCampaignsBaseAPI(this.axios); } + /** + * Getter for Email Marketing Stats API. Scoped to the token's account, so no accountId is required. + */ + get marketingStats() { + return new MarketingStatsBaseAPI(this.axios); + } + /** * Getter for Tracking Opt-outs API. */ diff --git a/src/lib/api/MarketingStats.ts b/src/lib/api/MarketingStats.ts new file mode 100644 index 0000000..d4d7050 --- /dev/null +++ b/src/lib/api/MarketingStats.ts @@ -0,0 +1,12 @@ +import { AxiosInstance } from "axios"; + +import MarketingStatsApi from "./resources/MarketingStats"; + +export default class MarketingStatsBaseAPI { + public get: MarketingStatsApi["get"]; + + constructor(client: AxiosInstance) { + const marketingStats = new MarketingStatsApi(client); + this.get = marketingStats.get.bind(marketingStats); + } +} diff --git a/src/lib/api/resources/MarketingStats.ts b/src/lib/api/resources/MarketingStats.ts new file mode 100644 index 0000000..df8f1cd --- /dev/null +++ b/src/lib/api/resources/MarketingStats.ts @@ -0,0 +1,47 @@ +import { AxiosInstance } from "axios"; + +import CONFIG from "../../../config"; +import { + GetMarketingStatsResponse, + MarketingStatsFilterParams, +} from "../../../types/api/marketing-stats"; + +const { CLIENT_SETTINGS } = CONFIG; +const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; + +export default class MarketingStatsApi { + private client: AxiosInstance; + + private marketingStatsURL: string; + + constructor(client: AxiosInstance) { + this.client = client; + // Token-scoped: the account is resolved from the API token server-side, + // so the path takes no account id. + this.marketingStatsURL = `${GENERAL_ENDPOINT}/api/marketing/stats`; + } + + /** + * Get aggregated Email Marketing stats (campaigns and automations) for a + * date range, wrapped in a `{ data }` envelope. Email API/SMTP traffic is + * reported by `stats.get` instead. + */ + public async get(params: MarketingStatsFilterParams) { + const query = { + start_date: params.start_date, + end_date: params.end_date, + ...(params.sending_domain_ids && { + sending_domain_ids: params.sending_domain_ids, + }), + ...(params.sources && { sources: params.sources }), + ...(params.email_service_providers && { + email_service_providers: params.email_service_providers, + }), + }; + + return this.client.get< + GetMarketingStatsResponse, + GetMarketingStatsResponse + >(this.marketingStatsURL, { params: query }); + } +} diff --git a/src/types/api/marketing-stats.ts b/src/types/api/marketing-stats.ts new file mode 100644 index 0000000..975b502 --- /dev/null +++ b/src/types/api/marketing-stats.ts @@ -0,0 +1,40 @@ +export type MarketingStatsSource = "campaigns" | "automations"; + +/** + * Aggregated Email Marketing metrics. Rates are fractions from 0 to 1: + * `delivery_rate` and `bounce_rate` are shares of `sent_count`, the other + * rates are shares of `delivery_count`. Every count and rate is `0` when + * nothing matched the filters. + */ +export type MarketingStats = { + 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; + unsubscription_count: number; + unsubscription_rate: number; + /** Delivered plus bounced messages. */ + sent_count: number; +}; + +export type MarketingStatsFilterParams = { + /** Start of the period (inclusive), `YYYY-MM-DD`. */ + start_date: string; + /** End of the period (inclusive), `YYYY-MM-DD`. */ + end_date: string; + /** IDs of the sending domains to include, as returned by the Sending Domains endpoints. */ + sending_domain_ids?: number[]; + /** One source narrows the results to it; omit, or pass both, for all Email Marketing traffic. */ + sources?: MarketingStatsSource[]; + email_service_providers?: string[]; +}; + +export type GetMarketingStatsResponse = { + data: MarketingStats; +}; From 167886878f115d3e770b2bee20de0db5264d9ead Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Tue, 13 Oct 2026 11:58:05 +0200 Subject: [PATCH 2/3] Simplify marketing stats query and test the 429 error Decisions: - Plain assignments instead of conditional spreads: axios already drops undefined params, and the known-filter pick still keeps unknown keys off the wire - 429 added to the error table: the endpoint is rate limited per account, so it is the error callers will hit most Co-Authored-By: Claude Opus 5.5 --- .../lib/api/resources/MarketingStats.test.ts | 1 + src/lib/api/resources/MarketingStats.ts | 11 ++++------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/__tests__/lib/api/resources/MarketingStats.test.ts b/src/__tests__/lib/api/resources/MarketingStats.test.ts index 4757e8a..4de32bc 100644 --- a/src/__tests__/lib/api/resources/MarketingStats.test.ts +++ b/src/__tests__/lib/api/resources/MarketingStats.test.ts @@ -102,6 +102,7 @@ describe("lib/api/resources/MarketingStats: ", () => { [400, { errors: "Invalid source: 'newsletters'" }], [401, { error: "Incorrect API token" }], [403, { errors: "Access forbidden" }], + [429, { errors: "Rate limit exceeded" }], ])("fails with error on %i.", async (statusCode, body) => { expect.assertions(3); diff --git a/src/lib/api/resources/MarketingStats.ts b/src/lib/api/resources/MarketingStats.ts index df8f1cd..0e04d32 100644 --- a/src/lib/api/resources/MarketingStats.ts +++ b/src/lib/api/resources/MarketingStats.ts @@ -27,16 +27,13 @@ export default class MarketingStatsApi { * reported by `stats.get` instead. */ public async get(params: MarketingStatsFilterParams) { + // Pick only the known filters; axios drops the ones left undefined. const query = { start_date: params.start_date, end_date: params.end_date, - ...(params.sending_domain_ids && { - sending_domain_ids: params.sending_domain_ids, - }), - ...(params.sources && { sources: params.sources }), - ...(params.email_service_providers && { - email_service_providers: params.email_service_providers, - }), + sending_domain_ids: params.sending_domain_ids, + sources: params.sources, + email_service_providers: params.email_service_providers, }; return this.client.get< From c9534ae42e959183adca180b8faf4ddff7e44c55 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Tue, 13 Oct 2026 12:50:38 +0200 Subject: [PATCH 3/3] Document the email_service_providers filter Decisions: - It was the only marketing stats filter without a doc comment, and the API matches providers by exact name ("Google", not "Gmail") Co-Authored-By: Claude Opus 5.5 --- src/types/api/marketing-stats.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types/api/marketing-stats.ts b/src/types/api/marketing-stats.ts index 975b502..e509da8 100644 --- a/src/types/api/marketing-stats.ts +++ b/src/types/api/marketing-stats.ts @@ -32,6 +32,7 @@ export type MarketingStatsFilterParams = { sending_domain_ids?: number[]; /** One source narrows the results to it; omit, or pass both, for all Email Marketing traffic. */ sources?: MarketingStatsSource[]; + /** Recipient email service providers to include, e.g. `"Google"`, `"Yahoo"`; omit for all providers. */ email_service_providers?: string[]; };