diff --git a/README.md b/README.md index 3ed1c57..39750c1 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,8 @@ Email Marketing: General API: - Templates CRUD – [`templates/everything.ts`](examples/templates/everything.ts) -- Suppressions (find & delete) – [`sending/suppressions.ts`](examples/sending/suppressions.ts) +- Suppressions (create, find & delete) – [`sending/suppressions.ts`](examples/sending/suppressions.ts) +- Tracking Opt-outs (list, create & delete) – [`sending/tracking-opt-outs.ts`](examples/sending/tracking-opt-outs.ts) - Billing info – [`general/billing.ts`](examples/general/billing.ts) - Accounts info – [`general/accounts.ts`](examples/general/accounts.ts) - Permissions listing – [`general/permissions.ts`](examples/general/permissions.ts) diff --git a/examples/sending/suppressions.ts b/examples/sending/suppressions.ts index 6b6ded6..24b8575 100644 --- a/examples/sending/suppressions.ts +++ b/examples/sending/suppressions.ts @@ -17,6 +17,14 @@ async function suppressionsFlow() { const filteredSuppressions = await client.suppressions.getList({email: "test@example.com"}); console.log("Filtered suppressions:", filteredSuppressions); + // Add an email to the suppression list. `type` defaults to "manual import". + const created = await client.suppressions.create({ + email: "suppressed@example.com", + domain_id: 12345, + sending_stream: "transactional" + }); + console.log("Created suppression:", created.data); + // Delete a suppression by ID (if any exist) if (suppressions.length > 0) { const suppressionToDelete = suppressions[0]; diff --git a/examples/sending/tracking-opt-outs.ts b/examples/sending/tracking-opt-outs.ts new file mode 100644 index 0000000..41e5776 --- /dev/null +++ b/examples/sending/tracking-opt-outs.ts @@ -0,0 +1,43 @@ +import { MailtrapClient } from "mailtrap"; + +const TOKEN = ""; +const DOMAIN_ID = Number(""); + +const client = new MailtrapClient({ token: TOKEN }); + +async function trackingOptOutsFlow() { + // Opt an email out of open and click tracking for a sending domain + const created = await client.trackingOptOuts.create({ + email: "tracked@example.com", + domain_id: DOMAIN_ID + }); + console.log("Created tracking opt-out:", created.data); + + // Get tracking opt-outs (up to 1000 per request) + const page = await client.trackingOptOuts.getList(); + console.log("Tracking opt-outs:", page.data, "next cursor:", page.last_id); + + // Filter by email and creation time + const filtered = await client.trackingOptOuts.getList({ + email: "tracked@example.com", + start_time: "2025-01-01T00:00:00Z", + end_time: "2025-12-31T23:59:59Z" + }); + console.log("Filtered tracking opt-outs:", filtered.data); + + // Page through the full list, following the cursor + const all = [...page.data]; + let cursor = page.last_id; + while (cursor) { + const next = await client.trackingOptOuts.getList({ last_id: cursor }); + all.push(...next.data); + cursor = next.last_id; + } + console.log(`Fetched ${all.length} tracking opt-outs in total`); + + // Remove an email from the tracking opt-out list. Returns the deleted record. + const deleted = await client.trackingOptOuts.delete(created.data.id); + console.log("Deleted tracking opt-out:", deleted); +} + +trackingOptOutsFlow().catch(console.error); diff --git a/src/__tests__/lib/api/resources/Suppressions.test.ts b/src/__tests__/lib/api/resources/Suppressions.test.ts index 2767133..40fde7a 100644 --- a/src/__tests__/lib/api/resources/Suppressions.test.ts +++ b/src/__tests__/lib/api/resources/Suppressions.test.ts @@ -27,6 +27,8 @@ describe("lib/api/resources/Suppressions: ", () => { message_category: "test", message_client_ip: "192.168.1.1", message_created_at: "2023-01-01T00:00:00Z", + message_esp_response: null, + message_esp_server_type: null, message_outgoing_ip: "10.0.0.1", message_recipient_mx_name: "mx.example.com", message_sender_email: "sender@example.com", @@ -45,6 +47,8 @@ describe("lib/api/resources/Suppressions: ", () => { message_category: "test", message_client_ip: "192.168.1.1", message_created_at: "2023-01-01T00:00:00Z", + message_esp_response: null, + message_esp_server_type: null, message_outgoing_ip: "10.0.0.1", message_recipient_mx_name: "mx.example.com", message_sender_email: "sender@example.com", @@ -61,6 +65,8 @@ describe("lib/api/resources/Suppressions: ", () => { message_category: "promotional", message_client_ip: "192.168.1.2", message_created_at: "2023-01-02T00:00:00Z", + message_esp_response: null, + message_esp_server_type: null, message_outgoing_ip: "10.0.0.2", message_recipient_mx_name: "mx.example.com", message_sender_email: "sender@example.com", @@ -72,6 +78,7 @@ describe("lib/api/resources/Suppressions: ", () => { describe("init: ", () => { it("initializes with all necessary params.", () => { expect(suppressionsAPI).toHaveProperty("getList"); + expect(suppressionsAPI).toHaveProperty("create"); expect(suppressionsAPI).toHaveProperty("delete"); }); }); @@ -153,6 +160,64 @@ describe("lib/api/resources/Suppressions: ", () => { }); }); + describe("create(): ", () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/suppressions`; + + it("sends a flat body and returns the wrapped suppression.", async () => { + const params = { + email: "test@example.com", + domain_id: 12345, + sending_stream: "transactional" as const, + }; + const expectedResponse = { data: mockSuppression }; + + expect.assertions(2); + + mock.onPost(endpoint).reply(201, expectedResponse); + const result = await suppressionsAPI.create(params); + + expect(JSON.parse(mock.history.post[0].data)).toEqual(params); + expect(result).toEqual(expectedResponse); + }); + + it("sends the optional type when provided.", async () => { + const params = { + email: "test@example.com", + domain_id: 12345, + sending_stream: "bulk" as const, + type: "spam complaint" as const, + }; + + expect.assertions(1); + + mock.onPost(endpoint).reply(201, { data: mockSuppression }); + await suppressionsAPI.create(params); + + expect(JSON.parse(mock.history.post[0].data)).toEqual(params); + }); + + it("fails with unauthorized error (401).", async () => { + const expectedErrorMessage = "Incorrect API token"; + + expect.assertions(2); + + mock.onPost(endpoint).reply(401, { error: expectedErrorMessage }); + + try { + await suppressionsAPI.create({ + email: "test@example.com", + domain_id: 12345, + sending_stream: "transactional", + }); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + describe("delete(): ", () => { const suppressionId = "1"; diff --git a/src/__tests__/lib/api/resources/TrackingOptOuts.test.ts b/src/__tests__/lib/api/resources/TrackingOptOuts.test.ts new file mode 100644 index 0000000..c92ee12 --- /dev/null +++ b/src/__tests__/lib/api/resources/TrackingOptOuts.test.ts @@ -0,0 +1,183 @@ +import axios from "axios"; +import AxiosMockAdapter from "axios-mock-adapter"; + +import TrackingOptOutsApi from "../../../../lib/api/resources/TrackingOptOuts"; +import handleSendingError from "../../../../lib/axios-logger"; +import MailtrapError from "../../../../lib/MailtrapError"; +import { TrackingOptOut } from "../../../../types/api/tracking-opt-outs"; + +import CONFIG from "../../../../config"; + +const { CLIENT_SETTINGS } = CONFIG; +const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; + +describe("lib/api/resources/TrackingOptOuts: ", () => { + let mock: AxiosMockAdapter; + const trackingOptOutsAPI = new TrackingOptOutsApi(axios); + const endpoint = `${GENERAL_ENDPOINT}/api/tracking_opt_outs`; + + const mockTrackingOptOut: TrackingOptOut = { + id: "64d71bf3-1276-417b-86e1-8e66f138acfe", + email: "tracked@example.com", + created_at: "2025-01-15T10:30:00Z", + domain_name: "example.com", + }; + + beforeAll(() => { + axios.interceptors.response.use( + (response) => response.data, + handleSendingError + ); + mock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + mock.reset(); + }); + + describe("class TrackingOptOutsApi(): ", () => { + describe("init: ", () => { + it("initializes with all necessary params.", () => { + expect(trackingOptOutsAPI).toHaveProperty("getList"); + expect(trackingOptOutsAPI).toHaveProperty("create"); + expect(trackingOptOutsAPI).toHaveProperty("delete"); + }); + }); + }); + + describe("getList(): ", () => { + it("returns the page and the cursor.", async () => { + const expectedResponse = { + data: [mockTrackingOptOut], + last_id: mockTrackingOptOut.id, + }; + + expect.assertions(2); + + mock.onGet(endpoint).reply(200, expectedResponse); + const result = await trackingOptOutsAPI.getList(); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(result).toEqual(expectedResponse); + }); + + it("returns a null cursor on the last page.", async () => { + expect.assertions(1); + + mock.onGet(endpoint).reply(200, { data: [], last_id: null }); + const result = await trackingOptOutsAPI.getList(); + + expect(result.last_id).toBeNull(); + }); + + it("passes the filters as query params.", async () => { + const params = { + email: "tracked@example.com", + start_time: "2025-01-01T00:00:00Z", + end_time: "2025-12-31T23:59:59Z", + last_id: mockTrackingOptOut.id, + }; + + expect.assertions(1); + + mock.onGet(endpoint).reply(200, { data: [], last_id: null }); + await trackingOptOutsAPI.getList(params); + + expect(mock.history.get[0].params).toEqual(params); + }); + + it("omits unset filters.", async () => { + expect.assertions(1); + + mock.onGet(endpoint).reply(200, { data: [], last_id: null }); + await trackingOptOutsAPI.getList({ email: "tracked@example.com" }); + + expect(mock.history.get[0].params).toEqual({ + email: "tracked@example.com", + }); + }); + + it("fails with unauthorized error (401).", async () => { + const expectedErrorMessage = "Incorrect API token"; + + expect.assertions(2); + + mock.onGet(endpoint).reply(401, { error: expectedErrorMessage }); + + try { + await trackingOptOutsAPI.getList(); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("create(): ", () => { + const params = { email: "tracked@example.com", domain_id: 12345 }; + + it("sends a flat body and returns the wrapped opt-out.", async () => { + const expectedResponse = { data: mockTrackingOptOut }; + + expect.assertions(2); + + mock.onPost(endpoint).reply(201, expectedResponse); + const result = await trackingOptOutsAPI.create(params); + + expect(JSON.parse(mock.history.post[0].data)).toEqual(params); + expect(result).toEqual(expectedResponse); + }); + + it("fails with forbidden error (403).", async () => { + const expectedErrorMessage = "Access forbidden"; + + expect.assertions(2); + + mock.onPost(endpoint).reply(403, { errors: expectedErrorMessage }); + + try { + await trackingOptOutsAPI.create(params); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("delete(): ", () => { + const deleteEndpoint = `${endpoint}/${mockTrackingOptOut.id}`; + + it("returns the deleted opt-out from the unwrapped response.", async () => { + expect.assertions(2); + + mock.onDelete(deleteEndpoint).reply(200, mockTrackingOptOut); + const result = await trackingOptOutsAPI.delete(mockTrackingOptOut.id); + + expect(mock.history.delete[0].url).toEqual(deleteEndpoint); + expect(result).toEqual(mockTrackingOptOut); + }); + + it("fails with not found error (404).", async () => { + const expectedErrorMessage = "Tracking opt-out not found"; + + expect.assertions(2); + + mock + .onDelete(deleteEndpoint) + .reply(404, { errors: expectedErrorMessage }); + + try { + await trackingOptOutsAPI.delete(mockTrackingOptOut.id); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); +}); diff --git a/src/lib/MailtrapClient.ts b/src/lib/MailtrapClient.ts index 80f20c9..7e4263e 100644 --- a/src/lib/MailtrapClient.ts +++ b/src/lib/MailtrapClient.ts @@ -21,6 +21,7 @@ import InboundAPI from "./api/Inbound"; import SendingDomainsBaseAPI from "./api/SendingDomains"; import StatsBaseAPI from "./api/Stats"; import SuppressionsBaseAPI from "./api/Suppressions"; +import TrackingOptOutsBaseAPI from "./api/TrackingOptOuts"; import OrganizationsBaseAPI from "./api/Organizations"; import TemplatesBaseAPI from "./api/Templates"; import TestingAPI from "./api/Testing"; @@ -260,6 +261,13 @@ export default class MailtrapClient { return new EmailCampaignsBaseAPI(this.axios); } + /** + * Getter for Tracking Opt-outs API. + */ + get trackingOptOuts() { + return new TrackingOptOutsBaseAPI(this.axios); + } + /** * Getter for Company Info API. */ diff --git a/src/lib/api/Suppressions.ts b/src/lib/api/Suppressions.ts index 48027cf..0c90a5a 100644 --- a/src/lib/api/Suppressions.ts +++ b/src/lib/api/Suppressions.ts @@ -5,11 +5,14 @@ import SuppressionsApi from "./resources/Suppressions"; export default class SuppressionsBaseAPI { public getList: SuppressionsApi["getList"]; + public create: SuppressionsApi["create"]; + public delete: SuppressionsApi["delete"]; constructor(client: AxiosInstance, accountId: number) { const suppressions = new SuppressionsApi(client, accountId); this.getList = suppressions.getList.bind(suppressions); + this.create = suppressions.create.bind(suppressions); this.delete = suppressions.delete.bind(suppressions); } } diff --git a/src/lib/api/TrackingOptOuts.ts b/src/lib/api/TrackingOptOuts.ts new file mode 100644 index 0000000..39165e5 --- /dev/null +++ b/src/lib/api/TrackingOptOuts.ts @@ -0,0 +1,18 @@ +import { AxiosInstance } from "axios"; + +import TrackingOptOutsApi from "./resources/TrackingOptOuts"; + +export default class TrackingOptOutsBaseAPI { + public getList: TrackingOptOutsApi["getList"]; + + public create: TrackingOptOutsApi["create"]; + + public delete: TrackingOptOutsApi["delete"]; + + constructor(client: AxiosInstance) { + const trackingOptOuts = new TrackingOptOutsApi(client); + this.getList = trackingOptOuts.getList.bind(trackingOptOuts); + this.create = trackingOptOuts.create.bind(trackingOptOuts); + this.delete = trackingOptOuts.delete.bind(trackingOptOuts); + } +} diff --git a/src/lib/api/resources/Suppressions.ts b/src/lib/api/resources/Suppressions.ts index 3a76cf5..5d9e420 100644 --- a/src/lib/api/resources/Suppressions.ts +++ b/src/lib/api/resources/Suppressions.ts @@ -1,7 +1,12 @@ import { AxiosInstance } from "axios"; import CONFIG from "../../../config"; -import { ListOptions, Suppression } from "../../../types/api/suppressions"; +import { + CreateSuppressionParams, + CreateSuppressionResponse, + ListOptions, + Suppression, +} from "../../../types/api/suppressions"; const { CLIENT_SETTINGS } = CONFIG; const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; @@ -29,6 +34,17 @@ export default class SuppressionsApi { }); } + /** + * Add an email address to the account's suppression list. `type` defaults to + * `manual import` when omitted. + */ + public async create(params: CreateSuppressionParams) { + return this.client.post< + CreateSuppressionResponse, + CreateSuppressionResponse + >(this.suppressionsURL, params); + } + /** * Delete a suppression by ID. * Mailtrap will no longer prevent sending to this email unless it's recorded in suppressions again. diff --git a/src/lib/api/resources/TrackingOptOuts.ts b/src/lib/api/resources/TrackingOptOuts.ts new file mode 100644 index 0000000..93721b6 --- /dev/null +++ b/src/lib/api/resources/TrackingOptOuts.ts @@ -0,0 +1,63 @@ +import { AxiosInstance } from "axios"; + +import CONFIG from "../../../config"; +import { + CreateTrackingOptOutParams, + CreateTrackingOptOutResponse, + ListTrackingOptOutsParams, + ListTrackingOptOutsResponse, + TrackingOptOut, +} from "../../../types/api/tracking-opt-outs"; + +const { CLIENT_SETTINGS } = CONFIG; +const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; + +export default class TrackingOptOutsApi { + private client: AxiosInstance; + + private trackingOptOutsURL: string; + + constructor(client: AxiosInstance) { + this.client = client; + this.trackingOptOutsURL = `${GENERAL_ENDPOINT}/api/tracking_opt_outs`; + } + + /** + * List email addresses that have opted out of open and click tracking. + * The endpoint returns up to 1000 records per request; pass the previous + * response's `last_id` to fetch the next page. + */ + public async getList(params?: ListTrackingOptOutsParams) { + const query = { + ...(params?.email && { email: params.email }), + ...(params?.start_time && { start_time: params.start_time }), + ...(params?.end_time && { end_time: params.end_time }), + ...(params?.last_id && { last_id: params.last_id }), + }; + + return this.client.get< + ListTrackingOptOutsResponse, + ListTrackingOptOutsResponse + >(this.trackingOptOutsURL, { params: query }); + } + + /** + * Add an email address to the tracking opt-out list for a sending domain. + */ + public async create(params: CreateTrackingOptOutParams) { + return this.client.post< + CreateTrackingOptOutResponse, + CreateTrackingOptOutResponse + >(this.trackingOptOutsURL, params); + } + + /** + * Remove an email address from the tracking opt-out list so open and click + * tracking can apply again. + */ + public async delete(id: string) { + return this.client.delete( + `${this.trackingOptOutsURL}/${id}` + ); + } +} diff --git a/src/types/api/suppressions.ts b/src/types/api/suppressions.ts index e707739..e95a72b 100644 --- a/src/types/api/suppressions.ts +++ b/src/types/api/suppressions.ts @@ -9,6 +9,8 @@ export type Suppression = { message_category: string | null; message_client_ip: string | null; message_created_at: string | null; + message_esp_response: string | null; + message_esp_server_type: string | null; message_outgoing_ip: string | null; message_recipient_mx_name: string | null; message_sender_email: string | null; @@ -18,3 +20,14 @@ export type Suppression = { export type ListOptions = { email?: string; }; + +export type CreateSuppressionParams = { + email: string; + domain_id: number; + sending_stream: "transactional" | "bulk"; + type?: Suppression["type"]; +}; + +export type CreateSuppressionResponse = { + data: Suppression; +}; diff --git a/src/types/api/tracking-opt-outs.ts b/src/types/api/tracking-opt-outs.ts new file mode 100644 index 0000000..a49e304 --- /dev/null +++ b/src/types/api/tracking-opt-outs.ts @@ -0,0 +1,27 @@ +export type TrackingOptOut = { + id: string; + email: string; + created_at: string; + domain_name: string | null; +}; + +export type ListTrackingOptOutsParams = { + email?: string; + start_time?: string; + end_time?: string; + last_id?: string; +}; + +export type ListTrackingOptOutsResponse = { + data: TrackingOptOut[]; + last_id: string | null; +}; + +export type CreateTrackingOptOutParams = { + email: string; + domain_id: number; +}; + +export type CreateTrackingOptOutResponse = { + data: TrackingOptOut; +};