diff --git a/README.md b/README.md index edecd5a..e7936e2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Currently, with this SDK you can: - Contacts CRUD - Lists CRUD - Contact Fields CRUD + - Contact Imports (bulk import contacts) - General - Templates CRUD - Suppressions management (find and delete) @@ -126,6 +127,10 @@ Refer to the [`examples`](examples) folder for the source code of this and other - [Contact Fields](examples/contact-fields/everything.ts) +### Contact Imports API + +- [Contact Imports](examples/contact-imports/everything.ts) + ### Sending API - [Advanced](examples/sending/everything.ts) diff --git a/examples/contact-imports/everything.ts b/examples/contact-imports/everything.ts new file mode 100644 index 0000000..c1aecf4 --- /dev/null +++ b/examples/contact-imports/everything.ts @@ -0,0 +1,36 @@ +import { MailtrapClient } from "mailtrap"; + +const TOKEN = ""; +const ACCOUNT_ID = ""; + +const client = new MailtrapClient({ + token: TOKEN, + accountId: ACCOUNT_ID +}); + +async function runContactImportsFlow() { + const importData = { + contacts: [ + { + email: "customer1@example.com" + }, + { + email: "customer2@example.com" + } + ] + }; + + try { + // Create import + const response = await client.contactImports.create(importData); + console.log("Import created:", response); + + // Get import by ID + const importDetails = await client.contactImports.get(response.id); + console.log("Import details:", importDetails); + } catch (error: any) { + console.error("Error:", error); + } +} + +runContactImportsFlow(); diff --git a/src/__tests__/lib/api/ContactImports.test.ts b/src/__tests__/lib/api/ContactImports.test.ts new file mode 100644 index 0000000..26fd856 --- /dev/null +++ b/src/__tests__/lib/api/ContactImports.test.ts @@ -0,0 +1,17 @@ +import axios from "axios"; + +import ContactImports from "../../../lib/api/ContactImports"; + +describe("lib/api/ContactImports: ", () => { + const accountId = 100; + const contactImportsAPI = new ContactImports(axios, accountId); + + describe("class ContactImports(): ", () => { + describe("init: ", () => { + it("initializes with all necessary params.", () => { + expect(contactImportsAPI).toHaveProperty("create"); + expect(contactImportsAPI).toHaveProperty("get"); + }); + }); + }); +}); diff --git a/src/__tests__/lib/api/resources/ContactImports.test.ts b/src/__tests__/lib/api/resources/ContactImports.test.ts new file mode 100644 index 0000000..82a13ca --- /dev/null +++ b/src/__tests__/lib/api/resources/ContactImports.test.ts @@ -0,0 +1,265 @@ +import axios from "axios"; +import AxiosMockAdapter from "axios-mock-adapter"; + +import ContactImportsApi from "../../../../lib/api/resources/ContactImports"; +import handleSendingError from "../../../../lib/axios-logger"; +import MailtrapError from "../../../../lib/MailtrapError"; +import { + ContactImportResponse, + ImportContactsRequest, +} from "../../../../types/api/contact-imports"; + +import CONFIG from "../../../../config"; + +const { CLIENT_SETTINGS } = CONFIG; +const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; + +describe("lib/api/resources/ContactImports: ", () => { + let mock: AxiosMockAdapter; + const accountId = 100; + const contactImportsAPI = new ContactImportsApi(axios, accountId); + + const createImportRequest: ImportContactsRequest = { + contacts: [ + { + email: "customer1@example.com", + fields: { + first_name: "John", + last_name: "Doe", + }, + list_ids_included: [1, 2], + }, + { + email: "customer2@example.com", + fields: { + first_name: "Jane", + zip_code: 12345, + }, + list_ids_excluded: [3], + }, + ], + }; + + const createImportResponse: ContactImportResponse = { + id: 1, + status: "created", + created_contacts_count: 2, + updated_contacts_count: 0, + contacts_over_limit_count: 0, + }; + + const getImportResponse: ContactImportResponse = { + id: 1, + status: "finished", + created_contacts_count: 2, + updated_contacts_count: 0, + contacts_over_limit_count: 0, + }; + + describe("class ContactImportsApi(): ", () => { + describe("init: ", () => { + it("initializes with all necessary params.", () => { + expect(contactImportsAPI).toHaveProperty("create"); + expect(contactImportsAPI).toHaveProperty("get"); + }); + }); + }); + + beforeAll(() => { + axios.interceptors.response.use( + (response) => response.data, + handleSendingError + ); + mock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + mock.reset(); + }); + + describe("create(): ", () => { + it("successfully creates a contact import.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/contacts/imports`; + const expectedResponseData = createImportResponse; + + expect.assertions(2); + + mock + .onPost(endpoint, createImportRequest) + .reply(200, expectedResponseData); + const result = await contactImportsAPI.create(createImportRequest); + + expect(mock.history.post[0].url).toEqual(endpoint); + expect(result).toEqual(expectedResponseData); + }); + + it("successfully creates a contact import with minimal data.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/contacts/imports`; + const minimalRequest: ImportContactsRequest = { + contacts: [ + { + email: "customer@example.com", + }, + ], + }; + const expectedResponseData: ContactImportResponse = { + id: 2, + status: "created", + }; + + expect.assertions(2); + + mock.onPost(endpoint, minimalRequest).reply(200, expectedResponseData); + const result = await contactImportsAPI.create(minimalRequest); + + expect(mock.history.post[0].url).toEqual(endpoint); + expect(result).toEqual(expectedResponseData); + }); + + it("fails with error.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/contacts/imports`; + const expectedErrorMessage = "Request failed with status code 400"; + + expect.assertions(2); + + mock.onPost(endpoint).reply(400, { error: expectedErrorMessage }); + + try { + await contactImportsAPI.create(createImportRequest); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + + it("fails with validation error.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/contacts/imports`; + const invalidRequest: ImportContactsRequest = { + contacts: [ + { + email: "invalid-email", + }, + ], + }; + const expectedErrorMessage = "Invalid email format"; + + expect.assertions(2); + + mock.onPost(endpoint).reply(422, { error: expectedErrorMessage }); + + try { + await contactImportsAPI.create(invalidRequest); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + expect(error.message).toEqual(expectedErrorMessage); + } + } + }); + + it("fails with array of validation errors.", async () => { + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/contacts/imports`; + const invalidRequest: ImportContactsRequest = { + contacts: [ + { + email: "invalid-email-1", + }, + { + email: "invalid-email-2", + }, + ], + }; + + expect.assertions(2); + + // API returns errors as an array of objects (confirmed by actual API response) + // Each object contains the email and nested errors object with field-specific messages + mock.onPost(endpoint).reply(422, { + errors: [ + { + email: "invalid-email-1", + errors: { + email: ["is invalid", "is required"], + }, + }, + { + email: "invalid-email-2", + errors: { + base: ["Contact limit exceeded"], + }, + }, + ], + }); + + try { + await contactImportsAPI.create(invalidRequest); + } catch (error) { + expect(error).toBeInstanceOf(MailtrapError); + if (error instanceof MailtrapError) { + // Note: Current axios-logger doesn't properly handle array of objects format, + // so it falls back to stringifying the array, resulting in [object Object],[object Object] + // This test documents the current behavior. Updating axios-logger to properly + // parse this format will be a separate task. + expect(error.message).toBe("[object Object],[object Object]"); + } + } + }); + }); + + describe("get(): ", () => { + it("successfully gets a contact import by ID.", async () => { + const importId = 1; + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/contacts/imports/${importId}`; + const expectedResponseData = getImportResponse; + + expect.assertions(2); + + mock.onGet(endpoint).reply(200, expectedResponseData); + const result = await contactImportsAPI.get(importId); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(result).toEqual(expectedResponseData); + }); + + it("successfully gets a contact import with all status fields.", async () => { + const importId = 2; + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/contacts/imports/${importId}`; + const expectedResponseData: ContactImportResponse = { + id: importId, + status: "failed", + created_contacts_count: 5, + updated_contacts_count: 3, + contacts_over_limit_count: 2, + }; + + expect.assertions(2); + + mock.onGet(endpoint).reply(200, expectedResponseData); + const result = await contactImportsAPI.get(importId); + + expect(mock.history.get[0].url).toEqual(endpoint); + expect(result).toEqual(expectedResponseData); + }); + + it("fails with error when getting a contact import.", async () => { + const importId = 999; + const endpoint = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/contacts/imports/${importId}`; + const expectedErrorMessage = "Contact import not found"; + + expect.assertions(2); + + mock.onGet(endpoint).reply(404, { error: expectedErrorMessage }); + + try { + await contactImportsAPI.get(importId); + } 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 baa6bb2..c562ece 100644 --- a/src/lib/MailtrapClient.ts +++ b/src/lib/MailtrapClient.ts @@ -7,14 +7,15 @@ import encodeMailBuffers from "./mail-buffer-encoder"; import handleSendingError from "./axios-logger"; import MailtrapError from "./MailtrapError"; -import GeneralAPI from "./api/General"; -import TestingAPI from "./api/Testing"; import ContactsBaseAPI from "./api/Contacts"; -import ContactListsBaseAPI from "./api/ContactLists"; import ContactFieldsBaseAPI from "./api/ContactFields"; +import ContactListsBaseAPI from "./api/ContactLists"; +import ContactImportsBaseAPI from "./api/ContactImports"; +import GeneralAPI from "./api/General"; import TemplatesBaseAPI from "./api/Templates"; import SuppressionsBaseAPI from "./api/Suppressions"; import SendingDomainsBaseAPI from "./api/SendingDomains"; +import TestingAPI from "./api/Testing"; import CONFIG from "../config"; @@ -146,6 +147,14 @@ export default class MailtrapClient { return new ContactFieldsBaseAPI(this.axios, accountId); } + /** + * Getter for Contact Imports API. + */ + get contactImports() { + const accountId = this.validateAccountIdPresence(); + return new ContactImportsBaseAPI(this.axios, accountId); + } + /** * Getter for Templates API. */ @@ -166,9 +175,8 @@ export default class MailtrapClient { * Getter for Sending Domains API. */ get sendingDomains() { - this.validateAccountIdPresence(); - - return new SendingDomainsBaseAPI(this.axios, this.accountId!); + const accountId = this.validateAccountIdPresence(); + return new SendingDomainsBaseAPI(this.axios, accountId); } /** diff --git a/src/lib/api/ContactImports.ts b/src/lib/api/ContactImports.ts new file mode 100644 index 0000000..f83c68c --- /dev/null +++ b/src/lib/api/ContactImports.ts @@ -0,0 +1,15 @@ +import { AxiosInstance } from "axios"; + +import ContactImportsApi from "./resources/ContactImports"; + +export default class ContactImportsBaseAPI { + public create: ContactImportsApi["create"]; + + public get: ContactImportsApi["get"]; + + constructor(client: AxiosInstance, accountId: number) { + const contactImports = new ContactImportsApi(client, accountId); + this.create = contactImports.create.bind(contactImports); + this.get = contactImports.get.bind(contactImports); + } +} diff --git a/src/lib/api/resources/ContactImports.ts b/src/lib/api/resources/ContactImports.ts new file mode 100644 index 0000000..a007ee0 --- /dev/null +++ b/src/lib/api/resources/ContactImports.ts @@ -0,0 +1,43 @@ +import { AxiosInstance } from "axios"; + +import CONFIG from "../../../config"; + +import { + ContactImportResponse, + ImportContactsRequest, +} from "../../../types/api/contact-imports"; + +const { CLIENT_SETTINGS } = CONFIG; +const { GENERAL_ENDPOINT } = CLIENT_SETTINGS; + +export default class ContactImportsApi { + private client: AxiosInstance; + + private contactImportsURL: string; + + constructor(client: AxiosInstance, accountId: number) { + this.client = client; + this.contactImportsURL = `${GENERAL_ENDPOINT}/api/accounts/${accountId}/contacts/imports`; + } + + /** + * Get a contact import by `importId`. + */ + public async get(importId: number) { + const url = `${this.contactImportsURL}/${importId}`; + + return this.client.get(url); + } + + /** + * Import contacts. + */ + public async create(data: ImportContactsRequest) { + const url = `${this.contactImportsURL}`; + + return this.client.post( + url, + data + ); + } +} diff --git a/src/types/api/contact-imports.ts b/src/types/api/contact-imports.ts new file mode 100644 index 0000000..86b624f --- /dev/null +++ b/src/types/api/contact-imports.ts @@ -0,0 +1,21 @@ +export type ContactImportResponse = { + id: number; + status: "created" | "started" | "finished" | "failed"; + created_contacts_count?: number; + updated_contacts_count?: number; + contacts_over_limit_count?: number; +}; + +export type ImportContactsRequest = { + contacts: { + email: string; + fields?: { + first_name?: string; + last_name?: string; + zip_code?: number; + [key: string]: string | number | undefined; + }; + list_ids_included?: number[]; + list_ids_excluded?: number[]; + }[]; +};