diff --git a/README.md b/README.md index f3ddff2..7c40255 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ You can use custom contact properties in API calls. Please make sure to [add cus - [updateContact()](#updatecontact) - [findContact()](#findcontact) - [deleteContact()](#deletecontact) +- [checkContactSuppression()](#checkcontactsuppression) +- [removeContactSuppression()](#removecontactsuppression) - [createContactProperty()](#createcontactproperty) - [getContactProperties()](#getcontactproperties) - [getMailingLists()](#getmailinglists) @@ -363,6 +365,122 @@ HTTP 404 Not Found --- +### checkContactSuppression() + +Check whether a contact is suppressed, either by email address or `userId`. + +[API Reference](https://loops.so/docs/api-reference/check-contact-suppression) + +#### Parameters + +You must use one parameter in the request. + +| Name | Type | Required | Notes | +| -------- | ------ | -------- | ----- | +| `email` | string | No | | +| `userId` | string | No | | + +#### Example + +```javascript +const resp = await loops.checkContactSuppression({ email: "hello@gmail.com" }); + +const resp = await loops.checkContactSuppression({ userId: "12345" }); +``` + +#### Response + +```json +{ + "contact": { + "id": "cll6b3i8901a9jx0oyktl2m4u", + "email": "adam@loops.so", + "userId": null + }, + "isSuppressed": true, + "removalQuota": { + "limit": 100, + "remaining": 4 + } +} +``` + +Error handling is done through the `APIError` class, which provides `statusCode` and `json` properties containing the API's error response details. For implementation examples, see the [Usage section](#usage). + +```json +HTTP 400 Bad Request +{ + "success": false, + "message": "An email or userId is required." +} +``` + +```json +HTTP 404 Not Found +{ + "success": false, + "message": "This contact was not found." +} +``` + +--- + +### removeContactSuppression() + +Remove suppression for a contact, either by email address or `userId`. + +[API Reference](https://loops.so/docs/api-reference/remove-contact-suppression) + +#### Parameters + +You must use one parameter in the request. + +| Name | Type | Required | Notes | +| -------- | ------ | -------- | ----- | +| `email` | string | No | | +| `userId` | string | No | | + +#### Example + +```javascript +const resp = await loops.removeContactSuppression({ email: "hello@gmail.com" }); + +const resp = await loops.removeContactSuppression({ userId: "12345" }); +``` + +#### Response + +```json +{ + "success": true, + "message": "Email removed from suppression list.", + "removalQuota": { + "limit": 100, + "remaining": 4 + } +} +``` + +Error handling is done through the `APIError` class, which provides `statusCode` and `json` properties containing the API's error response details. For implementation examples, see the [Usage section](#usage). + +```json +HTTP 400 Bad Request +{ + "success": false, + "message": "This contact is not suppressed." +} +``` + +```json +HTTP 404 Not Found +{ + "success": false, + "message": "This contact was not found." +} +``` + +--- + ### createContactProperty() Create a new contact property. @@ -770,6 +888,7 @@ const resp = await loops.getTransactionalEmails({ perPage: 15 }); ## Version history +- `v6.3.0` (Apr 8, 2026) - Added [`checkContactSuppression()`](#checkcontactsuppression) and [`removeContactSuppression()`](#removecontactsuppression) methods. - `v6.2.0` (Feb 9, 2026) - Support for the new arrays feature in sendTransactionalEmail. - `v6.1.2` (Jan 29, 2026) - Added `rawBody` to `APIError` in the case no JSON is received from the server (thanks to [@leipert](https://github.com/leipert)). - `v6.0.1` (Oct 15, 2025) - Added `optInStatus` to contact object in [`findContact()`](#findcontact) for the new double opt-in feature. diff --git a/package-lock.json b/package-lock.json index 3207093..01a048c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loops", - "version": "6.2.1", + "version": "6.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loops", - "version": "6.2.1", + "version": "6.3.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.12", diff --git a/package.json b/package.json index 7d26408..21788b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loops", - "version": "6.2.1", + "version": "6.3.0", "author": "Dan Rowden ", "license": "MIT", "main": "./dist/index.cjs", diff --git a/src/__tests__/LoopsClient.test.ts b/src/__tests__/LoopsClient.test.ts index 61d2cd1..352bbc5 100644 --- a/src/__tests__/LoopsClient.test.ts +++ b/src/__tests__/LoopsClient.test.ts @@ -247,6 +247,156 @@ describe("LoopsClient", () => { }); }); + describe("checkContactSuppression", () => { + it("should check suppression status by email", async () => { + const email = "test@example.com"; + const mockResponse = { + contact: { + id: "cll6b3i8901a9jx0oyktl2m4u", + email, + userId: null, + }, + isSuppressed: true, + removalQuota: { + limit: 100, + remaining: 10, + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.checkContactSuppression({ email }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/contacts/suppression?email=test%40example.com"), + expect.objectContaining({ + method: "GET", + }) + ); + }); + + it("should check suppression status by userId", async () => { + const userId = "user_123"; + const mockResponse = { + contact: { + id: "cll6b3i8901a9jx0oyktl2m4u", + email: "test@example.com", + userId, + }, + isSuppressed: false, + removalQuota: { + limit: 100, + remaining: 99, + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.checkContactSuppression({ userId }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/contacts/suppression?userId=user_123"), + expect.objectContaining({ + method: "GET", + }) + ); + }); + + it("should throw error when both email and userId are provided", async () => { + await expect( + client.checkContactSuppression({ + email: "test@example.com", + userId: "user_123", + }) + ).rejects.toThrow(ValidationError); + }); + + it("should throw error when neither email nor userId is provided", async () => { + await expect(client.checkContactSuppression({})).rejects.toThrow( + ValidationError + ); + }); + }); + + describe("removeContactSuppression", () => { + it("should remove suppression by email", async () => { + const email = "test@example.com"; + const mockResponse = { + success: true, + message: "Email removed from suppression list.", + removalQuota: { + limit: 100, + remaining: 9, + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.removeContactSuppression({ email }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/contacts/suppression?email=test%40example.com"), + expect.objectContaining({ + method: "DELETE", + }) + ); + }); + + it("should remove suppression by userId", async () => { + const userId = "user_123"; + const mockResponse = { + success: true, + message: "User removed from suppression list.", + removalQuota: { + limit: 100, + remaining: 8, + }, + }; + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const result = await client.removeContactSuppression({ userId }); + + expect(result).toEqual(mockResponse); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("v1/contacts/suppression?userId=user_123"), + expect.objectContaining({ + method: "DELETE", + }) + ); + }); + + it("should throw error when both email and userId are provided", async () => { + await expect( + client.removeContactSuppression({ + email: "test@example.com", + userId: "user_123", + }) + ).rejects.toThrow(ValidationError); + }); + + it("should throw error when neither email nor userId is provided", async () => { + await expect(client.removeContactSuppression({})).rejects.toThrow( + ValidationError + ); + }); + }); + describe("createContactProperty", () => { it("should create contact property successfully", async () => { const name = "customField"; diff --git a/src/index.ts b/src/index.ts index 4c6e622..3f989b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ interface QueryOptions { path: `v1/${string}`; - method?: "GET" | "POST" | "PUT"; + method?: "GET" | "POST" | "PUT" | "DELETE"; payload?: Record; params?: Record; headers?: Record; @@ -26,6 +26,44 @@ interface DeleteSuccessResponse { message: "Contact deleted."; } +interface SuppressionContact { + /** + * The contact's Loops-assigned ID. + */ + id: string; + /** + * The contact's email address. + */ + email: string; + /** + * The contact's unique user ID. + */ + userId: string | null; +} + +interface SuppressionRemovalQuota { + /** + * The number of suppression removals you can request in a rolling 30 day period. + */ + limit: number; + /** + * The remaining number of suppression removals left in the current 30 day period. + */ + remaining: number; +} + +interface CheckContactSuppressionSuccessResponse { + contact: SuppressionContact; + isSuppressed: boolean; + removalQuota: SuppressionRemovalQuota; +} + +interface RemoveContactSuppressionSuccessResponse { + success: true; + message: string; + removalQuota: SuppressionRemovalQuota; +} + interface ErrorResponse { success: false; message: string; @@ -321,7 +359,7 @@ class LoopsClient { } const url = new URL(path, this.apiRoot); - if (params && method === "GET") { + if (params) { Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value as string) ); @@ -524,6 +562,73 @@ class LoopsClient { }); } + /** + * Check whether a contact is suppressed by email or user ID. + * + * @param {Object} params + * @param {string} [params.email] The email address of the contact. + * @param {string} [params.userId] The user ID of the contact. + * + * @see https://loops.so/docs/api-reference/check-contact-suppression + * + * @returns {Object} Suppression status and removal quota (JSON) + */ + async checkContactSuppression({ + email, + userId, + }: { + email?: string; + userId?: string; + }): Promise { + if (email && userId) + throw new ValidationError("Only one parameter is permitted."); + if (!email && !userId) + throw new ValidationError( + "You must provide an `email` or `userId` value." + ); + const params: { email?: string; userId?: string } = {}; + if (email) params["email"] = email; + else if (userId) params["userId"] = userId; + return this._makeQuery({ + path: "v1/contacts/suppression", + params, + }); + } + + /** + * Remove suppression for a contact by email or user ID. + * + * @param {Object} params + * @param {string} [params.email] The email address of the contact. + * @param {string} [params.userId] The user ID of the contact. + * + * @see https://loops.so/docs/api-reference/remove-contact-suppression + * + * @returns {Object} Confirmation and remaining removal quota (JSON) + */ + async removeContactSuppression({ + email, + userId, + }: { + email?: string; + userId?: string; + }): Promise { + if (email && userId) + throw new ValidationError("Only one parameter is permitted."); + if (!email && !userId) + throw new ValidationError( + "You must provide an `email` or `userId` value." + ); + const params: { email?: string; userId?: string } = {}; + if (email) params["email"] = email; + else if (userId) params["userId"] = userId; + return this._makeQuery({ + path: "v1/contacts/suppression", + method: "DELETE", + params, + }); + } + /** * Create a new contact property. * @@ -721,6 +826,10 @@ export { ApiKeyErrorResponse, ContactSuccessResponse, DeleteSuccessResponse, + SuppressionContact, + SuppressionRemovalQuota, + CheckContactSuppressionSuccessResponse, + RemoveContactSuppressionSuccessResponse, ErrorResponse, Contact, ContactProperty,