From c7e64faaa348079e1bebd092d22a629acf0dccaf Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Thu, 30 Jul 2026 13:32:42 +0200 Subject: [PATCH 1/2] MT-22401: Add Email Campaigns API to the Node.js SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decisions: - Request bodies are flat (no email_campaign wrapper) per the current OpenAPI contract - Single-object and stats responses keep the {data} envelope (Webhooks convention; the axios interceptor only unwraps the HTTP body) - delete() returns void — the API responds 204 No Content - Added the five lifecycle endpoints (start/schedule/cancel/terminate/reset) with schedule taking {datetime} - current_state typed as the 10-value enum; mailsend_domain_id is a string UUID; template_attributes shared between create and update with no template id --- README.md | 1 + examples/email-campaigns/everything.ts | 106 ++++ src/__tests__/lib/api/EmailCampaigns.test.ts | 25 + .../lib/api/resources/EmailCampaigns.test.ts | 593 ++++++++++++++++++ src/lib/MailtrapClient.ts | 10 + src/lib/api/EmailCampaigns.ts | 45 ++ src/lib/api/resources/EmailCampaigns.ts | 187 ++++++ src/types/api/email-campaigns.ts | 208 ++++++ 8 files changed, 1175 insertions(+) create mode 100644 examples/email-campaigns/everything.ts create mode 100644 src/__tests__/lib/api/EmailCampaigns.test.ts create mode 100644 src/__tests__/lib/api/resources/EmailCampaigns.test.ts create mode 100644 src/lib/api/EmailCampaigns.ts create mode 100644 src/lib/api/resources/EmailCampaigns.ts create mode 100644 src/types/api/email-campaigns.ts diff --git a/README.md b/README.md index c2d4d85..47c72e7 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,7 @@ Contact management: - Custom fields CRUD – [`contact-fields/everything.ts`](examples/contact-fields/everything.ts) - Import/Export – [`contact-imports/everything.ts`](examples/contact-imports/everything.ts), [`contact-exports/everything.ts`](examples/contact-exports/everything.ts) - Events – [`contact-events/everything.ts`](examples/contact-events/everything.ts) +- Email campaigns CRUD, lifecycle & stats – [`email-campaigns/everything.ts`](examples/email-campaigns/everything.ts) General API: diff --git a/examples/email-campaigns/everything.ts b/examples/email-campaigns/everything.ts new file mode 100644 index 0000000..eb8ba99 --- /dev/null +++ b/examples/email-campaigns/everything.ts @@ -0,0 +1,106 @@ +import { MailtrapClient } from "mailtrap"; + +const TOKEN = ""; +const ACCOUNT_ID = ""; +// UUID of a verified sending domain on the account (required to create a campaign). +const MAILSEND_DOMAIN_ID = ""; + +const client = new MailtrapClient({ + token: TOKEN, + accountId: Number(ACCOUNT_ID), +}); + +async function emailCampaignsFlow() { + try { + // List campaigns (newest first). The response is a `{ data, pagination }` + // envelope; pagination is page-token based. + const list = await client.emailCampaigns.getList({ + per_page: 50, + token: 1, + search: "spring", // filter by name + }); + console.log("Campaigns:", JSON.stringify(list.data, null, 2)); + console.log("Pagination:", JSON.stringify(list.pagination, null, 2)); + + // Create a campaign. It starts in the `draft` state. Single-campaign + // responses are wrapped in a `{ data }` envelope. + const created = await client.emailCampaigns.create({ + name: "Spring Sale", + mailsend_domain_id: MAILSEND_DOMAIN_ID, + from_display_name: "Acme Marketing", + from_local_part: "news", + reply_to: { + display_name: "Acme Support", + local_part: "support", + domain: "acme.com", + }, + template_attributes: { subject: "Spring is here — 30% off" }, + }); + console.log("Created campaign:", JSON.stringify(created.data, null, 2)); + + const campaignId = created.data.id; + + // Get a single campaign by ID. + const one = await client.emailCampaigns.get(campaignId); + console.log("One campaign:", JSON.stringify(one.data, null, 2)); + + // Update the campaign (PATCH — only the provided fields change). The + // template is edited in place; add the design and pick the audience via + // contact list/segment IDs. Sending can be throttled with `gradual` mode. + const updated = await client.emailCampaigns.update(campaignId, { + name: "Spring Sale (updated)", + template_attributes: { + subject: "Hi {{first_name}}, spring is here — 30% off", + body_html: + '

Hi {{first_name}}!

Unsubscribe

', + merge_tags: ["first_name"], + }, + contact_list_ids: [1], + delivery_mode: "gradual", + delivery_options: { emails_per_hour: 1000 }, + }); + console.log("Updated campaign:", JSON.stringify(updated.data, null, 2)); + + // Schedule the draft to send later. The time is reported back in + // `current_state_metadata.scheduled_at`. + const scheduled = await client.emailCampaigns.schedule(campaignId, { + datetime: "2026-06-01T09:00:00.000Z", + }); + console.log( + "Scheduled for:", + scheduled.data.current_state_metadata.scheduled_at + ); + + // Cancel the scheduled send — the campaign returns to `draft`. + // (`reset` also returns a scheduled campaign to `draft`.) + const cancelled = await client.emailCampaigns.cancel(campaignId); + console.log("State after cancel:", cancelled.data.current_state); + + // Start sending immediately. + const started = await client.emailCampaigns.start(campaignId); + console.log("State after start:", started.data.current_state); + + // Terminate the in-flight send. + const terminated = await client.emailCampaigns.terminate(campaignId); + console.log("State after terminate:", terminated.data.current_state); + + // Get aggregated stats for the campaign, optionally narrowed to a date + // window. Counts and rates are all `0` until the campaign has been started. + const stats = await client.emailCampaigns.getStats(campaignId, { + start_date: "2026-05-01", + end_date: "2026-05-31", + }); + console.log("Campaign stats:", JSON.stringify(stats.data, null, 2)); + + // Delete the campaign. Returns nothing (204 No Content). + await client.emailCampaigns.delete(campaignId); + console.log("Deleted campaign:", campaignId); + } catch (error) { + console.error( + "Error in emailCampaignsFlow:", + error instanceof Error ? error.message : String(error) + ); + } +} + +emailCampaignsFlow(); diff --git a/src/__tests__/lib/api/EmailCampaigns.test.ts b/src/__tests__/lib/api/EmailCampaigns.test.ts new file mode 100644 index 0000000..eb5a6d9 --- /dev/null +++ b/src/__tests__/lib/api/EmailCampaigns.test.ts @@ -0,0 +1,25 @@ +import axios from "axios"; + +import EmailCampaignsBaseAPI from "../../../lib/api/EmailCampaigns"; + +describe("lib/api/EmailCampaigns: ", () => { + const emailCampaignsAPI = new EmailCampaignsBaseAPI(axios); + + describe("class EmailCampaignsBaseAPI(): ", () => { + describe("init: ", () => { + it("initializes with all necessary params.", () => { + expect(emailCampaignsAPI).toHaveProperty("getList"); + expect(emailCampaignsAPI).toHaveProperty("create"); + expect(emailCampaignsAPI).toHaveProperty("get"); + expect(emailCampaignsAPI).toHaveProperty("update"); + expect(emailCampaignsAPI).toHaveProperty("delete"); + expect(emailCampaignsAPI).toHaveProperty("start"); + expect(emailCampaignsAPI).toHaveProperty("schedule"); + expect(emailCampaignsAPI).toHaveProperty("cancel"); + expect(emailCampaignsAPI).toHaveProperty("terminate"); + expect(emailCampaignsAPI).toHaveProperty("reset"); + expect(emailCampaignsAPI).toHaveProperty("getStats"); + }); + }); + }); +}); diff --git a/src/__tests__/lib/api/resources/EmailCampaigns.test.ts b/src/__tests__/lib/api/resources/EmailCampaigns.test.ts new file mode 100644 index 0000000..0d73533 --- /dev/null +++ b/src/__tests__/lib/api/resources/EmailCampaigns.test.ts @@ -0,0 +1,593 @@ +import axios from "axios"; +import AxiosMockAdapter from "axios-mock-adapter"; + +import EmailCampaignsApi from "../../../../lib/api/resources/EmailCampaigns"; +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/EmailCampaigns: ", () => { + let mock: AxiosMockAdapter; + const emailCampaignsAPI = new EmailCampaignsApi(axios); + const endpoint = `${GENERAL_ENDPOINT}/api/email_campaigns`; + + const campaign = { + id: 4567, + type: "ContactsEmailCampaign", + mailsend_domain_id: "d2313359-acb4-4b87-bce6-f5774f6a1e37", + mailsend_domain_name: "acme.com", + name: "Spring Sale", + from_local_part: "news", + from_display_name: "Acme Marketing", + reply_to: { + display_name: "Acme Support", + local_part: "support", + domain: "acme.com", + }, + current_state: "draft", + current_state_metadata: {}, + created_at: "2026-05-01T10:15:00.000Z", + updated_at: "2026-05-02T09:00:00.000Z", + last_started_at: null, + recipient_total_count: null, + contact_list_ids: [55, 56], + contact_segment_ids: [12], + delivery_mode: "rapid", + delivery_options: { emails_per_hour: null }, + template: { + id: 789, + subject: "Spring is here — 30% off", + merge_tags: ["first_name"], + body_html: + '

Hi {{first_name}}!

Unsubscribe

', + body_text: null, + }, + }; + + describe("class EmailCampaignsApi(): ", () => { + describe("init: ", () => { + it("initializes with all necessary params.", () => { + expect(emailCampaignsAPI).toHaveProperty("getList"); + expect(emailCampaignsAPI).toHaveProperty("create"); + expect(emailCampaignsAPI).toHaveProperty("get"); + expect(emailCampaignsAPI).toHaveProperty("update"); + expect(emailCampaignsAPI).toHaveProperty("delete"); + expect(emailCampaignsAPI).toHaveProperty("start"); + expect(emailCampaignsAPI).toHaveProperty("schedule"); + expect(emailCampaignsAPI).toHaveProperty("cancel"); + expect(emailCampaignsAPI).toHaveProperty("terminate"); + expect(emailCampaignsAPI).toHaveProperty("reset"); + expect(emailCampaignsAPI).toHaveProperty("getStats"); + }); + }); + }); + + 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("getList(): ", () => { + // List items omit the template bodies. + const listItem = { + ...campaign, + template: { + id: 789, + subject: "Spring is here — 30% off", + merge_tags: ["first_name"], + }, + }; + + const responseData = { + data: [listItem], + pagination: { + token: 1, + prev_token: null, + next_token: 2, + first_url: `${endpoint}?per_page=50&token=1`, + prev_url: null, + current_url: `${endpoint}?per_page=50&token=1`, + next_url: `${endpoint}?per_page=50&token=2`, + }, + }; + + it("gets the list of email campaigns wrapped in a data/pagination envelope.", async () => { + expect.assertions(2); + + mock.onGet(endpoint).reply(200, responseData); + const result = await emailCampaignsAPI.getList(); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(result).toEqual(responseData); + }); + + it("serializes per_page, search and token as query params.", async () => { + expect.assertions(2); + + const params = { per_page: 25, search: "spring", token: 2 }; + mock.onGet(endpoint, { params }).reply(200, responseData); + await emailCampaignsAPI.getList(params); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(mock.history.get[0].params).toEqual(params); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = "Account access forbidden"; + + expect.assertions(2); + + mock.onGet(endpoint).reply(403, { errors: "Account access forbidden" }); + + try { + await emailCampaignsAPI.getList(); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("create(): ", () => { + const params = { + name: "Spring Sale", + mailsend_domain_id: "d2313359-acb4-4b87-bce6-f5774f6a1e37", + from_display_name: "Acme Marketing", + from_local_part: "news", + reply_to: { + display_name: "Acme Support", + local_part: "support", + domain: "acme.com", + }, + template_attributes: { + subject: "Spring is here — 30% off", + body_html: + '

Hi {{first_name}}!

Unsubscribe

', + merge_tags: ["first_name"], + }, + delivery_mode: "gradual" as const, + delivery_options: { emails_per_hour: 1000 }, + contact_list_ids: [55, 56], + contact_segment_ids: [12], + }; + + const responseData = { data: campaign }; + + it("creates an email campaign with a flat body and returns the data envelope.", async () => { + expect.assertions(3); + + mock.onPost(endpoint, params).reply(201, responseData); + const result = await emailCampaignsAPI.create(params); + + expect(mock.history.post[0].url).toEqual(endpoint); + expect(JSON.parse(mock.history.post[0].data)).toEqual(params); + expect(result).toEqual(responseData); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = "mailsend_domain_id: can't be blank"; + + expect.assertions(2); + + mock + .onPost(endpoint) + .reply(422, { errors: { mailsend_domain_id: ["can't be blank"] } }); + + try { + await emailCampaignsAPI.create(params); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("get(): ", () => { + const campaignId = 4567; + const getEndpoint = `${endpoint}/${campaignId}`; + + it("gets a campaign by id wrapped in a data envelope.", async () => { + const responseData = { data: campaign }; + + expect.assertions(2); + + mock.onGet(getEndpoint).reply(200, responseData); + const result = await emailCampaignsAPI.get(campaignId); + + expect(mock.history.get[0].url).toEqual(getEndpoint); + expect(result).toEqual(responseData); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = "Not Found"; + + expect.assertions(2); + + mock.onGet(getEndpoint).reply(404, { error: "Not Found" }); + + try { + await emailCampaignsAPI.get(campaignId); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("update(): ", () => { + const campaignId = 4567; + const updateEndpoint = `${endpoint}/${campaignId}`; + const params = { + name: "Spring Sale (updated)", + delivery_mode: "gradual" as const, + delivery_options: { emails_per_hour: 1000 }, + template_attributes: { + subject: "New subject", + body_text: "Hi {{first_name}}! Unsubscribe: __unsubscribe_url__", + }, + contact_list_ids: [55], + }; + + const responseData = { + data: { + ...campaign, + name: "Spring Sale (updated)", + delivery_mode: "gradual", + delivery_options: { emails_per_hour: 1000 }, + contact_list_ids: [55], + }, + }; + + it("updates a campaign with a flat PATCH body and returns the data envelope.", async () => { + expect.assertions(3); + + mock.onPatch(updateEndpoint, params).reply(200, responseData); + const result = await emailCampaignsAPI.update(campaignId, params); + + expect(mock.history.patch[0].url).toEqual(updateEndpoint); + expect(JSON.parse(mock.history.patch[0].data)).toEqual(params); + expect(result).toEqual(responseData); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = "from_local_part: can't be blank"; + + expect.assertions(2); + + mock + .onPatch(updateEndpoint) + .reply(422, { errors: { from_local_part: ["can't be blank"] } }); + + try { + await emailCampaignsAPI.update(campaignId, params); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("delete(): ", () => { + const campaignId = 4567; + const deleteEndpoint = `${endpoint}/${campaignId}`; + + it("deletes a campaign, returning nothing (204 No Content).", async () => { + expect.assertions(2); + + mock.onDelete(deleteEndpoint).reply(204); + const result = await emailCampaignsAPI.delete(campaignId); + + expect(mock.history.delete[0].url).toEqual(deleteEndpoint); + expect(result).toBeUndefined(); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = "campaign is sending"; + + expect.assertions(2); + + mock + .onDelete(deleteEndpoint) + .reply(422, { errors: { base: ["campaign is sending"] } }); + + try { + await emailCampaignsAPI.delete(campaignId); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("start(): ", () => { + const campaignId = 4567; + const startEndpoint = `${endpoint}/${campaignId}/start`; + + it("starts a draft campaign and returns the data envelope.", async () => { + const responseData = { data: { ...campaign, current_state: "started" } }; + + expect.assertions(2); + + mock.onPost(startEndpoint).reply(200, responseData); + const result = await emailCampaignsAPI.start(campaignId); + + expect(mock.history.post[0].url).toEqual(startEndpoint); + expect(result).toEqual(responseData); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = "Campaign design can't be blank"; + + expect.assertions(2); + + mock + .onPost(startEndpoint) + .reply(422, { errors: ["Campaign design can't be blank"] }); + + try { + await emailCampaignsAPI.start(campaignId); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("schedule(): ", () => { + const campaignId = 4567; + const scheduleEndpoint = `${endpoint}/${campaignId}/schedule`; + const params = { datetime: "2026-06-01T09:00:00.000Z" }; + + it("schedules a draft campaign and returns the data envelope.", async () => { + const responseData = { + data: { + ...campaign, + current_state: "scheduled", + current_state_metadata: { + scheduled_at: "2026-06-01T09:00:00.000Z", + }, + }, + }; + + expect.assertions(3); + + mock.onPost(scheduleEndpoint, params).reply(200, responseData); + const result = await emailCampaignsAPI.schedule(campaignId, params); + + expect(mock.history.post[0].url).toEqual(scheduleEndpoint); + expect(JSON.parse(mock.history.post[0].data)).toEqual(params); + expect(result).toEqual(responseData); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = + "Cannot transition from 'started' to 'scheduled'"; + + expect.assertions(2); + + mock.onPost(scheduleEndpoint).reply(422, { + errors: "Cannot transition from 'started' to 'scheduled'", + }); + + try { + await emailCampaignsAPI.schedule(campaignId, params); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("cancel(): ", () => { + const campaignId = 4567; + const cancelEndpoint = `${endpoint}/${campaignId}/cancel`; + + it("cancels a scheduled campaign and returns the data envelope.", async () => { + const responseData = { data: campaign }; + + expect.assertions(2); + + mock.onPost(cancelEndpoint).reply(200, responseData); + const result = await emailCampaignsAPI.cancel(campaignId); + + expect(mock.history.post[0].url).toEqual(cancelEndpoint); + expect(result).toEqual(responseData); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = "Campaign is not scheduled"; + + expect.assertions(2); + + mock + .onPost(cancelEndpoint) + .reply(422, { errors: "Campaign is not scheduled" }); + + try { + await emailCampaignsAPI.cancel(campaignId); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("terminate(): ", () => { + const campaignId = 4567; + const terminateEndpoint = `${endpoint}/${campaignId}/terminate`; + + it("terminates a sending campaign and returns the data envelope.", async () => { + const responseData = { + data: { ...campaign, current_state: "terminating" }, + }; + + expect.assertions(2); + + mock.onPost(terminateEndpoint).reply(200, responseData); + const result = await emailCampaignsAPI.terminate(campaignId); + + expect(mock.history.post[0].url).toEqual(terminateEndpoint); + expect(result).toEqual(responseData); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = + "Cannot transition from 'draft' to 'terminating'"; + + expect.assertions(2); + + mock.onPost(terminateEndpoint).reply(422, { + errors: "Cannot transition from 'draft' to 'terminating'", + }); + + try { + await emailCampaignsAPI.terminate(campaignId); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("reset(): ", () => { + const campaignId = 4567; + const resetEndpoint = `${endpoint}/${campaignId}/reset`; + + it("resets a scheduled campaign to draft and returns the data envelope.", async () => { + const responseData = { data: campaign }; + + expect.assertions(2); + + mock.onPost(resetEndpoint).reply(200, responseData); + const result = await emailCampaignsAPI.reset(campaignId); + + expect(mock.history.post[0].url).toEqual(resetEndpoint); + expect(result).toEqual(responseData); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = "Campaign is not scheduled"; + + expect.assertions(2); + + mock + .onPost(resetEndpoint) + .reply(422, { errors: "Campaign is not scheduled" }); + + try { + await emailCampaignsAPI.reset(campaignId); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + }); + + describe("getStats(): ", () => { + const campaignId = 4567; + const statsEndpoint = `${endpoint}/${campaignId}/stats`; + + const responseData = { + data: { + delivery_count: 1450, + open_count: 820, + click_count: 310, + bounce_count: 30, + unsubscription_count: 12, + sent_count: 1500, + spam_count: 5, + message_count: 1500, + reject_count: 20, + delivery_rate: 0.9667, + open_rate: 0.5655, + click_rate: 0.2138, + bounce_rate: 0.02, + spam_rate: 0.0033, + unsubscription_rate: 0.0083, + }, + }; + + it("gets campaign stats wrapped in a data envelope.", async () => { + expect.assertions(2); + + mock.onGet(statsEndpoint).reply(200, responseData); + const result = await emailCampaignsAPI.getStats(campaignId); + + expect(mock.history.get[0].url).toEqual(statsEndpoint); + expect(result).toEqual(responseData); + }); + + it("serializes start_date and end_date as query params.", async () => { + expect.assertions(2); + + const params = { start_date: "2026-05-01", end_date: "2026-05-31" }; + mock.onGet(statsEndpoint, { params }).reply(200, responseData); + await emailCampaignsAPI.getStats(campaignId, params); + + expect(mock.history.get[0].url).toEqual(statsEndpoint); + expect(mock.history.get[0].params).toEqual(params); + }); + + it("fails with error.", async () => { + const expectedErrorMessage = "Not Found"; + + expect.assertions(2); + + mock.onGet(statsEndpoint).reply(404, { error: "Not Found" }); + + try { + await emailCampaignsAPI.getStats(campaignId); + } 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 0d574a2..9112e8a 100644 --- a/src/lib/MailtrapClient.ts +++ b/src/lib/MailtrapClient.ts @@ -13,6 +13,7 @@ import ContactFieldsBaseAPI from "./api/ContactFields"; import ContactImportsBaseAPI from "./api/ContactImports"; import ContactListsBaseAPI from "./api/ContactLists"; import ContactsBaseAPI from "./api/Contacts"; +import EmailCampaignsBaseAPI from "./api/EmailCampaigns"; import EmailLogsBaseAPI from "./api/EmailLogs"; import GeneralAPI from "./api/General"; import InboundAPI from "./api/Inbound"; @@ -251,6 +252,15 @@ export default class MailtrapClient { return new WebhooksBaseAPI(this.axios, accountId); } + /** + * Getter for Email Campaigns API. The endpoint is token-scoped, but + * `accountId` is still validated for consistency with sibling resources. + */ + get emailCampaigns() { + this.validateAccountIdPresence(); + return new EmailCampaignsBaseAPI(this.axios); + } + /** * Getter for Organizations API. Requires `organizationId` in config. */ diff --git a/src/lib/api/EmailCampaigns.ts b/src/lib/api/EmailCampaigns.ts new file mode 100644 index 0000000..c7a4a16 --- /dev/null +++ b/src/lib/api/EmailCampaigns.ts @@ -0,0 +1,45 @@ +import { AxiosInstance } from "axios"; + +import EmailCampaignsApi from "./resources/EmailCampaigns"; + +export default class EmailCampaignsBaseAPI { + private client: AxiosInstance; + + public getList: EmailCampaignsApi["getList"]; + + public create: EmailCampaignsApi["create"]; + + public get: EmailCampaignsApi["get"]; + + public update: EmailCampaignsApi["update"]; + + public delete: EmailCampaignsApi["delete"]; + + public start: EmailCampaignsApi["start"]; + + public schedule: EmailCampaignsApi["schedule"]; + + public cancel: EmailCampaignsApi["cancel"]; + + public terminate: EmailCampaignsApi["terminate"]; + + public reset: EmailCampaignsApi["reset"]; + + public getStats: EmailCampaignsApi["getStats"]; + + constructor(client: AxiosInstance) { + this.client = client; + const emailCampaigns = new EmailCampaignsApi(this.client); + this.getList = emailCampaigns.getList.bind(emailCampaigns); + this.create = emailCampaigns.create.bind(emailCampaigns); + this.get = emailCampaigns.get.bind(emailCampaigns); + this.update = emailCampaigns.update.bind(emailCampaigns); + this.delete = emailCampaigns.delete.bind(emailCampaigns); + this.start = emailCampaigns.start.bind(emailCampaigns); + this.schedule = emailCampaigns.schedule.bind(emailCampaigns); + this.cancel = emailCampaigns.cancel.bind(emailCampaigns); + this.terminate = emailCampaigns.terminate.bind(emailCampaigns); + this.reset = emailCampaigns.reset.bind(emailCampaigns); + this.getStats = emailCampaigns.getStats.bind(emailCampaigns); + } +} diff --git a/src/lib/api/resources/EmailCampaigns.ts b/src/lib/api/resources/EmailCampaigns.ts new file mode 100644 index 0000000..1c9c875 --- /dev/null +++ b/src/lib/api/resources/EmailCampaigns.ts @@ -0,0 +1,187 @@ +import { AxiosInstance } from "axios"; + +import CONFIG from "../../../config"; +import { + CreateEmailCampaignParams, + CreateEmailCampaignResponse, + DeleteEmailCampaignResponse, + EmailCampaignActionResponse, + GetEmailCampaignResponse, + GetEmailCampaignStatsParams, + GetEmailCampaignStatsResponse, + ListEmailCampaignsParams, + ListEmailCampaignsResponse, + ScheduleEmailCampaignParams, + UpdateEmailCampaignParams, + UpdateEmailCampaignResponse, +} from "../../../types/api/email-campaigns"; + +const { CLIENT_SETTINGS } = CONFIG; +const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; + +export default class EmailCampaignsApi { + private client: AxiosInstance; + + private emailCampaignsURL: string; + + constructor(client: AxiosInstance) { + this.client = client; + // The Email Campaigns API is token-scoped, not account-scoped: the account + // is resolved from the API token server-side, so the path is bare. + this.emailCampaignsURL = `${GENERAL_ENDPOINT}/api/email_campaigns`; + } + + /** + * Lists the account's email campaigns, newest first. The result is wrapped in + * a `{ data, pagination }` envelope; pagination is page-token based. + */ + public async getList(params?: ListEmailCampaignsParams) { + const url = this.emailCampaignsURL; + const query = { + ...(params?.per_page !== undefined && { per_page: params.per_page }), + ...(params?.search !== undefined && { search: params.search }), + ...(params?.token !== undefined && { token: params.token }), + }; + + return this.client.get< + ListEmailCampaignsResponse, + ListEmailCampaignsResponse + >(url, { params: query }); + } + + /** + * Create a new email campaign in the `draft` state. The campaign must + * reference an existing sending domain via `mailsend_domain_id` and include + * a template `subject` within `template_attributes`. + */ + public async create(params: CreateEmailCampaignParams) { + const url = this.emailCampaignsURL; + + return this.client.post< + CreateEmailCampaignResponse, + CreateEmailCampaignResponse + >(url, params); + } + + /** + * Get a single email campaign by ID. + */ + public async get(id: number) { + const url = `${this.emailCampaignsURL}/${id}`; + + return this.client.get( + url + ); + } + + /** + * Update an existing `draft` email campaign. Only the provided attributes + * are changed (PATCH semantics). + */ + public async update(id: number, params: UpdateEmailCampaignParams) { + const url = `${this.emailCampaignsURL}/${id}`; + + return this.client.patch< + UpdateEmailCampaignResponse, + UpdateEmailCampaignResponse + >(url, params); + } + + /** + * Delete an email campaign by ID. The campaign must not be in a sending + * state. Returns nothing (204 No Content). + */ + public async delete(id: number) { + const url = `${this.emailCampaignsURL}/${id}`; + + return this.client.delete< + DeleteEmailCampaignResponse, + DeleteEmailCampaignResponse + >(url); + } + + /** + * Start sending a `draft` campaign immediately. Runs full sending validation; + * on failure the request fails with `422` and the campaign stays a `draft`. + */ + public async start(id: number) { + const url = `${this.emailCampaignsURL}/${id}/start`; + + return this.client.post< + EmailCampaignActionResponse, + EmailCampaignActionResponse + >(url); + } + + /** + * Schedule a `draft` campaign to start sending at a future time. After + * scheduling, the time is reported back in + * `current_state_metadata.scheduled_at`. + */ + public async schedule(id: number, params: ScheduleEmailCampaignParams) { + const url = `${this.emailCampaignsURL}/${id}/schedule`; + + return this.client.post< + EmailCampaignActionResponse, + EmailCampaignActionResponse + >(url, params); + } + + /** + * Cancel a `scheduled` campaign, removing the pending send job and returning + * the campaign to the `draft` state. + */ + public async cancel(id: number) { + const url = `${this.emailCampaignsURL}/${id}/cancel`; + + return this.client.post< + EmailCampaignActionResponse, + EmailCampaignActionResponse + >(url); + } + + /** + * Terminate a campaign that is currently sending (`started`, `queued`, or + * `paused`), aborting the in-flight send. + */ + public async terminate(id: number) { + const url = `${this.emailCampaignsURL}/${id}/terminate`; + + return this.client.post< + EmailCampaignActionResponse, + EmailCampaignActionResponse + >(url); + } + + /** + * Reset a `scheduled` campaign back to the `draft` state. + */ + public async reset(id: number) { + const url = `${this.emailCampaignsURL}/${id}/reset`; + + return this.client.post< + EmailCampaignActionResponse, + EmailCampaignActionResponse + >(url); + } + + /** + * Get aggregated performance statistics for a single campaign. If the + * campaign has never been started, all counts and rates are returned as `0`. + * Use `start_date`/`end_date` (`YYYY-MM-DD`) to narrow the aggregation window. + */ + public async getStats(id: number, params?: GetEmailCampaignStatsParams) { + const url = `${this.emailCampaignsURL}/${id}/stats`; + const query = { + ...(params?.start_date !== undefined && { + start_date: params.start_date, + }), + ...(params?.end_date !== undefined && { end_date: params.end_date }), + }; + + return this.client.get< + GetEmailCampaignStatsResponse, + GetEmailCampaignStatsResponse + >(url, { params: query }); + } +} diff --git a/src/types/api/email-campaigns.ts b/src/types/api/email-campaigns.ts new file mode 100644 index 0000000..0811d77 --- /dev/null +++ b/src/types/api/email-campaigns.ts @@ -0,0 +1,208 @@ +export type DeliveryMode = "rapid" | "gradual"; + +export type CampaignState = + | "draft" + | "scheduled" + | "started" + | "queued" + | "paused" + | "terminating" + | "under_review" + | "finished" + | "failed" + | "failed_immediately"; + +export type CampaignType = "ContactsEmailCampaign" | "RecipientsEmailCampaign"; + +export type ReplyTo = { + display_name?: string; + local_part?: string; + domain?: string; +}; + +export type DeliveryOptions = { + /** Applies when `delivery_mode` is `gradual`. */ + emails_per_hour?: number | null; +}; + +/** + * Inline email template — the campaign's subject and design. The campaign's + * template is always edited in place (there is no template `id` to pass); + * updates are partial — only the provided sub-fields change. + */ +export type TemplateAttributes = { + /** Email subject line. Required when creating a campaign. */ + subject?: string; + /** + * HTML body (the design). Optional for a draft; required before the campaign + * can be scheduled or started. Include an unsubscribe link via an anchor + * whose `href` contains the `__unsubscribe_url__` placeholder. + */ + body_html?: string; + body_text?: string | null; + /** Bare names of the merge tags used in the subject/body, e.g. `["first_name"]`. */ + merge_tags?: string[]; +}; + +export type CampaignStateError = { + message: string; + rcpt_index: number; +}; + +export type CurrentStateMetadata = { + reason?: string; + /** Last error message recorded for a failed campaign. */ + error?: string; + /** Per-recipient errors recorded when sending failed. */ + errors?: CampaignStateError[]; + /** When the campaign is scheduled to send. Present in the `scheduled` state. */ + scheduled_at?: string; +}; + +/** + * Aggregated campaign performance metrics. Counts and rates are `0` when the + * campaign has not been started. + */ +export type EmailCampaignStats = { + delivery_count: number; + open_count: number; + click_count: number; + bounce_count: number; + unsubscription_count: number; + sent_count: number; + spam_count: number; + message_count: number; + reject_count: number; + delivery_rate: number; + open_rate: number; + click_rate: number; + bounce_rate: number; + spam_rate: number; + unsubscription_rate: number; +}; + +export type CampaignTemplate = { + id: number; + subject: string; + merge_tags: string[]; + /** Returned only on single-campaign responses; the list endpoint omits it. */ + body_html?: string | null; + /** Returned only on single-campaign responses; the list endpoint omits it. */ + body_text?: string | null; +}; + +export type EmailCampaign = { + id: number; + type: CampaignType; + mailsend_domain_id: string; + mailsend_domain_name: string; + name: string; + from_local_part: string; + from_display_name: string; + reply_to: ReplyTo; + current_state: CampaignState; + current_state_metadata: CurrentStateMetadata; + created_at: string; + updated_at: string; + last_started_at: string | null; + /** Present only when the campaign has been started. */ + last_started_at_date?: string; + /** `null` until the audience is resolved. */ + recipient_total_count: number | null; + contact_list_ids: number[]; + contact_segment_ids: number[]; + delivery_mode: DeliveryMode; + delivery_options: DeliveryOptions; + template: CampaignTemplate; +}; + +export type Pagination = { + token: number; + prev_token: number | null; + next_token: number | null; + first_url: string; + prev_url: string | null; + current_url: string; + next_url: string | null; +}; + +export type ListEmailCampaignsParams = { + /** Number of campaigns per page. Maximum 100, defaults to 50. */ + per_page?: number; + /** Filter campaigns by name. */ + search?: string; + /** Page number to retrieve (page-token pagination). Defaults to 1. */ + token?: number; +}; + +export type CreateEmailCampaignParams = { + name: string; + /** UUID of the verified sending domain used for the campaign. */ + mailsend_domain_id: string; + from_local_part: string; + from_display_name?: string; + reply_to?: ReplyTo; + template_attributes: TemplateAttributes & { subject: string }; + delivery_mode?: DeliveryMode; + delivery_options?: DeliveryOptions; + contact_list_ids?: number[]; + contact_segment_ids?: number[]; +}; + +export type UpdateEmailCampaignParams = { + name?: string; + /** UUID of the verified sending domain used for the campaign. */ + mailsend_domain_id?: string; + from_local_part?: string; + from_display_name?: string; + reply_to?: ReplyTo; + template_attributes?: TemplateAttributes; + delivery_mode?: DeliveryMode; + delivery_options?: DeliveryOptions; + contact_list_ids?: number[]; + contact_segment_ids?: number[]; +}; + +export type ScheduleEmailCampaignParams = { + /** + * When to send the campaign (ISO 8601). Must be in the future and no more + * than 1 month ahead. + */ + datetime: string; +}; + +export type GetEmailCampaignStatsParams = { + /** Start of the aggregation window (inclusive), `YYYY-MM-DD`. */ + start_date?: string; + /** End of the aggregation window (inclusive), `YYYY-MM-DD`. */ + end_date?: string; +}; + +export type ListEmailCampaignsResponse = { + data: EmailCampaign[]; + pagination: Pagination; +}; + +export type GetEmailCampaignResponse = { + data: EmailCampaign; +}; + +export type CreateEmailCampaignResponse = { + data: EmailCampaign; +}; + +export type UpdateEmailCampaignResponse = { + data: EmailCampaign; +}; + +/** Delete returns `204 No Content` — there is no response body. */ +export type DeleteEmailCampaignResponse = void; + +/** Lifecycle actions (start/schedule/cancel/terminate/reset) return the updated campaign. */ +export type EmailCampaignActionResponse = { + data: EmailCampaign; +}; + +export type GetEmailCampaignStatsResponse = { + data: EmailCampaignStats; +}; From ac3adfd57760a4870f2940cae49fc13c20994af5 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Fri, 31 Jul 2026 11:25:18 +0200 Subject: [PATCH 2/2] MT-22401: Drop accountId requirement from emailCampaigns getter Decisions: - The endpoint is token-scoped and resolves the account server-side, so requiring accountId made a valid token-only client unusable (matches the inbound getter precedent and the fix applied to the Python SDK) - Example no longer configures accountId --- examples/email-campaigns/everything.ts | 7 ++----- src/lib/MailtrapClient.ts | 4 +--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/examples/email-campaigns/everything.ts b/examples/email-campaigns/everything.ts index eb8ba99..eb417ce 100644 --- a/examples/email-campaigns/everything.ts +++ b/examples/email-campaigns/everything.ts @@ -1,14 +1,11 @@ import { MailtrapClient } from "mailtrap"; const TOKEN = ""; -const ACCOUNT_ID = ""; // UUID of a verified sending domain on the account (required to create a campaign). const MAILSEND_DOMAIN_ID = ""; -const client = new MailtrapClient({ - token: TOKEN, - accountId: Number(ACCOUNT_ID), -}); +// The Email Campaigns API is token-scoped — no `accountId` is needed. +const client = new MailtrapClient({ token: TOKEN }); async function emailCampaignsFlow() { try { diff --git a/src/lib/MailtrapClient.ts b/src/lib/MailtrapClient.ts index 9112e8a..ee272d3 100644 --- a/src/lib/MailtrapClient.ts +++ b/src/lib/MailtrapClient.ts @@ -253,11 +253,9 @@ export default class MailtrapClient { } /** - * Getter for Email Campaigns API. The endpoint is token-scoped, but - * `accountId` is still validated for consistency with sibling resources. + * Getter for Email Campaigns API. Scoped to the token's account, so no accountId is required. */ get emailCampaigns() { - this.validateAccountIdPresence(); return new EmailCampaignsBaseAPI(this.axios); }