From bea3efad52226f7e43a18a10263bf10dec9e2578 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:50:12 +0400 Subject: [PATCH 1/5] feat: add verify_plus and activity_data on validate APIs Expose optional verify_plus, activity_data, and batch timeout per v2 docs; extend validation result types with activity fields. Co-authored-by: Cursor --- src/zero-bounce.js | 28 +++++++++++++++++++++++++- tests/ts-consumer.ts | 11 ++++++++++- tests/zero-bounce.test.js | 41 +++++++++++++++++++++++++++++++++++++++ zeroBounceSDK.d.ts | 27 +++++++++++++++++++++++--- 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/zero-bounce.js b/src/zero-bounce.js index 786c6d4..b25fe2d 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -115,6 +115,8 @@ export class ZeroBounceSDK { * @param options - options object or ip_address string for backwards compatibility * @param options.ip_address - IP address (optional) * @param options.timeout - validation timeout in seconds, 3-60 (optional). If met, the API will return unknown/greylisted. + * @param options.activityData - when true/false, sends activity_data query param (optional) + * @param options.verifyPlus - when true/false, sends verify_plus query param (optional) * */ validateEmail(email, options = null) { if (!this._initialized) { @@ -127,11 +129,15 @@ export class ZeroBounceSDK { let ip_address; let timeout; + let activityData; + let verifyPlus; if (typeof options === "string") { ip_address = options; } else if (options && typeof options === "object") { ip_address = options.ip_address; timeout = options.timeout; + activityData = options.activityData; + verifyPlus = options.verifyPlus; } if (timeout != null && (timeout < 3 || timeout > 60)) { @@ -145,6 +151,8 @@ export class ZeroBounceSDK { }; if (ip_address != null) params.ip_address = ip_address; if (timeout != null) params.timeout = timeout; + if (activityData != null) params.activity_data = activityData ? "true" : "false"; + if (verifyPlus != null) params.verify_plus = verifyPlus ? "true" : "false"; return createRequest({ requestType: "GET", params, path: "/validate", apiBaseURL: this._api_base_url }); } @@ -180,9 +188,10 @@ export class ZeroBounceSDK { /** * @param emailBatch: an array containing a list of email objects {email_address: "example@example.com"} + * @param options - optional timeout (10-120 seconds), activityData, verifyPlus * */ - validateBatch(emailBatch) { + validateBatch(emailBatch, options = null) { if (!this._initialized) { notInitialized(); return; @@ -191,10 +200,27 @@ export class ZeroBounceSDK { return; } + let timeout; + let activityData; + let verifyPlus; + if (options && typeof options === "object") { + timeout = options.timeout; + activityData = options.activityData; + verifyPlus = options.verifyPlus; + } + + if (timeout != null && (timeout < 10 || timeout > 120)) { + parameterIsInvalid("timeout", "Must be between 10 and 120 seconds."); + return; + } + const body = { api_key: this._api_key, email_batch: emailBatch, }; + if (timeout != null) body.timeout = timeout; + if (activityData != null) body.activity_data = activityData; + if (verifyPlus != null) body.verify_plus = verifyPlus; return createRequest({ requestType: "POST", path: "/validatebatch", diff --git a/tests/ts-consumer.ts b/tests/ts-consumer.ts index 0056a52..9606003 100644 --- a/tests/ts-consumer.ts +++ b/tests/ts-consumer.ts @@ -5,6 +5,7 @@ import ZeroBounceSDK, { type ValidateEmailOptions, + type ValidateBatchOptions, type ValidateBatchEmailItem, type SendFileOptions, type SendFileStreamOptions, @@ -46,6 +47,14 @@ const downloadCombined: string = ZeroBounceSDK.ZBDownloadType.COMBINED; const validateOpts: ValidateEmailOptions = { ip_address: "127.0.0.1", timeout: 10, + activityData: true, + verifyPlus: false, +}; + +const batchOpts: ValidateBatchOptions = { + timeout: 30, + activityData: true, + verifyPlus: true, }; const batchItems: ValidateBatchEmailItem[] = [ @@ -82,7 +91,7 @@ const usagePromise: Promise = zb.getApiUsage("2024-01-01", "2024-12-31"); const batchPromise: Promise = - zb.validateBatch(batchItems); + zb.validateBatch(batchItems, batchOpts); const activityPromise: Promise = zb.getEmailActivity("test@example.com"); diff --git a/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index c041cb4..b30bd15 100644 --- a/tests/zero-bounce.test.js +++ b/tests/zero-bounce.test.js @@ -303,6 +303,21 @@ describe("ZeroBounceSDK", () => { expect.stringContaining("timeout parameter is invalid") ); }); + + it("should pass activity_data and verify_plus via options object", async () => { + jest.spyOn(global, "fetch").mockImplementationOnce(() => Promise.resolve({ + json: () => Promise.resolve({ status: "valid" }), + text: () => Promise.resolve('{"status":"valid"}'), + })); + zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); + await zeroBounceSDK.validateEmail(email, { + activityData: true, + verifyPlus: false, + }); + const calledUrl = String(fetch.mock.calls[0][0]); + expect(calledUrl).toContain("activity_data=true"); + expect(calledUrl).toContain("verify_plus=false"); + }); }); @@ -397,6 +412,32 @@ describe("ZeroBounceSDK", () => { expect(String(fetch.mock.calls[0][0])).not.toContain("bulkapi"); expect(response).toEqual(expectedResponse); }); + + it("should include batch options in POST body", async () => { + jest.spyOn(global, "fetch").mockImplementationOnce(() => Promise.resolve({ + json: () => Promise.resolve({ email_batch: [], errors: [] }), + text: () => Promise.resolve('{"email_batch":[],"errors":[]}'), + })); + zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); + await zeroBounceSDK.validateBatch(emailBatch, { + timeout: 60, + activityData: true, + verifyPlus: true, + }); + const init = fetch.mock.calls[0][1]; + const body = JSON.parse(init.body); + expect(body.timeout).toBe(60); + expect(body.activity_data).toBe(true); + expect(body.verify_plus).toBe(true); + }); + + it("should throw an error if batch timeout is out of range", async () => { + zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); + await zeroBounceSDK.validateBatch(emailBatch, { timeout: 9 }); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("timeout parameter is invalid") + ); + }); }); diff --git a/zeroBounceSDK.d.ts b/zeroBounceSDK.d.ts index 3432372..0627874 100644 --- a/zeroBounceSDK.d.ts +++ b/zeroBounceSDK.d.ts @@ -69,6 +69,10 @@ export interface EmailValidationResult { account?: string | null; domain?: string | null; domain_age_days?: string | null; + /** Last activity bucket when activity_data was requested (e.g. 30, 365+). */ + active_in_days?: string | null; + /** First date activity was detected for this email (YYYY-MM-DD). */ + active_first_seen?: string | null; smtp_provider?: string | null; /** [true/false or null] Is the domain a catch-all? */ catchall_domain?: boolean | null; @@ -231,11 +235,26 @@ export interface ValidateEmailOptions { ip_address?: string; /** Validation timeout in seconds, 3–60. If exceeded, API may return unknown/greylisted. */ timeout?: number; + /** Maps to `activity_data` on GET /validate. */ + activityData?: boolean; + /** Maps to `verify_plus` on GET /validate. */ + verifyPlus?: boolean; } /** Single item for validateBatch (email_batch array). */ export interface ValidateBatchEmailItem { email_address: string; + ip_address?: string | null; +} + +/** Options for validateBatch (timeout, activity_data, verify_plus). */ +export interface ValidateBatchOptions { + /** Validation timeout in seconds, 10–120 for the whole batch. */ + timeout?: number; + /** Maps to `activity_data` on POST /validatebatch. */ + activityData?: boolean; + /** Maps to `verify_plus` on POST /validatebatch. */ + verifyPlus?: boolean; } /** Bulk `getfile` query values for `download_type` (validation and scoring). */ @@ -365,7 +384,7 @@ export class ZeroBounceSDK { /** * Validate a single email address. * @param email - Email to validate - * @param options - Optional ip_address and/or timeout (3–60 seconds), or ip_address string for backwards compatibility + * @param options - Optional ip_address, timeout (3–60), activityData, verifyPlus, or ip_address string for backwards compatibility */ validateEmail( email: string, @@ -384,10 +403,12 @@ export class ZeroBounceSDK { /** * Validate a batch of emails. - * @param emailBatch - Array of objects with email_address + * @param emailBatch - Array of objects with email_address (and optional ip_address) + * @param options - Optional timeout (10–120), activityData, verifyPlus */ validateBatch( - emailBatch: ValidateBatchEmailItem[] + emailBatch: ValidateBatchEmailItem[], + options?: ValidateBatchOptions | null ): Promise; /** From 890ac07fa77730815e3d04c2fb4162ac93b3d161 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:46:22 +0400 Subject: [PATCH 2/5] feat: add real-time scoreEmail for GET/POST /v2/scoring Co-authored-by: Cursor --- src/zero-bounce.js | 40 ++++++++++++++++++++++++++ tests/ts-consumer.ts | 11 +++++++ tests/zero-bounce.test.js | 60 +++++++++++++++++++++++++++++++++++++++ zeroBounceSDK.d.ts | 27 ++++++++++++++++++ 4 files changed, 138 insertions(+) diff --git a/src/zero-bounce.js b/src/zero-bounce.js index b25fe2d..90459e4 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -252,6 +252,46 @@ export class ZeroBounceSDK { }); } + /** + * Real-time AI scoring for a single email (GET or POST /scoring). + * @param email - email to score + * @param options - optional `{ method: "POST" }` to use form-encoded POST (default GET) + * */ + scoreEmail(email, options = null) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!email) { + parameterIsMissing("Email"); + return; + } + + const params = { + api_key: this._api_key, + email, + }; + + const usePost = + options && typeof options === "object" && options.method === "POST"; + + if (usePost) { + return createRequest({ + requestType: "POST", + path: "/scoring", + body: new URLSearchParams(params), + params: {}, + apiBaseURL: this._api_base_url, + }); + } + + return createRequest({ + requestType: "GET", + params, + path: "/scoring", + apiBaseURL: this._api_base_url, + }); + } + /** * @param file: File - The csv or txt file to be submitted. * @param email_address_column: number - The column index of the email address in the file. Index starts from 1. diff --git a/tests/ts-consumer.ts b/tests/ts-consumer.ts index 9606003..eaa2340 100644 --- a/tests/ts-consumer.ts +++ b/tests/ts-consumer.ts @@ -22,6 +22,9 @@ import ZeroBounceSDK, { type ValidateBatchResponse, type ApiUsageResponse, type EmailActivityResponse, + type EmailScoringResult, + type EmailScoringError, + type ScoreEmailOptions, type SendFileResponse, type ValidationFileStatusResponse, type ScoringFileStatusResponse, @@ -96,6 +99,12 @@ const batchPromise: Promise = const activityPromise: Promise = zb.getEmailActivity("test@example.com"); +const scoreOpts: ScoreEmailOptions = { method: "POST" }; +const scorePromise: Promise = + zb.scoreEmail("test@example.com"); +const scorePostPromise: Promise = + zb.scoreEmail("test@example.com", scoreOpts); + const findDomainPromise: Promise = zb.findEmailByDomain(findByDomainOpts); const findCompanyPromise: Promise = @@ -207,6 +216,8 @@ function assertTypes(): void { void usagePromise; void batchPromise; void activityPromise; + void scorePromise; + void scorePostPromise; void findDomainPromise; void findCompanyPromise; void formatDomainPromise; diff --git a/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index b30bd15..803dbd4 100644 --- a/tests/zero-bounce.test.js +++ b/tests/zero-bounce.test.js @@ -320,6 +320,66 @@ describe("ZeroBounceSDK", () => { }); }); + describe("scoreEmail", () => { + const email = "mark@gmail.com"; + + it("should throw an error if not initialized", async () => { + await zeroBounceSDK.scoreEmail(email); + expect(console.error).toHaveBeenCalledWith(initErrorMessage); + }); + + it("should throw an error if email is missing", async () => { + zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); + await zeroBounceSDK.scoreEmail(null); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining(missingParamMessage) + ); + }); + + it("should return scoring data via GET", async () => { + const expectedResponse = { + email: "mark@gmail.com", + score: 10, + }; + + jest.spyOn(global, "fetch").mockImplementationOnce((url, init) => { + expect(String(url)).toContain("/scoring?"); + expect(init.method).toBe("GET"); + return Promise.resolve({ + json: () => Promise.resolve(expectedResponse), + text: () => Promise.resolve(JSON.stringify(expectedResponse)), + }); + }); + + zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); + const response = await zeroBounceSDK.scoreEmail(email); + expect(response).toEqual(expectedResponse); + }); + + it("should send POST with form body when requested", async () => { + const expectedResponse = { + email: "mark@gmail.com", + score: 10, + }; + + jest.spyOn(global, "fetch").mockImplementationOnce((url, init) => { + expect(String(url)).toContain("/scoring?"); + expect(init.method).toBe("POST"); + expect(init.body).toBeInstanceOf(URLSearchParams); + expect(init.body.get("email")).toBe(email); + expect(init.body.get("api_key")).toBe("valid-api-key"); + return Promise.resolve({ + json: () => Promise.resolve(expectedResponse), + text: () => Promise.resolve(JSON.stringify(expectedResponse)), + }); + }); + + zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); + const response = await zeroBounceSDK.scoreEmail(email, { method: "POST" }); + expect(response).toEqual(expectedResponse); + }); + }); + describe("validateBatch", () => { const emailBatch = [ diff --git a/zeroBounceSDK.d.ts b/zeroBounceSDK.d.ts index 0627874..73a1b8d 100644 --- a/zeroBounceSDK.d.ts +++ b/zeroBounceSDK.d.ts @@ -142,6 +142,23 @@ export interface EmailActivityResponse { active_in_days?: string; } +/** Successful GET/POST /scoring response. */ +export interface EmailScoringResult { + email: string; + score: number; +} + +/** Unsuccessful /scoring response (invalid email, missing params, etc.). */ +export interface EmailScoringError { + error: string; +} + +/** Options for scoreEmail. */ +export interface ScoreEmailOptions { + /** When `"POST"`, sends `api_key` and `email` as `application/x-www-form-urlencoded` (default GET). */ + method?: "GET" | "POST"; +} + /** Bulk sendfile success (validation or scoring). */ export interface BulkSendFileSuccessResponse { success: true; @@ -417,6 +434,16 @@ export class ZeroBounceSDK { */ getEmailActivity(email: string): Promise; + /** + * Real-time AI scoring for a single email (GET or POST /scoring). + * @param email - Email to score + * @param options - Optional `{ method: "POST" }` for form-encoded POST + */ + scoreEmail( + email: string, + options?: ScoreEmailOptions | null + ): Promise; + /** * Submit a file for bulk email validation. */ From 4a912612d881fd10fb4a587da7c29ec0128cfd30 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:32:57 +0400 Subject: [PATCH 3/5] feat: Priority 3 API parity (bulk finder, filters, S3) Add bulk email finder and domain search file jobs, list evaluator, allow/block filters, and S3 validate/score/export helpers aligned with bulkapi and v2 docs. Co-authored-by: Cursor --- src/utils.js | 19 +- src/zero-bounce.js | 439 +++++++++++++++++++++++++++++++++++++- tests/zero-bounce.test.js | 112 +++++++++- zeroBounceSDK.d.ts | 207 ++++++++++++++++++ 4 files changed, 770 insertions(+), 7 deletions(-) diff --git a/src/utils.js b/src/utils.js index ea3e60c..757bfdd 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,5 +1,6 @@ // Constants export const API_BULK_BASE_URL = "https://bulkapi.zerobounce.net/v2"; +export const API_BULK_ROOT_URL = "https://bulkapi.zerobounce.net"; export const HTTP_TIMEOUT_MS = 120000; export const HEADERS = { Accept: "*/*", @@ -97,19 +98,31 @@ function processGetFileResponseBody(finalResult, response, scoring) { return saveFile(blob, `result${scoring ? "-scoring" : ""}.csv`); } +function bulkApiBase(bulkBase) { + return bulkBase === "root" ? API_BULK_ROOT_URL : API_BULK_BASE_URL; +} + +function buildRequestUrl({ batch, bulkBase, apiBaseURL, path, params }) { + const base = batch ? bulkApiBase(bulkBase) : apiBaseURL; + const query = + params != null && Object.keys(params).length > 0 + ? `?${new URLSearchParams(params)}` + : ""; + return `${base}${path}${query}`; +} + export async function createRequest({ requestType, body = null, params = null, path, batch = false, + bulkBase = "v2", returnText = false, scoring = false, apiBaseURL }) { - const url = `${ - batch ? API_BULK_BASE_URL : apiBaseURL - }${path}?${new URLSearchParams(params)}`; + const url = buildRequestUrl({ batch, bulkBase, apiBaseURL, path, params }); try { const response = await fetch(url, { diff --git a/src/zero-bounce.js b/src/zero-bounce.js index 90459e4..a5accf1 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -506,7 +506,7 @@ export class ZeroBounceSDK { }); } - _getStatusUtil(fileId, path) { + _getStatusUtil(fileId, path, { bulkRoot = false } = {}) { if (!this._initialized) { notInitialized(); return; @@ -524,6 +524,7 @@ export class ZeroBounceSDK { params, path, batch: true, + bulkBase: bulkRoot ? "root" : "v2", apiBaseURL: this._api_base_url }); } @@ -542,7 +543,7 @@ export class ZeroBounceSDK { return this._getStatusUtil(fileId, "/scoring/filestatus"); } - _getFileUtil(fileId, path, scoring = false, getFileOptions = null) { + _getFileUtil(fileId, path, scoring = false, getFileOptions = null, { bulkRoot = false } = {}) { if (!this._initialized) { notInitialized(); return; @@ -567,6 +568,7 @@ export class ZeroBounceSDK { params, path, batch: true, + bulkBase: bulkRoot ? "root" : "v2", returnText: true, scoring, apiBaseURL: this._api_base_url @@ -589,7 +591,7 @@ export class ZeroBounceSDK { return this._getFileUtil(fileId, "/scoring/getfile", true, getFileOptions); } - _deleteFileUtil(fileId, path, scoring = false) { + _deleteFileUtil(fileId, path, scoring = false, { bulkRoot = false } = {}) { if (!this._initialized) { notInitialized(); return; @@ -606,11 +608,51 @@ export class ZeroBounceSDK { params, path, batch: true, + bulkBase: bulkRoot ? "root" : "v2", scoring, apiBaseURL: this._api_base_url }); } + _sendBulkRootMultipartFile(path, { file, fileName = null, fields = {} }) { + const body = new FormData(); + for (const [key, value] of Object.entries(fields)) { + if (value != null && value !== false) { + body.append(key, value); + } + } + if (fileName != null) { + body.append("file", file, fileName); + } else { + body.append("file", file); + } + body.append("api_key", this._api_key); + return createRequest({ + requestType: "POST", + path, + body, + batch: true, + bulkBase: "root", + apiBaseURL: this._api_base_url, + }); + } + + _sendBulkRootFormPost(path, fields) { + const params = { + api_key: this._api_key, + ...fields, + }; + return createRequest({ + requestType: "POST", + path, + body: new URLSearchParams(params), + params: {}, + batch: true, + bulkBase: "root", + apiBaseURL: this._api_base_url, + }); + } + /** * @param fileId - the id of a previously submmitted and accepted file * */ @@ -625,6 +667,397 @@ export class ZeroBounceSDK { return this._deleteFileUtil(fileId, "/scoring/deletefile", true); } + // BULK EMAIL FINDER (bulkapi.zerobounce.net/email-finder/*) + + sendEmailFinderFile({ + file, + domain_column, + first_name_column = false, + last_name_column = false, + middle_name_column = false, + full_name_column = false, + has_header_row = false, + }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!file) { + parameterIsMissing("file"); + return; + } else if (!domain_column) { + parameterIsMissing("domain_column"); + return; + } + return this._sendBulkRootMultipartFile("/email-finder/sendfile", { + file, + fields: { + domain_column, + first_name_column, + last_name_column, + middle_name_column, + full_name_column, + has_header_row, + }, + }); + } + + sendEmailFinderFileStream(fileStreamOrBlob, fileName, { + domain_column, + first_name_column = false, + last_name_column = false, + middle_name_column = false, + full_name_column = false, + has_header_row = false, + } = {}) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!fileStreamOrBlob) { + parameterIsMissing("fileStreamOrBlob"); + return; + } else if (!fileName) { + parameterIsMissing("fileName"); + return; + } else if (!domain_column) { + parameterIsMissing("domain_column"); + return; + } + return this._sendBulkRootMultipartFile("/email-finder/sendfile", { + file: fileStreamOrBlob, + fileName, + fields: { + domain_column, + first_name_column, + last_name_column, + middle_name_column, + full_name_column, + has_header_row, + }, + }); + } + + getEmailFinderFileStatus(fileId) { + return this._getStatusUtil(fileId, "/email-finder/filestatus", { bulkRoot: true }); + } + + getEmailFinderFile(fileId) { + return this._getFileUtil(fileId, "/email-finder/getfile", false, null, { bulkRoot: true }); + } + + deleteEmailFinderFile(fileId) { + return this._deleteFileUtil(fileId, "/email-finder/deletefile", false, { bulkRoot: true }); + } + + // BULK DOMAIN SEARCH (bulkapi.zerobounce.net/domain-search/*) + + sendDomainSearchFile({ + file, + domain_column, + has_header_row = false, + }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!file) { + parameterIsMissing("file"); + return; + } else if (!domain_column) { + parameterIsMissing("domain_column"); + return; + } + return this._sendBulkRootMultipartFile("/domain-search/sendfile", { + file, + fields: { domain_column, has_header_row }, + }); + } + + sendDomainSearchFileStream(fileStreamOrBlob, fileName, { + domain_column, + has_header_row = false, + } = {}) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!fileStreamOrBlob) { + parameterIsMissing("fileStreamOrBlob"); + return; + } else if (!fileName) { + parameterIsMissing("fileName"); + return; + } else if (!domain_column) { + parameterIsMissing("domain_column"); + return; + } + return this._sendBulkRootMultipartFile("/domain-search/sendfile", { + file: fileStreamOrBlob, + fileName, + fields: { domain_column, has_header_row }, + }); + } + + getDomainSearchFileStatus(fileId) { + return this._getStatusUtil(fileId, "/domain-search/filestatus", { bulkRoot: true }); + } + + getDomainSearchFile(fileId) { + return this._getFileUtil(fileId, "/domain-search/getfile", false, null, { bulkRoot: true }); + } + + deleteDomainSearchFile(fileId) { + return this._deleteFileUtil(fileId, "/domain-search/deletefile", false, { bulkRoot: true }); + } + + // LIST EVALUATOR (bulkapi.zerobounce.net/v2/listevaluator/) + + sendListEvaluatorFile({ file, email_address_column }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!file) { + parameterIsMissing("file"); + return; + } else if (!email_address_column) { + parameterIsMissing("email_address_column"); + return; + } + const body = new FormData(); + body.append("file", file); + body.append("email_address_column", email_address_column); + body.append("api_key", this._api_key); + return createRequest({ + requestType: "POST", + path: "/listevaluator/", + body, + batch: true, + apiBaseURL: this._api_base_url, + }); + } + + sendListEvaluatorFileStream(fileStreamOrBlob, fileName, { email_address_column } = {}) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!fileStreamOrBlob) { + parameterIsMissing("fileStreamOrBlob"); + return; + } else if (!fileName) { + parameterIsMissing("fileName"); + return; + } else if (!email_address_column) { + parameterIsMissing("email_address_column"); + return; + } + const body = new FormData(); + body.append("file", fileStreamOrBlob, fileName); + body.append("email_address_column", email_address_column); + body.append("api_key", this._api_key); + return createRequest({ + requestType: "POST", + path: "/listevaluator/", + body, + batch: true, + apiBaseURL: this._api_base_url, + }); + } + + getListEvaluatorResult(fileId) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!fileId) { + parameterIsMissing("File id"); + return; + } + const params = { api_key: this._api_key }; + return createRequest({ + requestType: "GET", + params, + path: `/listevaluator/${fileId}/`, + batch: true, + apiBaseURL: this._api_base_url, + }); + } + + sendEvaluatedFile({ file }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!file) { + parameterIsMissing("file"); + return; + } + const body = new FormData(); + body.append("file", file); + body.append("api_key", this._api_key); + return createRequest({ + requestType: "POST", + path: "/send-evaluated-file/", + body, + batch: true, + apiBaseURL: this._api_base_url, + }); + } + + // CUSTOM FILTERS (main API /v2/filters/*) + + addFilter({ rule, target, value }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!rule) { + parameterIsMissing("rule"); + return; + } else if (!target) { + parameterIsMissing("target"); + return; + } else if (!value) { + parameterIsMissing("value"); + return; + } + const params = { + api_key: this._api_key, + rule, + target, + value, + }; + return createRequest({ + requestType: "POST", + path: "/filters/add", + body: new URLSearchParams(params), + params: {}, + apiBaseURL: this._api_base_url, + }); + } + + listFilters(options = null) { + if (!this._initialized) { + notInitialized(); + return; + } + const params = { api_key: this._api_key }; + const usePost = + options && typeof options === "object" && options.method === "POST"; + if (usePost) { + return createRequest({ + requestType: "POST", + path: "/filters/list", + body: new URLSearchParams(params), + params: {}, + apiBaseURL: this._api_base_url, + }); + } + return createRequest({ + requestType: "GET", + params, + path: "/filters/list", + apiBaseURL: this._api_base_url, + }); + } + + deleteFilter({ rule, target, value }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!rule) { + parameterIsMissing("rule"); + return; + } else if (!target) { + parameterIsMissing("target"); + return; + } else if (!value) { + parameterIsMissing("value"); + return; + } + const params = { + api_key: this._api_key, + rule, + target, + value, + }; + return createRequest({ + requestType: "POST", + path: "/filters/delete", + body: new URLSearchParams(params), + params: {}, + apiBaseURL: this._api_base_url, + }); + } + + // AMAZON S3 BULK HELPERS (bulkapi root) + + validateS3file({ + file_name, + has_header_row, + remove_duplicates, + email_address_column = null, + return_url = false, + first_name_column = false, + last_name_column = false, + gender_column = false, + ip_address_column = false, + remove_duplicate = null, + allowPhase2 = null, + }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!file_name) { + parameterIsMissing("file_name"); + return; + } + const fields = { file_name }; + if (has_header_row != null) fields.has_header_row = has_header_row; + if (remove_duplicates != null) fields.remove_duplicates = remove_duplicates; + if (remove_duplicate != null) fields.remove_duplicate = remove_duplicate; + if (email_address_column != null) fields.email_address_column = email_address_column; + if (return_url) fields.return_url = return_url; + if (first_name_column) fields.first_name_column = first_name_column; + if (last_name_column) fields.last_name_column = last_name_column; + if (gender_column) fields.gender_column = gender_column; + if (ip_address_column) fields.ip_address_column = ip_address_column; + if (allowPhase2 != null) fields.allow_phase_2 = String(allowPhase2); + return this._sendBulkRootFormPost("/validateS3file", fields); + } + + scoreS3file({ + file_name, + has_header_row, + remove_duplicates, + email_address_column = null, + remove_duplicate = null, + }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!file_name) { + parameterIsMissing("file_name"); + return; + } + const fields = { file_name }; + if (has_header_row != null) fields.has_header_row = has_header_row; + if (remove_duplicates != null) fields.remove_duplicates = remove_duplicates; + if (remove_duplicate != null) fields.remove_duplicate = remove_duplicate; + if (email_address_column != null) fields.email_address_column = email_address_column; + return this._sendBulkRootFormPost("/scoreS3file", fields); + } + + exportS3file({ file_id, export_bucket }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!file_id) { + parameterIsMissing("file_id"); + return; + } else if (!export_bucket) { + parameterIsMissing("export_bucket"); + return; + } + return this._sendBulkRootFormPost("/exportS3file", { + file_id, + export_bucket, + }); + } + // EMAIL FINDER /** diff --git a/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index 803dbd4..c182d4a 100644 --- a/tests/zero-bounce.test.js +++ b/tests/zero-bounce.test.js @@ -363,7 +363,8 @@ describe("ZeroBounceSDK", () => { }; jest.spyOn(global, "fetch").mockImplementationOnce((url, init) => { - expect(String(url)).toContain("/scoring?"); + expect(String(url)).toContain("/scoring"); + expect(String(url)).not.toContain("api_key="); expect(init.method).toBe("POST"); expect(init.body).toBeInstanceOf(URLSearchParams); expect(init.body.get("email")).toBe(email); @@ -1134,4 +1135,113 @@ describe("ZeroBounceSDK", () => { expect(response).toEqual(expectedResponse); }); }); + + describe("Priority 3 bulk APIs", () => { + class File {} + const file = new File(); + + const jsonOk = (data) => + Promise.resolve({ + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + ok: true, + headers: { get: () => "application/json" }, + }); + + beforeEach(() => { + zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); + }); + + it("sendEmailFinderFile targets bulkapi email-finder sendfile", async () => { + const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => + jsonOk({ success: true, file_id: "fid" }) + ); + await zeroBounceSDK.sendEmailFinderFile({ file, domain_column: 1 }); + expect(String(fetch.mock.calls[0][0])).toContain( + "https://bulkapi.zerobounce.net/email-finder/sendfile" + ); + }); + + it("getDomainSearchFileStatus uses domain-search filestatus on bulk root", async () => { + const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => + jsonOk({ success: true, file_status: "Complete" }) + ); + await zeroBounceSDK.getDomainSearchFileStatus("file-123"); + expect(String(fetch.mock.calls[0][0])).toContain( + "https://bulkapi.zerobounce.net/domain-search/filestatus" + ); + expect(String(fetch.mock.calls[0][0])).toContain("file_id=file-123"); + }); + + it("sendListEvaluatorFile posts to v2 listevaluator", async () => { + const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => + jsonOk({ file_id: "le-1" }) + ); + await zeroBounceSDK.sendListEvaluatorFile({ file, email_address_column: 2 }); + expect(String(fetch.mock.calls[0][0])).toBe( + "https://bulkapi.zerobounce.net/v2/listevaluator/" + ); + }); + + it("getListEvaluatorResult GETs file id path on bulk v2", async () => { + const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => + jsonOk({ file_status: "Complete" }) + ); + await zeroBounceSDK.getListEvaluatorResult("abc-def"); + expect(String(fetch.mock.calls[0][0])).toContain( + "https://bulkapi.zerobounce.net/v2/listevaluator/abc-def/" + ); + }); + + it("addFilter POSTs to main API filters/add", async () => { + const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => + jsonOk({ Message: "Filter successfully added" }) + ); + await zeroBounceSDK.addFilter({ + rule: "allow", + target: "domain", + value: "example.com", + }); + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toContain("https://api.zerobounce.net/v2/filters/add"); + expect(init.method).toBe("POST"); + }); + + it("listFilters supports POST method option", async () => { + const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => + jsonOk([]) + ); + await zeroBounceSDK.listFilters({ method: "POST" }); + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toContain("/filters/list"); + expect(init.method).toBe("POST"); + }); + + it("validateS3file posts to bulkapi validateS3file", async () => { + const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => + jsonOk({ success: true, file_id: "s3-job" }) + ); + await zeroBounceSDK.validateS3file({ + file_name: "emails/list.csv", + has_header_row: true, + remove_duplicates: true, + }); + expect(String(fetch.mock.calls[0][0])).toBe( + "https://bulkapi.zerobounce.net/validateS3file" + ); + }); + + it("exportS3file posts file_id and export_bucket", async () => { + const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => + jsonOk({ success: true }) + ); + await zeroBounceSDK.exportS3file({ + file_id: "job-1", + export_bucket: "my-bucket", + }); + const body = fetch.mock.calls[0][1].body; + expect(body.get("file_id")).toBe("job-1"); + expect(body.get("export_bucket")).toBe("my-bucket"); + }); + }); }); \ No newline at end of file diff --git a/zeroBounceSDK.d.ts b/zeroBounceSDK.d.ts index 73a1b8d..37ce8e0 100644 --- a/zeroBounceSDK.d.ts +++ b/zeroBounceSDK.d.ts @@ -346,6 +346,127 @@ export interface SendScoringFileStreamOptions { remove_duplicate?: boolean; } +/** Options for bulk email finder sendfile. */ +export interface SendEmailFinderFileOptions { + file: File; + domain_column: number; + first_name_column?: number | false; + last_name_column?: number | false; + middle_name_column?: number | false; + full_name_column?: number | false; + has_header_row?: boolean; +} + +export interface SendEmailFinderFileStreamOptions { + domain_column: number; + first_name_column?: number | false; + last_name_column?: number | false; + middle_name_column?: number | false; + full_name_column?: number | false; + has_header_row?: boolean; +} + +/** Options for bulk domain search sendfile. */ +export interface SendDomainSearchFileOptions { + file: File; + domain_column: number; + has_header_row?: boolean; +} + +export interface SendDomainSearchFileStreamOptions { + domain_column: number; + has_header_row?: boolean; +} + +/** POST /v2/listevaluator/ success (201). */ +export interface ListEvaluatorSubmitResponse { + file_id?: string; + message?: string; + success?: boolean; +} + +/** GET /v2/listevaluator/{file_id}/ while processing or complete. */ +export interface ListEvaluatorResultResponse { + file_status?: string; + success?: boolean; + message?: string; + [key: string]: unknown; +} + +export interface SendListEvaluatorFileOptions { + file: File; + email_address_column: number; +} + +export interface SendListEvaluatorFileStreamOptions { + email_address_column: number; +} + +export interface SendEvaluatedFileOptions { + file: File; +} + +export type FilterRule = "allow" | "block"; +export type FilterTarget = "email" | "domain" | "mx" | "tld"; + +export interface FilterRuleParams { + rule: FilterRule; + target: FilterTarget; + value: string; +} + +export interface FilterListItem { + rule: FilterRule; + target: FilterTarget; + value: string; +} + +export interface FilterMessageResponse { + Message?: string; + Error?: string; +} + +export interface ListFiltersOptions { + /** When `"POST"`, uses form-encoded POST (supported from Sep 2026). */ + method?: "GET" | "POST"; +} + +export interface ValidateS3FileOptions { + file_name: string; + has_header_row?: boolean; + /** Postman field name for duplicate removal. */ + remove_duplicates?: boolean; + /** Alternate field name used by some bulk endpoints. */ + remove_duplicate?: boolean; + email_address_column?: number; + return_url?: string | false; + first_name_column?: number | false; + last_name_column?: number | false; + gender_column?: number | false; + ip_address_column?: number | false; + allowPhase2?: boolean | null; +} + +export interface ScoreS3FileOptions { + file_name: string; + has_header_row?: boolean; + remove_duplicates?: boolean; + remove_duplicate?: boolean; + email_address_column?: number; +} + +export interface ExportS3FileOptions { + file_id: string; + export_bucket: string; +} + +export interface S3BulkJobResponse { + success?: boolean; + message?: string; + file_id?: string; + export_path?: string; +} + /** Options for findEmailByDomain / Email Finder. */ export interface FindEmailByDomainOptions { domain: string; @@ -532,6 +653,92 @@ export class ZeroBounceSDK { fileId: string ): Promise; + sendEmailFinderFile( + options: SendEmailFinderFileOptions + ): Promise; + + sendEmailFinderFileStream( + fileStreamOrBlob: File | Blob, + fileName: string, + options?: SendEmailFinderFileStreamOptions + ): Promise; + + getEmailFinderFileStatus( + fileId: string + ): Promise; + + getEmailFinderFile( + fileId: string + ): Promise; + + deleteEmailFinderFile( + fileId: string + ): Promise; + + sendDomainSearchFile( + options: SendDomainSearchFileOptions + ): Promise; + + sendDomainSearchFileStream( + fileStreamOrBlob: File | Blob, + fileName: string, + options?: SendDomainSearchFileStreamOptions + ): Promise; + + getDomainSearchFileStatus( + fileId: string + ): Promise; + + getDomainSearchFile( + fileId: string + ): Promise; + + deleteDomainSearchFile( + fileId: string + ): Promise; + + sendListEvaluatorFile( + options: SendListEvaluatorFileOptions + ): Promise; + + sendListEvaluatorFileStream( + fileStreamOrBlob: File | Blob, + fileName: string, + options?: SendListEvaluatorFileStreamOptions + ): Promise; + + getListEvaluatorResult( + fileId: string + ): Promise; + + sendEvaluatedFile( + options: SendEvaluatedFileOptions + ): Promise; + + addFilter( + options: FilterRuleParams + ): Promise; + + listFilters( + options?: ListFiltersOptions | null + ): Promise; + + deleteFilter( + options: FilterRuleParams + ): Promise; + + validateS3file( + options: ValidateS3FileOptions + ): Promise; + + scoreS3file( + options: ScoreS3FileOptions + ): Promise; + + exportS3file( + options: ExportS3FileOptions + ): Promise; + /** * Email Finder: find email by domain and name. */ From b759b1642df4c07e6828bafea25527884232cd70 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:17:25 +0400 Subject: [PATCH 4/5] feat: POST /validate transport parity and listS3files Add form-encoded POST option on single validate (alongside GET) and GET listS3files on bulkapi root, with unit tests and API parity doc updates. --- src/zero-bounce.js | 32 +++++++++++++++++++++++++++++++ tests/ts-consumer.ts | 1 + tests/zero-bounce.test.js | 40 +++++++++++++++++++++++++++++++++++++++ zeroBounceSDK.d.ts | 13 +++++++++++++ 4 files changed, 86 insertions(+) diff --git a/src/zero-bounce.js b/src/zero-bounce.js index a5accf1..a2e6a6e 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -117,6 +117,7 @@ export class ZeroBounceSDK { * @param options.timeout - validation timeout in seconds, 3-60 (optional). If met, the API will return unknown/greylisted. * @param options.activityData - when true/false, sends activity_data query param (optional) * @param options.verifyPlus - when true/false, sends verify_plus query param (optional) + * @param options.method - `"POST"` for form-encoded POST (default GET) * */ validateEmail(email, options = null) { if (!this._initialized) { @@ -154,6 +155,19 @@ export class ZeroBounceSDK { if (activityData != null) params.activity_data = activityData ? "true" : "false"; if (verifyPlus != null) params.verify_plus = verifyPlus ? "true" : "false"; + const usePost = + options && typeof options === "object" && options.method === "POST"; + + if (usePost) { + return createRequest({ + requestType: "POST", + path: "/validate", + body: new URLSearchParams(params), + params: {}, + apiBaseURL: this._api_base_url, + }); + } + return createRequest({ requestType: "GET", params, path: "/validate", apiBaseURL: this._api_base_url }); } @@ -985,6 +999,24 @@ export class ZeroBounceSDK { // AMAZON S3 BULK HELPERS (bulkapi root) + listS3files({ bucket = null, search = null } = {}) { + if (!this._initialized) { + notInitialized(); + return; + } + const params = { api_key: this._api_key }; + if (bucket != null) params.bucket = bucket; + if (search != null) params.search = search; + return createRequest({ + requestType: "GET", + params, + path: "/listS3files", + batch: true, + bulkBase: "root", + apiBaseURL: this._api_base_url, + }); + } + validateS3file({ file_name, has_header_row, diff --git a/tests/ts-consumer.ts b/tests/ts-consumer.ts index eaa2340..fc1da64 100644 --- a/tests/ts-consumer.ts +++ b/tests/ts-consumer.ts @@ -52,6 +52,7 @@ const validateOpts: ValidateEmailOptions = { timeout: 10, activityData: true, verifyPlus: false, + method: "POST", }; const batchOpts: ValidateBatchOptions = { diff --git a/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index c182d4a..99ce400 100644 --- a/tests/zero-bounce.test.js +++ b/tests/zero-bounce.test.js @@ -318,6 +318,31 @@ describe("ZeroBounceSDK", () => { expect(calledUrl).toContain("activity_data=true"); expect(calledUrl).toContain("verify_plus=false"); }); + + it("should send POST with form body when method is POST", async () => { + const expectedResponse = { status: "valid", address: email }; + + jest.spyOn(global, "fetch").mockImplementationOnce((url, init) => { + expect(String(url)).toContain("/validate"); + expect(String(url)).not.toContain("api_key="); + expect(init.method).toBe("POST"); + expect(init.body).toBeInstanceOf(URLSearchParams); + expect(init.body.get("email")).toBe(email); + expect(init.body.get("api_key")).toBe("valid-api-key"); + expect(init.body.get("ip_address")).toBe(ip_address); + return Promise.resolve({ + json: () => Promise.resolve(expectedResponse), + text: () => Promise.resolve(JSON.stringify(expectedResponse)), + }); + }); + + zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); + const response = await zeroBounceSDK.validateEmail(email, { + ip_address, + method: "POST", + }); + expect(response).toEqual(expectedResponse); + }); }); describe("scoreEmail", () => { @@ -1217,6 +1242,21 @@ describe("ZeroBounceSDK", () => { expect(init.method).toBe("POST"); }); + it("listS3files GETs bulkapi listS3files with optional bucket and search", async () => { + const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => + jsonOk([{ name: "uploads/list.csv", size: 100, last_modified: "2024-01-01T00:00:00Z" }]) + ); + await zeroBounceSDK.listS3files({ + bucket: "my-bucket", + search: "list", + }); + const calledUrl = String(fetch.mock.calls[0][0]); + expect(calledUrl).toBe( + "https://bulkapi.zerobounce.net/listS3files?api_key=valid-api-key&bucket=my-bucket&search=list" + ); + expect(fetch.mock.calls[0][1].method).toBe("GET"); + }); + it("validateS3file posts to bulkapi validateS3file", async () => { const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => jsonOk({ success: true, file_id: "s3-job" }) diff --git a/zeroBounceSDK.d.ts b/zeroBounceSDK.d.ts index 37ce8e0..3499fe3 100644 --- a/zeroBounceSDK.d.ts +++ b/zeroBounceSDK.d.ts @@ -256,6 +256,8 @@ export interface ValidateEmailOptions { activityData?: boolean; /** Maps to `verify_plus` on GET /validate. */ verifyPlus?: boolean; + /** Use form-encoded POST instead of GET (same parameters). */ + method?: "GET" | "POST"; } /** Single item for validateBatch (email_batch array). */ @@ -460,6 +462,13 @@ export interface ExportS3FileOptions { export_bucket: string; } +/** Single entry from GET /listS3files. */ +export interface S3ListFileEntry { + name: string; + size: number; + last_modified: string; +} + export interface S3BulkJobResponse { success?: boolean; message?: string; @@ -727,6 +736,10 @@ export class ZeroBounceSDK { options: FilterRuleParams ): Promise; + listS3files( + options?: { bucket?: string | null; search?: string | null } | null + ): Promise; + validateS3file( options: ValidateS3FileOptions ): Promise; From c649892725d59a16cb4a1f79187e746e92650316 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:31:08 +0400 Subject: [PATCH 5/5] fix: reject short API errors and cover requests offline HTML, empty, and account-level failures now reject with a short message, required column 0 and an explicit false header row are sent, and the default test run checks every public method without the network. Co-authored-by: Cursor --- .github/workflows/sdk_ci.yml | 6 + AGENTS.md | 64 +++ CHANGELOG.md | 101 +++++ src/utils.js | 206 ++++++++-- src/zero-bounce.js | 258 +++++++------ tests/offline-api.test.js | 727 +++++++++++++++++++++++++++++++++++ tests/zero-bounce.test.js | 214 ++++------- 7 files changed, 1288 insertions(+), 288 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 tests/offline-api.test.js diff --git a/.github/workflows/sdk_ci.yml b/.github/workflows/sdk_ci.yml index c507f66..63402c8 100644 --- a/.github/workflows/sdk_ci.yml +++ b/.github/workflows/sdk_ci.yml @@ -28,5 +28,11 @@ jobs: - name: Install dependencies run: npm ci + - name: Run tests and typecheck + run: npm test + - name: Run tests with coverage run: npm run test:coverage + + - name: Typecheck declarations + run: npm run dts-check diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3a333d6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project + +`@zerobounce/zero-bounce-sdk` is the ZeroBounce JavaScript SDK. Callers construct `ZeroBounceSDK`, call `init` with an API key, and `await` the method. HTTP goes through global `fetch` inside `createRequest` in `src/utils.js`. Releases are git tags. The Publish workflow ships that tag to npm. + +## Layout + +- `src/zero-bounce.js`: public client. +- `src/utils.js`: URLs, timeouts, and HTTP errors. +- `src/index.js`: default export used by the webpack build. +- `zeroBounceSDK.d.ts`: published TypeScript types. `npm run dts-check` typechecks this file. `tests/ts-consumer.ts` typechecks a consumer (`npm run test:types`). +- `tests/offline-api.test.js`: offline suite. A fake `fetch` records every request and answers without the network. +- `tests/zero-bounce.test.js` and `tests/getfile-node.test.js`: existing Jest cases. +- `package.json` and `package-lock.json`: the version fields. There is no runtime version constant and no custom User-Agent. + +## Commands + +```bash +npm ci +npm test # jest && tsc --noEmit -p tsconfig.test.json +npm run dts-check # tsc --noEmit on zeroBounceSDK.d.ts +npm run test:coverage # jest --coverage +npm run build # webpack production bundle; also runs from prepublishOnly +``` + +There is no separate lint command. CI (`.github/workflows/sdk_ci.yml`) runs `npm test`, coverage, and `npm run dts-check`. The publish workflow's test gate is `npm test`, which is stricter than coverage alone because it includes the TypeScript consumer check. + +## Conventions + +- **One `ZeroBounceSDK` per API key.** The key is stored on the instance. Do not reuse one instance across users or requests. Construct a separate object per key. The offline concurrency test does this. +- **Column indexes are 1-based and are not converted.** Optional name, gender, and IP columns use `0` or `false` to mean "leave this field out". Required `email_address_column` and `domain_column` are sent as given, including `0`. `0` is not "missing". +- **Optional parameters are omitted when unset.** `timeout`, `activityData`, `verifyPlus`, `allowPhase2`, `downloadType`, `ip_address`, and email-finder name fields are not sent until the caller sets them. Explicit `false` is sent as the string `false` on query, form, and multipart bodies, and as a JSON boolean on `validateBatch`. +- **Do not use boolean `false` as the "omit this column" sentinel inside `_sendBulkRootMultipartFile`.** `false` is a real `has_header_row` value and must be appended as `"false"`. Optional columns that are unset are `null`, and `null` is omitted. `optionalColumn` turns `0` and `false` into `null` for those optional columns only. +- **`allow_phase_2` is validation bulk only** (`sendFile`, `sendFileStream`, `validateS3file`), and only when `allowPhase2` is set. Scoring `sendFile` ignores it. `activity_data` on get-file is validation `getFile` only. +- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. Bulk email finder and domain search use the bulk host root (`/email-finder/…`, `/domain-search/…`) with no `/v2`. List evaluator and validation/scoring file methods use `https://bulkapi.zerobounce.net/v2`. +- **Failures reject the promise.** The message must be short: no raw HTML, no megabyte body, no `SyntaxError` / `Unexpected token` text, and no `Error: Error:` or `[Error]:` prefix. An account-level `validateBatch` error (`email_address` `all`) rejects. A per-address error stays on the resolved result. A top-level `error` string for an invalid API key or no credits rejects. `{ error: "invalid email" }` from `scoreEmail` stays on the result. A bulk JSON body such as `{ success: "False", message: […] }` is returned when it is not one of those account-level `error` strings. +- **`getFile` in a browser** downloads the CSV and resolves to the filename. **In Node** it resolves to the CSV text. A JSON error body (for example `success: false`) is returned as the parsed object. HTML, an empty body, and HTTP status 400 or higher reject. +- **`init` requires an `https://` base URL** and a non-blank API key. Requests time out after 120 seconds. `fetch` is called with `redirect: "error"`. +- **Tests replace global `fetch`.** Do not call the live API from `npm test`. `tests/real-api-prod.mjs` is opt-in (`npm run test:real-api`) and is not part of `npm test`. +- Match the surrounding style. Do not reformat unrelated code. Do not commit `dist/`, `node_modules/`, `coverage/`, or `.env`. + +## Changelog + +`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). + +- Add every user-visible change to `## [Unreleased]` in the same commit as the change. +- Group entries under these headings, in this order, omitting empty ones: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. +- Write for SDK users: name the method, option, or behaviour, and say what changed. Leave out CI-only changes, internal refactors, and fixes to code that was never released. +- At 2.y.z, a breaking change bumps the major version, a new method or option bumps the minor version, and fixes alone bump the patch version. + +## Releasing + +Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. + +1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links at the bottom of `CHANGELOG.md`. +2. Set the same version in all of these. The publish workflow fails if `package.json` differs from the tag: + - `package.json` `"version"` + - `package-lock.json` top-level `"version"` + - `package-lock.json` `packages[""].version` +3. `npm test`, `npm run dts-check`, and `npm run test:coverage`. +4. Tag `vx.y.z` on that commit and run the Publish workflow with that tag. It publishes `@zerobounce/zero-bounce-sdk` to npm and creates the GitHub release. There is no runtime version constant to update. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..17a25b8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,101 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `validateEmail` accepts `activityData`, `verifyPlus`, and `method: "POST"` (form-encoded `POST /v2/validate`). `timeout` is still optional and must be from 3 to 60 seconds. +- `validateBatch` accepts `timeout` (10–120 seconds), `activityData`, and `verifyPlus`. +- `scoreEmail` calls `GET /v2/scoring`. `scoreEmail(email, { method: "POST" })` sends a form-encoded `POST /v2/scoring`. +- Bulk email finder: `sendEmailFinderFile`, `sendEmailFinderFileStream`, `getEmailFinderFileStatus`, `getEmailFinderFile`, `deleteEmailFinderFile`. +- Bulk domain search: `sendDomainSearchFile`, `sendDomainSearchFileStream`, `getDomainSearchFileStatus`, `getDomainSearchFile`, `deleteDomainSearchFile`. +- List evaluator: `sendListEvaluatorFile`, `sendListEvaluatorFileStream`, `getListEvaluatorResult`, `sendEvaluatedFile`. +- Filters: `addFilter`, `listFilters` (GET, or POST when `method` is `"POST"`), `deleteFilter`. +- S3 bulk: `validateS3file`, `scoreS3file`, `exportS3file`, `listS3files`. + +### Changed + +- `init` rejects an `apiBaseURL` that is not `https://`. +- API requests abort after 120 seconds and do not follow redirects. +- Missing or invalid arguments reject the promise. They no longer log with `console.error` and return `undefined`. + +### Fixed + +- A 403 HTML "Access denied" page is a short error. `getFile` no longer puts the page in the error (about 1.4 MB from the live API). An HTML page with status 200 is an error as well, including `getFile`, `getEmailFinderFile`, and `getDomainSearchFile`, instead of a saved CSV or a JSON parse. +- Empty and non-JSON bodies are a short error. The message is no longer `SyntaxError: …` text, and a repeated `Error:` / `[Error]:` prefix is removed. +- `validateBatch` rejects an account-level failure (HTTP 200, error for address `all`, such as an invalid API key or no credits). An error for one address stays on the resolved result. A top-level `error` string for an invalid API key or no credits also rejects. A per-email scoring error such as `{ error: "invalid email" }` stays on the resolved result. +- `sendEmailFinderFile` and `sendDomainSearchFile` send `has_header_row` when it is set to false. `sendFile` and `sendScoringFile` send `has_header_row` and `remove_duplicate` as `true` or `false`. +- Required column indexes are sent as given, including `0`: `email_address_column` on `sendFile`, `sendFileStream`, `sendScoringFile`, `sendScoringFileStream`, and the list evaluator, and `domain_column` on bulk email finder and domain search. Optional name, gender, and IP columns of `0` are still left out. +- `findEmailByDomain`, `findEmailByCompanyName`, and `guessFormat` omit unset name fields. They no longer send the string `null`. +- `init("")` and a whitespace-only API key throw before any request. + +### Security + +- Redirects are disabled, so a response cannot send the client, or the API key in the query string, to another host. +- A custom API base URL cannot be `http://`. +- Rejected errors no longer include the raw HTML access-denied page. + +## [2.1.8] - 2026-06-18 + +### Fixed + +- `validateBatch` posts to `api.zerobounce.net/v2/validatebatch` instead of `bulkapi.zerobounce.net`. + +## [2.1.6] - 2026-06-11 + +### Fixed + +- `getFile` and `getScoringFile` return the CSV text in Node.js. In a browser they still download `result.csv` or `result-scoring.csv`. + +## [2.1.5] - 2026-06-10 + +### Added + +- Validate responses include `catchall_domain`. + +## [2.1.0] - 2026-04-05 + +### Added + +- `sendFile` and `sendFileStream` take `allowPhase2`, sent as `allow_phase_2` on validation bulk only. +- `getFile` and `getScoringFile` take `downloadType` (`ZeroBounceSDK.ZBDownloadType`: `phase_1`, `phase_2`, `combined`). Validation `getFile` also takes `activityData`. +- TypeScript response types for validation, batch validation, and API usage. + +## [2.0.11] - 2026-03-03 + +### Changed + +- License the project under MIT. + +## [2.0.0] - 2026-03-03 + +### Added + +- TypeScript declarations in `zeroBounceSDK.d.ts`. +- `package.json` `exports` for Node.js. + +## [1.3.0] - 2026-01-15 + +### Added + +- First tagged release: `getCredits`, `validateEmail` (including `timeout`), `getApiUsage`, `validateBatch`, `getEmailActivity`, bulk validation and scoring file methods, `findEmailByDomain`, `findEmailByCompanyName`, `findEmailFormatByDomain`, and `findEmailFormatByCompanyName`. +- `init(apiKey, apiBaseURL)` selects the default, US, or EU API host. +- `ZBValidateSubStatus.ACCEPT_ALL`. + +### Deprecated + +- `guessFormat`. Use the email-finder or domain-search methods instead. + +[Unreleased]: https://github.com/zerobounce/zero-bounce-javascript/compare/v2.1.11...HEAD +[2.1.8]: https://github.com/zerobounce/zero-bounce-javascript/compare/v2.1.6...v2.1.8 +[2.1.6]: https://github.com/zerobounce/zero-bounce-javascript/compare/v2.1.5...v2.1.6 +[2.1.5]: https://github.com/zerobounce/zero-bounce-javascript/compare/v2.1.0...v2.1.5 +[2.1.0]: https://github.com/zerobounce/zero-bounce-javascript/compare/v2.0.11...v2.1.0 +[2.0.11]: https://github.com/zerobounce/zero-bounce-javascript/compare/v2.0.0...v2.0.11 +[2.0.0]: https://github.com/zerobounce/zero-bounce-javascript/compare/v1.3.0...v2.0.0 +[1.3.0]: https://github.com/zerobounce/zero-bounce-javascript/releases/tag/v1.3.0 diff --git a/src/utils.js b/src/utils.js index 757bfdd..acd0947 100644 --- a/src/utils.js +++ b/src/utils.js @@ -10,7 +10,6 @@ export const HEADERS = { export function requireHttpsUrl(url) { if (typeof url !== "string" || !/^https:\/\//i.test(url)) { - parameterIsInvalid("apiBaseURL", "Must be an https:// URL."); throw new Error("apiBaseURL must be an https:// URL"); } return url; @@ -65,8 +64,139 @@ function isBrowserEnvironment() { ); } +function responseStatus(response) { + return response && typeof response.status === "number" ? response.status : 200; +} + +function responseContentType(response) { + if (!response || !response.headers || typeof response.headers.get !== "function") return ""; + return String(response.headers.get("content-type") || "").toLowerCase(); +} + +function looksLikeHtml(body) { + const head = String(body).slice(0, 2000).toLowerCase(); + return ( + head.includes("]*>([\s\S]*?)<\/title>/i); + if (!match) return ""; + return match[1].replace(/\s+/g, " ").trim(); +} + +function stripErrorPrefix(message) { + let value = String(message).trim(); + for (;;) { + if (value.startsWith("Error:")) { + value = value.slice("Error:".length).trim(); + continue; + } + if (value.startsWith("[Error]:")) { + value = value.slice("[Error]:".length).trim(); + continue; + } + return value; + } +} + +function isAccountLevelText(value) { + return /invalid api key|api_key is invalid|ran out of credits|out of credits/i.test(value); +} + +function readableJsonMessage(parsed) { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + if (typeof parsed.error === "string" && parsed.error.trim()) return parsed.error.trim(); + if (typeof parsed.message === "string" && parsed.message.trim()) return parsed.message.trim(); + if (Array.isArray(parsed.message)) { + const parts = parsed.message.filter((item) => typeof item === "string" && item.trim()); + if (parts.length) return parts.join("; "); + } + return null; +} + +function accountLevelMessage(parsed) { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + if (Array.isArray(parsed.errors)) { + for (const item of parsed.errors) { + if (item && String(item.email_address || "").toLowerCase() === "all") { + const text = typeof item.error === "string" ? item.error.trim() : ""; + return stripErrorPrefix(text || "Invalid API Key or your account ran out of credits"); + } + } + } + if (typeof parsed.error === "string" && isAccountLevelText(parsed.error)) { + return stripErrorPrefix(parsed.error.trim()); + } + return null; +} + +function cloudflareTitle(parsed) { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || typeof parsed.title !== "string") { + return null; + } + const accessDenied = parsed.title.includes("Access denied") || parsed.title.includes("1020"); + if (accessDenied && parsed.cloudflare_error === true) return parsed.title; + return null; +} + +function messageFromBody(body, statusCode) { + const trimmed = String(body).trim(); + if (!trimmed) return `Empty response from ZeroBounce (HTTP ${statusCode})`; + if (looksLikeHtml(body)) { + const title = htmlTitle(body); + if (!title) return "ZeroBounce returned an HTML error page instead of an API response."; + return `ZeroBounce returned an HTML error page ("${title}") instead of an API response.`; + } + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + const title = cloudflareTitle(parsed); + if (title) return title; + const account = accountLevelMessage(parsed); + if (account) return account; + const readable = readableJsonMessage(parsed); + if (readable) return stripErrorPrefix(readable); + return "ZeroBounce returned an unexpected response."; + } catch { + return "ZeroBounce returned a response that is not valid JSON."; + } + } + if (trimmed.length > 1000) return "ZeroBounce returned a response that is not valid JSON."; + const message = stripErrorPrefix(trimmed); + return message || `Empty response from ZeroBounce (HTTP ${statusCode})`; +} + +function transportError(error) { + const name = error && error.name ? error.name : "Error"; + const message = error && error.message ? error.message : String(error); + const redacted = `${name}: ${message}`.replace(/([?&]api_key=)[^&\s"']+/gi, "$1***"); + return new Error(redacted); +} + +async function readBodyText(response) { + if (response && typeof response.text === "function") return await response.text(); + if (response && typeof response.json === "function") { + const data = await response.json(); + return typeof data === "string" ? data : JSON.stringify(data); + } + return ""; +} + function processGetFileResponseBody(finalResult, response, scoring) { - const ct = (response.headers.get("content-type") || "").toLowerCase(); + const status = responseStatus(response); + const ct = responseContentType(response); + if (looksLikeHtml(finalResult) || ct.includes("text/html") || status >= 400) { + throw new Error(messageFromBody(finalResult, status)); + } + if (!String(finalResult).trim()) { + throw new Error(`Empty response from ZeroBounce (HTTP ${status})`); + } + const trimmed = finalResult.trim(); const looksJson = ct.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("["); @@ -78,15 +208,7 @@ function processGetFileResponseBody(finalResult, response, scoring) { return parsed; } } catch { - /* treat as file body */ - } - } - - if (!response.ok) { - try { - return JSON.parse(finalResult); - } catch { - throw new Error(finalResult || `HTTP ${response.status}`); + throw new Error("ZeroBounce returned a response that is not valid JSON."); } } @@ -124,27 +246,51 @@ export async function createRequest({ }) { const url = buildRequestUrl({ batch, bulkBase, apiBaseURL, path, params }); + let response; try { - const response = await fetch(url, { + response = await fetch(url, { method: requestType, headers: HEADERS, body, signal: fetchTimeoutSignal(), + redirect: "error", }); - if (returnText) { - const finalResult = await response.text(); - return processGetFileResponseBody(finalResult, response, scoring); - } - if (response.status === 403) { - throw new Error('[Error]: api_key is invalid'); - } else { - const finalResult = await response.json(); - return finalResult; - } } catch (error) { - // Some fetch polyfills (e.g. node-fetch) embed the request URL, including api_key, in errors. - throw new Error(String(error).replace(/([?&]api_key=)[^&\s"']+/gi, "$1***")); + // Some fetch polyfills embed the request URL, including api_key, in errors. + throw transportError(error); + } + + let finalResult; + try { + finalResult = await readBodyText(response); + } catch (error) { + throw transportError(error); + } + + if (returnText) { + return processGetFileResponseBody(finalResult, response, scoring); + } + + const status = responseStatus(response); + const contentType = responseContentType(response); + if ( + looksLikeHtml(finalResult) || + contentType.includes("text/html") || + status >= 400 || + !String(finalResult).trim() + ) { + throw new Error(messageFromBody(finalResult, status)); + } + + let parsed; + try { + parsed = JSON.parse(finalResult); + } catch { + throw new Error(messageFromBody(finalResult, status)); } + const account = accountLevelMessage(parsed); + if (account) throw new Error(account); + return parsed; } function saveFile(blob, filename) { @@ -170,17 +316,15 @@ function saveFile(blob, filename) { } export function notInitialized() { - console.error("ZeroBounce: Call init function first with a valid api key."); + throw new Error("ZeroBounce: Call init function first with a valid api key."); } export function parameterIsMissing(parameter, aditionalInfo = "") { - console.error( - `ZeroBounce: ${parameter} parameter is missing. ${aditionalInfo}` - ); + const extra = aditionalInfo ? ` ${aditionalInfo}` : ""; + throw new Error(`ZeroBounce: ${parameter} parameter is missing.${extra}`); } export function parameterIsInvalid(parameter, aditionalInfo = "") { - console.error( - `ZeroBounce: ${parameter} parameter is invalid. ${aditionalInfo}` - ); + const extra = aditionalInfo ? ` ${aditionalInfo}` : ""; + throw new Error(`ZeroBounce: ${parameter} parameter is invalid.${extra}`); } diff --git a/src/zero-bounce.js b/src/zero-bounce.js index a2e6a6e..be8e3f3 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -65,6 +65,18 @@ export const ZBDownloadType = Object.freeze({ COMBINED: "combined" }); +function isMissing(value) { + return value == null || (typeof value === "string" && value.trim() === ""); +} + +function booleanText(value) { + return value ? "true" : "false"; +} + +function optionalColumn(value) { + return value ? value : null; +} + export class ZeroBounceSDK { static ApiURL = Object.freeze({ DEFAULT_API_URL: "https://api.zerobounce.net/v2", @@ -90,16 +102,15 @@ export class ZeroBounceSDK { * @param apiBaseURL - your preferred API, possible values being ApiURL.DEFAULT_API_URL, ApiURL.USAAPIURL, ApiURL.EUAPIURL * */ init(apiKey, apiBaseURL = ZeroBounceSDK.ApiURL.DEFAULT_API_URL) { - if (!apiKey) { - parameterIsMissing("Api key", "Please provide a valid API key."); - } else { - this._api_key = apiKey; - this._api_base_url = requireHttpsUrl(apiBaseURL); - this._initialized = true; + if (apiKey == null || String(apiKey).trim() === "") { + throw new Error("ZeroBounce: Api key parameter is missing. Please provide a valid API key."); } + this._api_key = apiKey; + this._api_base_url = requireHttpsUrl(apiBaseURL); + this._initialized = true; } - getCredits() { + async getCredits() { if (!this._initialized) { notInitialized(); return; @@ -119,11 +130,11 @@ export class ZeroBounceSDK { * @param options.verifyPlus - when true/false, sends verify_plus query param (optional) * @param options.method - `"POST"` for form-encoded POST (default GET) * */ - validateEmail(email, options = null) { + async validateEmail(email, options = null) { if (!this._initialized) { notInitialized(); return; - } else if (!email) { + } else if (isMissing(email)) { parameterIsMissing("Email"); return; } @@ -175,14 +186,14 @@ export class ZeroBounceSDK { * @param startDate - The start date * @param endDate - The end date * */ - getApiUsage(startDate, endDate) { + async getApiUsage(startDate, endDate) { if (!this._initialized) { notInitialized(); return; - } else if (!startDate) { + } else if (isMissing(startDate)) { parameterIsMissing("Start date", "Format: YYYY-MM-DD"); return; - } else if (!endDate) { + } else if (isMissing(endDate)) { parameterIsMissing("End date", "Format: YYYY-MM-DD"); return; } @@ -205,11 +216,11 @@ export class ZeroBounceSDK { * @param options - optional timeout (10-120 seconds), activityData, verifyPlus * */ - validateBatch(emailBatch, options = null) { + async validateBatch(emailBatch, options = null) { if (!this._initialized) { notInitialized(); return; - } else if (!emailBatch) { + } else if (emailBatch == null) { parameterIsMissing("Email list"); return; } @@ -246,11 +257,11 @@ export class ZeroBounceSDK { /** * @param email - a valid email address * */ - getEmailActivity(email) { + async getEmailActivity(email) { if (!this._initialized) { notInitialized(); return; - } else if (!email) { + } else if (isMissing(email)) { parameterIsMissing("Email"); return; } @@ -271,11 +282,11 @@ export class ZeroBounceSDK { * @param email - email to score * @param options - optional `{ method: "POST" }` to use form-encoded POST (default GET) * */ - scoreEmail(email, options = null) { + async scoreEmail(email, options = null) { if (!this._initialized) { notInitialized(); return; - } else if (!email) { + } else if (isMissing(email)) { parameterIsMissing("Email"); return; } @@ -318,7 +329,7 @@ export class ZeroBounceSDK { * @param remove_duplicate: Boolean - If you want the system to remove duplicate emails. * @param allowPhase2: Boolean|null - Optional. When set, sends {@code allow_phase_2} (validation bulk only; see v2 send file API). * */ - sendFile({ + async sendFile({ file, email_address_column, first_name_column = false, @@ -336,7 +347,7 @@ export class ZeroBounceSDK { } else if (!file) { parameterIsMissing("file"); return; - } else if (!email_address_column) { + } else if (isMissing(email_address_column)) { parameterIsMissing("email_address_column"); return; } @@ -360,11 +371,11 @@ export class ZeroBounceSDK { body.append("email_address_column", email_address_column); body.append("file", file); - body.append("has_header_row", has_header_row); - body.append("remove_duplicate", remove_duplicate); + body.append("has_header_row", booleanText(has_header_row)); + body.append("remove_duplicate", booleanText(remove_duplicate)); body.append("api_key", this._api_key); if (allowPhase2 != null) { - body.append("allow_phase_2", String(allowPhase2)); + body.append("allow_phase_2", booleanText(allowPhase2)); } return createRequest({ @@ -382,7 +393,7 @@ export class ZeroBounceSDK { * @param fileName: string - The file name to use for the upload (e.g. "emails.csv"). * @param options: object - Same options as sendFile (email_address_column, return_url, etc.). * */ - sendFileStream(fileStreamOrBlob, fileName, { + async sendFileStream(fileStreamOrBlob, fileName, { email_address_column, first_name_column = false, return_url = false, @@ -399,10 +410,10 @@ export class ZeroBounceSDK { } else if (!fileStreamOrBlob) { parameterIsMissing("fileStreamOrBlob"); return; - } else if (!fileName) { + } else if (isMissing(fileName)) { parameterIsMissing("fileName"); return; - } else if (!email_address_column) { + } else if (isMissing(email_address_column)) { parameterIsMissing("email_address_column"); return; } @@ -415,11 +426,11 @@ export class ZeroBounceSDK { if (ip_address_column) body.append("ip_address_column", ip_address_column); body.append("email_address_column", email_address_column); body.append("file", fileStreamOrBlob, fileName); - body.append("has_header_row", has_header_row); - body.append("remove_duplicate", remove_duplicate); + body.append("has_header_row", booleanText(has_header_row)); + body.append("remove_duplicate", booleanText(remove_duplicate)); body.append("api_key", this._api_key); if (allowPhase2 != null) { - body.append("allow_phase_2", String(allowPhase2)); + body.append("allow_phase_2", booleanText(allowPhase2)); } return createRequest({ @@ -438,7 +449,7 @@ export class ZeroBounceSDK { * @param has_header_row: Boolean - If the first row from the submitted file is a header row. * @param remove_duplicate: Boolean - If you want the system to remove duplicate emails. * */ - sendScoringFile({ + async sendScoringFile({ file, email_address_column, return_url = false, @@ -451,7 +462,7 @@ export class ZeroBounceSDK { } else if (!file) { parameterIsMissing("file: File"); return; - } else if (!email_address_column) { + } else if (isMissing(email_address_column)) { parameterIsMissing("email_address_column: number"); return; } @@ -464,9 +475,9 @@ export class ZeroBounceSDK { body.append("file", file); body.append("email_address_column", email_address_column); - body.append("has_header_row", has_header_row); + body.append("has_header_row", booleanText(has_header_row)); body.append("api_key", this._api_key); - body.append("remove_duplicate", remove_duplicate); + body.append("remove_duplicate", booleanText(remove_duplicate)); return createRequest({ requestType: "POST", @@ -483,7 +494,7 @@ export class ZeroBounceSDK { * @param fileName: string - e.g. "emails.csv" * @param options: object - email_address_column, return_url, has_header_row, remove_duplicate * */ - sendScoringFileStream(fileStreamOrBlob, fileName, { + async sendScoringFileStream(fileStreamOrBlob, fileName, { email_address_column, return_url = false, has_header_row = false, @@ -495,10 +506,10 @@ export class ZeroBounceSDK { } else if (!fileStreamOrBlob) { parameterIsMissing("fileStreamOrBlob"); return; - } else if (!fileName) { + } else if (isMissing(fileName)) { parameterIsMissing("fileName"); return; - } else if (!email_address_column) { + } else if (isMissing(email_address_column)) { parameterIsMissing("email_address_column"); return; } @@ -507,9 +518,9 @@ export class ZeroBounceSDK { if (return_url) body.append("return_url", return_url); body.append("file", fileStreamOrBlob, fileName); body.append("email_address_column", email_address_column); - body.append("has_header_row", has_header_row); + body.append("has_header_row", booleanText(has_header_row)); body.append("api_key", this._api_key); - body.append("remove_duplicate", remove_duplicate); + body.append("remove_duplicate", booleanText(remove_duplicate)); return createRequest({ requestType: "POST", @@ -520,11 +531,11 @@ export class ZeroBounceSDK { }); } - _getStatusUtil(fileId, path, { bulkRoot = false } = {}) { + async _getStatusUtil(fileId, path, { bulkRoot = false } = {}) { if (!this._initialized) { notInitialized(); return; - } else if (!fileId) { + } else if (isMissing(fileId)) { parameterIsMissing("File id"); return; } @@ -546,22 +557,22 @@ export class ZeroBounceSDK { /** * @param fileId - the id of a previously submmitted and accepted file * */ - getFileStatus(fileId) { + async getFileStatus(fileId) { return this._getStatusUtil(fileId, "/filestatus"); } /** * @param fileId - the id of a previously submmitted and accepted file * */ - getScoringFileStatus(fileId) { + async getScoringFileStatus(fileId) { return this._getStatusUtil(fileId, "/scoring/filestatus"); } - _getFileUtil(fileId, path, scoring = false, getFileOptions = null, { bulkRoot = false } = {}) { + async _getFileUtil(fileId, path, scoring = false, getFileOptions = null, { bulkRoot = false } = {}) { if (!this._initialized) { notInitialized(); return; - } else if (!fileId) { + } else if (isMissing(fileId)) { parameterIsMissing("File id"); return; } @@ -593,7 +604,7 @@ export class ZeroBounceSDK { * @param fileId - the id of a previously submmitted and accepted file * @param getFileOptions - optional {@code downloadType} ({@link ZBDownloadType} values) and {@code activityData} (validation only) * */ - getFile(fileId, getFileOptions = null) { + async getFile(fileId, getFileOptions = null) { return this._getFileUtil(fileId, "/getfile", false, getFileOptions); } @@ -601,15 +612,15 @@ export class ZeroBounceSDK { * @param fileId - the id of a previously submmitted and accepted file * @param getFileOptions - optional {@code downloadType}; {@code activityData} is not sent for scoring getfile * */ - getScoringFile(fileId, getFileOptions = null) { + async getScoringFile(fileId, getFileOptions = null) { return this._getFileUtil(fileId, "/scoring/getfile", true, getFileOptions); } - _deleteFileUtil(fileId, path, scoring = false, { bulkRoot = false } = {}) { + async _deleteFileUtil(fileId, path, scoring = false, { bulkRoot = false } = {}) { if (!this._initialized) { notInitialized(); return; - } else if (!fileId) { + } else if (isMissing(fileId)) { parameterIsMissing("File id"); return; } @@ -628,12 +639,11 @@ export class ZeroBounceSDK { }); } - _sendBulkRootMultipartFile(path, { file, fileName = null, fields = {} }) { + async _sendBulkRootMultipartFile(path, { file, fileName = null, fields = {} }) { const body = new FormData(); for (const [key, value] of Object.entries(fields)) { - if (value != null && value !== false) { - body.append(key, value); - } + if (value == null) continue; + body.append(key, typeof value === "boolean" ? booleanText(value) : value); } if (fileName != null) { body.append("file", file, fileName); @@ -651,7 +661,7 @@ export class ZeroBounceSDK { }); } - _sendBulkRootFormPost(path, fields) { + async _sendBulkRootFormPost(path, fields) { const params = { api_key: this._api_key, ...fields, @@ -670,20 +680,20 @@ export class ZeroBounceSDK { /** * @param fileId - the id of a previously submmitted and accepted file * */ - deleteFile(fileId) { + async deleteFile(fileId) { return this._deleteFileUtil(fileId, "/deletefile"); } /** * @param fileId - the id of a previously submmitted and accepted file * */ - deleteScoringFile(fileId) { + async deleteScoringFile(fileId) { return this._deleteFileUtil(fileId, "/scoring/deletefile", true); } // BULK EMAIL FINDER (bulkapi.zerobounce.net/email-finder/*) - sendEmailFinderFile({ + async sendEmailFinderFile({ file, domain_column, first_name_column = false, @@ -698,7 +708,7 @@ export class ZeroBounceSDK { } else if (!file) { parameterIsMissing("file"); return; - } else if (!domain_column) { + } else if (isMissing(domain_column)) { parameterIsMissing("domain_column"); return; } @@ -706,16 +716,16 @@ export class ZeroBounceSDK { file, fields: { domain_column, - first_name_column, - last_name_column, - middle_name_column, - full_name_column, - has_header_row, + first_name_column: optionalColumn(first_name_column), + last_name_column: optionalColumn(last_name_column), + middle_name_column: optionalColumn(middle_name_column), + full_name_column: optionalColumn(full_name_column), + has_header_row: Boolean(has_header_row), }, }); } - sendEmailFinderFileStream(fileStreamOrBlob, fileName, { + async sendEmailFinderFileStream(fileStreamOrBlob, fileName, { domain_column, first_name_column = false, last_name_column = false, @@ -729,10 +739,10 @@ export class ZeroBounceSDK { } else if (!fileStreamOrBlob) { parameterIsMissing("fileStreamOrBlob"); return; - } else if (!fileName) { + } else if (isMissing(fileName)) { parameterIsMissing("fileName"); return; - } else if (!domain_column) { + } else if (isMissing(domain_column)) { parameterIsMissing("domain_column"); return; } @@ -741,30 +751,30 @@ export class ZeroBounceSDK { fileName, fields: { domain_column, - first_name_column, - last_name_column, - middle_name_column, - full_name_column, - has_header_row, + first_name_column: optionalColumn(first_name_column), + last_name_column: optionalColumn(last_name_column), + middle_name_column: optionalColumn(middle_name_column), + full_name_column: optionalColumn(full_name_column), + has_header_row: Boolean(has_header_row), }, }); } - getEmailFinderFileStatus(fileId) { + async getEmailFinderFileStatus(fileId) { return this._getStatusUtil(fileId, "/email-finder/filestatus", { bulkRoot: true }); } - getEmailFinderFile(fileId) { + async getEmailFinderFile(fileId) { return this._getFileUtil(fileId, "/email-finder/getfile", false, null, { bulkRoot: true }); } - deleteEmailFinderFile(fileId) { + async deleteEmailFinderFile(fileId) { return this._deleteFileUtil(fileId, "/email-finder/deletefile", false, { bulkRoot: true }); } // BULK DOMAIN SEARCH (bulkapi.zerobounce.net/domain-search/*) - sendDomainSearchFile({ + async sendDomainSearchFile({ file, domain_column, has_header_row = false, @@ -775,17 +785,17 @@ export class ZeroBounceSDK { } else if (!file) { parameterIsMissing("file"); return; - } else if (!domain_column) { + } else if (isMissing(domain_column)) { parameterIsMissing("domain_column"); return; } return this._sendBulkRootMultipartFile("/domain-search/sendfile", { file, - fields: { domain_column, has_header_row }, + fields: { domain_column, has_header_row: Boolean(has_header_row) }, }); } - sendDomainSearchFileStream(fileStreamOrBlob, fileName, { + async sendDomainSearchFileStream(fileStreamOrBlob, fileName, { domain_column, has_header_row = false, } = {}) { @@ -795,42 +805,42 @@ export class ZeroBounceSDK { } else if (!fileStreamOrBlob) { parameterIsMissing("fileStreamOrBlob"); return; - } else if (!fileName) { + } else if (isMissing(fileName)) { parameterIsMissing("fileName"); return; - } else if (!domain_column) { + } else if (isMissing(domain_column)) { parameterIsMissing("domain_column"); return; } return this._sendBulkRootMultipartFile("/domain-search/sendfile", { file: fileStreamOrBlob, fileName, - fields: { domain_column, has_header_row }, + fields: { domain_column, has_header_row: Boolean(has_header_row) }, }); } - getDomainSearchFileStatus(fileId) { + async getDomainSearchFileStatus(fileId) { return this._getStatusUtil(fileId, "/domain-search/filestatus", { bulkRoot: true }); } - getDomainSearchFile(fileId) { + async getDomainSearchFile(fileId) { return this._getFileUtil(fileId, "/domain-search/getfile", false, null, { bulkRoot: true }); } - deleteDomainSearchFile(fileId) { + async deleteDomainSearchFile(fileId) { return this._deleteFileUtil(fileId, "/domain-search/deletefile", false, { bulkRoot: true }); } // LIST EVALUATOR (bulkapi.zerobounce.net/v2/listevaluator/) - sendListEvaluatorFile({ file, email_address_column }) { + async sendListEvaluatorFile({ file, email_address_column }) { if (!this._initialized) { notInitialized(); return; } else if (!file) { parameterIsMissing("file"); return; - } else if (!email_address_column) { + } else if (isMissing(email_address_column)) { parameterIsMissing("email_address_column"); return; } @@ -847,17 +857,17 @@ export class ZeroBounceSDK { }); } - sendListEvaluatorFileStream(fileStreamOrBlob, fileName, { email_address_column } = {}) { + async sendListEvaluatorFileStream(fileStreamOrBlob, fileName, { email_address_column } = {}) { if (!this._initialized) { notInitialized(); return; } else if (!fileStreamOrBlob) { parameterIsMissing("fileStreamOrBlob"); return; - } else if (!fileName) { + } else if (isMissing(fileName)) { parameterIsMissing("fileName"); return; - } else if (!email_address_column) { + } else if (isMissing(email_address_column)) { parameterIsMissing("email_address_column"); return; } @@ -874,11 +884,11 @@ export class ZeroBounceSDK { }); } - getListEvaluatorResult(fileId) { + async getListEvaluatorResult(fileId) { if (!this._initialized) { notInitialized(); return; - } else if (!fileId) { + } else if (isMissing(fileId)) { parameterIsMissing("File id"); return; } @@ -892,7 +902,7 @@ export class ZeroBounceSDK { }); } - sendEvaluatedFile({ file }) { + async sendEvaluatedFile({ file }) { if (!this._initialized) { notInitialized(); return; @@ -914,17 +924,17 @@ export class ZeroBounceSDK { // CUSTOM FILTERS (main API /v2/filters/*) - addFilter({ rule, target, value }) { + async addFilter({ rule, target, value }) { if (!this._initialized) { notInitialized(); return; - } else if (!rule) { + } else if (isMissing(rule)) { parameterIsMissing("rule"); return; - } else if (!target) { + } else if (isMissing(target)) { parameterIsMissing("target"); return; - } else if (!value) { + } else if (isMissing(value)) { parameterIsMissing("value"); return; } @@ -943,7 +953,7 @@ export class ZeroBounceSDK { }); } - listFilters(options = null) { + async listFilters(options = null) { if (!this._initialized) { notInitialized(); return; @@ -968,17 +978,17 @@ export class ZeroBounceSDK { }); } - deleteFilter({ rule, target, value }) { + async deleteFilter({ rule, target, value }) { if (!this._initialized) { notInitialized(); return; - } else if (!rule) { + } else if (isMissing(rule)) { parameterIsMissing("rule"); return; - } else if (!target) { + } else if (isMissing(target)) { parameterIsMissing("target"); return; - } else if (!value) { + } else if (isMissing(value)) { parameterIsMissing("value"); return; } @@ -999,7 +1009,7 @@ export class ZeroBounceSDK { // AMAZON S3 BULK HELPERS (bulkapi root) - listS3files({ bucket = null, search = null } = {}) { + async listS3files({ bucket = null, search = null } = {}) { if (!this._initialized) { notInitialized(); return; @@ -1017,7 +1027,7 @@ export class ZeroBounceSDK { }); } - validateS3file({ + async validateS3file({ file_name, has_header_row, remove_duplicates, @@ -1033,7 +1043,7 @@ export class ZeroBounceSDK { if (!this._initialized) { notInitialized(); return; - } else if (!file_name) { + } else if (isMissing(file_name)) { parameterIsMissing("file_name"); return; } @@ -1051,7 +1061,7 @@ export class ZeroBounceSDK { return this._sendBulkRootFormPost("/validateS3file", fields); } - scoreS3file({ + async scoreS3file({ file_name, has_header_row, remove_duplicates, @@ -1061,7 +1071,7 @@ export class ZeroBounceSDK { if (!this._initialized) { notInitialized(); return; - } else if (!file_name) { + } else if (isMissing(file_name)) { parameterIsMissing("file_name"); return; } @@ -1073,14 +1083,14 @@ export class ZeroBounceSDK { return this._sendBulkRootFormPost("/scoreS3file", fields); } - exportS3file({ file_id, export_bucket }) { + async exportS3file({ file_id, export_bucket }) { if (!this._initialized) { notInitialized(); return; - } else if (!file_id) { + } else if (isMissing(file_id)) { parameterIsMissing("file_id"); return; - } else if (!export_bucket) { + } else if (isMissing(export_bucket)) { parameterIsMissing("export_bucket"); return; } @@ -1098,7 +1108,7 @@ export class ZeroBounceSDK { * @param middle_name str or null - middle name * @param last_name str or null - last name * */ - findEmailByDomain({ + async findEmailByDomain({ domain, first_name, middle_name = null, @@ -1118,7 +1128,7 @@ export class ZeroBounceSDK { * @param middle_name str or null - middle name * @param last_name str or null - last name * */ - findEmailByCompanyName({ + async findEmailByCompanyName({ company_name, first_name, middle_name = null, @@ -1132,7 +1142,7 @@ export class ZeroBounceSDK { }); } - _findEmail({ + async _findEmail({ domain = null, company_name = null, first_name, @@ -1142,11 +1152,11 @@ export class ZeroBounceSDK { if (!this._initialized) { notInitialized(); return; - } else if (!domain && !company_name) { + } else if (isMissing(domain) && isMissing(company_name)) { parameterIsMissing("domain"); parameterIsMissing("company_name"); return; - } else if (!first_name) { + } else if (isMissing(first_name)) { parameterIsMissing("first_name"); return; } @@ -1154,9 +1164,9 @@ export class ZeroBounceSDK { const params = { api_key: this._api_key, first_name: first_name, - middle_name: middle_name, - last_name: last_name, }; + if (!isMissing(middle_name)) params.middle_name = middle_name; + if (!isMissing(last_name)) params.last_name = last_name; if (domain != null) { params["domain"] = domain; @@ -1178,7 +1188,7 @@ export class ZeroBounceSDK { /** * @param domain str - domain name * */ - findEmailFormatByDomain({ + async findEmailFormatByDomain({ domain }) { return this._findEmailFormat({ @@ -1189,7 +1199,7 @@ export class ZeroBounceSDK { /** * @param company_name str - company name * */ - findEmailFormatByCompanyName({ + async findEmailFormatByCompanyName({ company_name }) { return this._findEmailFormat({ @@ -1197,14 +1207,14 @@ export class ZeroBounceSDK { }); } - _findEmailFormat({ + async _findEmailFormat({ domain = null, company_name = null }) { if (!this._initialized) { notInitialized(); return; - } else if (!domain && !company_name) { + } else if (isMissing(domain) && isMissing(company_name)) { parameterIsMissing("domain"); parameterIsMissing("company_name"); return; @@ -1236,7 +1246,7 @@ export class ZeroBounceSDK { * @param middle_name str or null - middle name * @param last_name str or null - last name * */ - guessFormat ({ + async guessFormat ({ domain, first_name = null, middle_name = null, @@ -1246,7 +1256,7 @@ export class ZeroBounceSDK { if (!this._initialized) { notInitialized(); return; - } else if (!domain) { + } else if (isMissing(domain)) { parameterIsMissing("domain"); return; } @@ -1254,10 +1264,10 @@ export class ZeroBounceSDK { const params = { api_key: this._api_key, domain: domain, - first_name: first_name, - middle_name: middle_name, - last_name: last_name, }; + if (!isMissing(first_name)) params.first_name = first_name; + if (!isMissing(middle_name)) params.middle_name = middle_name; + if (!isMissing(last_name)) params.last_name = last_name; return createRequest({ requestType: "GET", diff --git a/tests/offline-api.test.js b/tests/offline-api.test.js new file mode 100644 index 0000000..e6a6cd6 --- /dev/null +++ b/tests/offline-api.test.js @@ -0,0 +1,727 @@ +/** + * Offline API suite. Replaces global fetch; no network and no real API key. + */ +import { ZeroBounceSDK } from "../src/zero-bounce.js"; + +const HTML_PAGE = (() => { + const head = + 'Access denied | zerobounce.net | Zero Bounce'; + return `${head}${"

blocked

".repeat(20000)}`; +})(); + +const CF_JSON = JSON.stringify({ + type: "https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1020/", + title: "Error 1020: Access denied", + status: 403, + detail: "The request was blocked by a Cloudflare firewall rule configured by the site owner.", + error_code: 1020, + cloudflare_error: true, + what_you_should_do: "Do not retry. This block is intentional.", +}); + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function flattenJson(value) { + const fields = {}; + if (!value || typeof value !== "object" || Array.isArray(value)) return fields; + for (const [key, item] of Object.entries(value)) { + if (item == null) fields[key] = "null"; + else if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") { + fields[key] = String(item); + } else { + fields[key] = JSON.stringify(item); + } + } + return fields; +} + +async function readBody(body) { + if (body == null) return { fields: {}, json: null }; + if (typeof body === "string") { + const trimmed = body.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + const json = JSON.parse(trimmed); + return { fields: flattenJson(json), json }; + } + const params = new URLSearchParams(body); + return { fields: Object.fromEntries(params.entries()), json: null }; + } + if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) { + return { fields: Object.fromEntries(body.entries()), json: null }; + } + if (typeof FormData !== "undefined" && body instanceof FormData) { + const fields = {}; + for (const [key, value] of body.entries()) { + fields[key] = typeof value === "string" ? value : ""; + } + return { fields, json: null }; + } + return { fields: {}, json: null }; +} + +function httpResponse(status, body, contentType) { + return { + status, + ok: status >= 200 && status < 300, + headers: { + get(name) { + return String(name).toLowerCase() === "content-type" ? contentType : null; + }, + }, + text: async () => body, + json: async () => JSON.parse(body), + }; +} + +function successBody(request) { + const key = JSON.stringify(request.apiKey); + const path = request.path; + const ends = (suffix) => path.endsWith(suffix); + if (path.includes("getfile")) { + return { + status: 200, + contentType: "text/csv", + body: `email\n${request.apiKey}@example.com\n`, + }; + } + if (path.includes("validatebatch")) { + return { + status: 200, + contentType: "application/json", + body: `{"email_batch":[{"address":${key},"status":"valid"}],"errors":[],"echo_api_key":${key}}`, + }; + } + if (ends("/validate")) { + return { + status: 200, + contentType: "application/json", + body: `{"address":"person@example.com","status":"valid","echo_api_key":${key}}`, + }; + } + if (ends("/scoring")) { + return { + status: 200, + contentType: "application/json", + body: `{"email":"person@example.com","score":10,"echo_api_key":${key}}`, + }; + } + if (path.includes("getcredits")) { + return { + status: 200, + contentType: "application/json", + body: `{"Credits":"7","echo_api_key":${key}}`, + }; + } + if (path.includes("getapiusage")) { + return { + status: 200, + contentType: "application/json", + body: `{"total":1,"echo_api_key":${key}}`, + }; + } + if (ends("/activity")) { + return { + status: 200, + contentType: "application/json", + body: `{"found":true,"active_in_days":"3","echo_api_key":${key}}`, + }; + } + if (path.includes("guessformat")) { + return { + status: 200, + contentType: "application/json", + body: `{"email":"ada@example.com","domain":"example.com","echo_api_key":${key},"other_domain_formats":[]}`, + }; + } + return { + status: 200, + contentType: "application/json", + body: `{"success":true,"message":"ok","file_id":${key},"echo_api_key":${key}}`, + }; +} + +function answer(request) { + const key = request.apiKey; + if (key.startsWith("html-403-") || key.startsWith("html-200-")) { + return httpResponse(key.startsWith("html-403-") ? 403 : 200, HTML_PAGE, "text/html; charset=UTF-8"); + } + if (key.startsWith("empty-")) { + return httpResponse(200, "", "application/json"); + } + if (key.startsWith("malformed-")) { + return httpResponse(200, `<<>>${"x".repeat(5000)}`, "application/json"); + } + if (key.startsWith("cf-json-")) { + return httpResponse(403, CF_JSON, "application/json"); + } + if (key.startsWith("double-error-")) { + return httpResponse(400, "Error: Error: api_key is invalid", "text/plain"); + } + if (key.startsWith("invalid-key-")) { + return httpResponse(200, JSON.stringify({ error: "Invalid API Key" }), "application/json"); + } + if (key.startsWith("no-credits-")) { + return httpResponse( + 200, + JSON.stringify({ error: "Your account ran out of credits" }), + "application/json" + ); + } + if (pathIsBatch(request) && key.startsWith("batch-account-")) { + return httpResponse( + 200, + JSON.stringify({ + email_batch: [], + errors: [{ email_address: "all", error: "Invalid API Key or your account ran out of credits" }], + }), + "application/json" + ); + } + if (pathIsBatch(request) && key.startsWith("batch-partial-")) { + return httpResponse( + 200, + JSON.stringify({ + email_batch: [{ address: "good@example.com", status: "valid" }], + errors: [{ email_address: "bad@", error: "Invalid email address" }], + }), + "application/json" + ); + } + if (key.startsWith("score-item-")) { + return httpResponse(200, JSON.stringify({ error: "invalid email" }), "application/json"); + } + const success = successBody(request); + return httpResponse(success.status, success.body, success.contentType); +} + +function pathIsBatch(request) { + return request.path.includes("validatebatch"); +} + +function installFakeFetch() { + const records = []; + const previous = global.fetch; + global.fetch = async (url, init = {}) => { + const parsed = new URL(String(url)); + const body = await readBody(init.body); + const query = Object.fromEntries(parsed.searchParams.entries()); + const recorded = { + method: String(init.method || "GET").toUpperCase(), + host: parsed.host, + path: parsed.pathname, + url: String(url), + query, + body: body.fields, + json: body.json, + fields: { ...query, ...body.fields }, + apiKey: body.fields.api_key || query.api_key || "", + redirect: init.redirect, + }; + records.push(recorded); + await delay(Math.floor(Math.random() * 21)); + return answer(recorded); + }; + return { + records, + restore() { + global.fetch = previous; + }, + only() { + expect(records).toHaveLength(1); + return records[0]; + }, + }; +} + +function expectShort(message) { + expect(typeof message).toBe("string"); + expect(message.length).toBeLessThan(500); + expect(message).not.toMatch(/blocked/i); + expect(message).not.toMatch(/Error:\s*Error:/); + expect(message).not.toMatch(/\[Error\]:/); + expect(message.startsWith("Error:")).toBe(false); + expect(message).not.toMatch(/SyntaxError|Unexpected token|json\.exception/i); + expect(message).not.toContain("<<>>"); +} + +async function expectRejected(promise) { + let thrown = null; + try { + await promise; + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expectShort(thrown.message); + return thrown; +} + +function client(key, base = ZeroBounceSDK.ApiURL.DEFAULT_API_URL) { + const sdk = new ZeroBounceSDK(); + sdk.init(key, base); + return sdk; +} + +describe("offline API", () => { + let fake; + const file = new File(["email\n"], "emails.csv", { type: "text/csv" }); + + beforeEach(() => { + fake = installFakeFetch(); + jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + }); + + afterEach(() => { + fake.restore(); + }); + + function callAll(sdk) { + const id = "file-1"; + return [ + ["getCredits", () => sdk.getCredits(), "GET", "api.zerobounce.net", "/v2/getcredits"], + ["validateEmail", () => sdk.validateEmail("a@example.com"), "GET", "api.zerobounce.net", "/v2/validate"], + ["getApiUsage", () => sdk.getApiUsage("2020-01-01", "2020-01-31"), "GET", "api.zerobounce.net", "/v2/getapiusage"], + ["validateBatch", () => sdk.validateBatch([{ email_address: "a@example.com" }]), "POST", "api.zerobounce.net", "/v2/validatebatch"], + ["getEmailActivity", () => sdk.getEmailActivity("a@example.com"), "GET", "api.zerobounce.net", "/v2/activity"], + ["scoreEmail", () => sdk.scoreEmail("a@example.com"), "GET", "api.zerobounce.net", "/v2/scoring"], + ["sendFile", () => sdk.sendFile({ file, email_address_column: 1 }), "POST", "bulkapi.zerobounce.net", "/v2/sendfile"], + ["sendFileStream", () => sdk.sendFileStream(file, "emails.csv", { email_address_column: 1 }), "POST", "bulkapi.zerobounce.net", "/v2/sendfile"], + ["sendScoringFile", () => sdk.sendScoringFile({ file, email_address_column: 1 }), "POST", "bulkapi.zerobounce.net", "/v2/scoring/sendfile"], + ["sendScoringFileStream", () => sdk.sendScoringFileStream(file, "emails.csv", { email_address_column: 1 }), "POST", "bulkapi.zerobounce.net", "/v2/scoring/sendfile"], + ["getFileStatus", () => sdk.getFileStatus(id), "GET", "bulkapi.zerobounce.net", "/v2/filestatus"], + ["getScoringFileStatus", () => sdk.getScoringFileStatus(id), "GET", "bulkapi.zerobounce.net", "/v2/scoring/filestatus"], + ["getFile", () => sdk.getFile(id), "GET", "bulkapi.zerobounce.net", "/v2/getfile"], + ["getScoringFile", () => sdk.getScoringFile(id), "GET", "bulkapi.zerobounce.net", "/v2/scoring/getfile"], + ["deleteFile", () => sdk.deleteFile(id), "GET", "bulkapi.zerobounce.net", "/v2/deletefile"], + ["deleteScoringFile", () => sdk.deleteScoringFile(id), "GET", "bulkapi.zerobounce.net", "/v2/scoring/deletefile"], + ["sendEmailFinderFile", () => sdk.sendEmailFinderFile({ file, domain_column: 1 }), "POST", "bulkapi.zerobounce.net", "/email-finder/sendfile"], + ["sendEmailFinderFileStream", () => sdk.sendEmailFinderFileStream(file, "emails.csv", { domain_column: 1 }), "POST", "bulkapi.zerobounce.net", "/email-finder/sendfile"], + ["getEmailFinderFileStatus", () => sdk.getEmailFinderFileStatus(id), "GET", "bulkapi.zerobounce.net", "/email-finder/filestatus"], + ["getEmailFinderFile", () => sdk.getEmailFinderFile(id), "GET", "bulkapi.zerobounce.net", "/email-finder/getfile"], + ["deleteEmailFinderFile", () => sdk.deleteEmailFinderFile(id), "GET", "bulkapi.zerobounce.net", "/email-finder/deletefile"], + ["sendDomainSearchFile", () => sdk.sendDomainSearchFile({ file, domain_column: 1 }), "POST", "bulkapi.zerobounce.net", "/domain-search/sendfile"], + ["sendDomainSearchFileStream", () => sdk.sendDomainSearchFileStream(file, "emails.csv", { domain_column: 1 }), "POST", "bulkapi.zerobounce.net", "/domain-search/sendfile"], + ["getDomainSearchFileStatus", () => sdk.getDomainSearchFileStatus(id), "GET", "bulkapi.zerobounce.net", "/domain-search/filestatus"], + ["getDomainSearchFile", () => sdk.getDomainSearchFile(id), "GET", "bulkapi.zerobounce.net", "/domain-search/getfile"], + ["deleteDomainSearchFile", () => sdk.deleteDomainSearchFile(id), "GET", "bulkapi.zerobounce.net", "/domain-search/deletefile"], + ["sendListEvaluatorFile", () => sdk.sendListEvaluatorFile({ file, email_address_column: 1 }), "POST", "bulkapi.zerobounce.net", "/v2/listevaluator/"], + ["sendListEvaluatorFileStream", () => sdk.sendListEvaluatorFileStream(file, "emails.csv", { email_address_column: 1 }), "POST", "bulkapi.zerobounce.net", "/v2/listevaluator/"], + ["getListEvaluatorResult", () => sdk.getListEvaluatorResult(id), "GET", "bulkapi.zerobounce.net", "/v2/listevaluator/file-1/"], + ["sendEvaluatedFile", () => sdk.sendEvaluatedFile({ file }), "POST", "bulkapi.zerobounce.net", "/v2/send-evaluated-file/"], + ["addFilter", () => sdk.addFilter({ rule: "exclude", target: "domain", value: "spam.com" }), "POST", "api.zerobounce.net", "/v2/filters/add"], + ["listFilters", () => sdk.listFilters(), "GET", "api.zerobounce.net", "/v2/filters/list"], + ["deleteFilter", () => sdk.deleteFilter({ rule: "exclude", target: "domain", value: "spam.com" }), "POST", "api.zerobounce.net", "/v2/filters/delete"], + ["listS3files", () => sdk.listS3files(), "GET", "bulkapi.zerobounce.net", "/listS3files"], + ["validateS3file", () => sdk.validateS3file({ file_name: "f.csv" }), "POST", "bulkapi.zerobounce.net", "/validateS3file"], + ["scoreS3file", () => sdk.scoreS3file({ file_name: "f.csv" }), "POST", "bulkapi.zerobounce.net", "/scoreS3file"], + ["exportS3file", () => sdk.exportS3file({ file_id: id, export_bucket: "bucket" }), "POST", "bulkapi.zerobounce.net", "/exportS3file"], + ["findEmailByDomain", () => sdk.findEmailByDomain({ domain: "example.com", first_name: "Ada" }), "GET", "api.zerobounce.net", "/v2/guessformat"], + ["findEmailByCompanyName", () => sdk.findEmailByCompanyName({ company_name: "Ada Inc", first_name: "Ada" }), "GET", "api.zerobounce.net", "/v2/guessformat"], + ["findEmailFormatByDomain", () => sdk.findEmailFormatByDomain({ domain: "example.com" }), "GET", "api.zerobounce.net", "/v2/guessformat"], + ["findEmailFormatByCompanyName", () => sdk.findEmailFormatByCompanyName({ company_name: "Ada Inc" }), "GET", "api.zerobounce.net", "/v2/guessformat"], + ["guessFormat", () => sdk.guessFormat({ domain: "example.com" }), "GET", "api.zerobounce.net", "/v2/guessformat"], + ]; + } + + it("calls the right host, path, and method for every public method", async () => { + const sdk = client("parity-key"); + jest.spyOn(console, "warn").mockImplementation(() => {}); + for (const [name, run, method, host, path] of callAll(sdk)) { + fake.records.length = 0; + await run(); + expect({ name, method: fake.records[0].method, host: fake.records[0].host, path: fake.records[0].path }).toEqual({ + name, + method, + host, + path, + }); + expect(fake.records[0].apiKey).toBe("parity-key"); + } + }); + + it("uses the regional host selected in init", async () => { + const usa = client("parity-key", ZeroBounceSDK.ApiURL.USA_API_URL); + await usa.getCredits(); + expect(fake.only().host).toBe("api-us.zerobounce.net"); + fake.records.length = 0; + const eu = client("parity-key", ZeroBounceSDK.ApiURL.EU_API_URL); + await eu.getCredits(); + expect(fake.only().host).toBe("api-eu.zerobounce.net"); + }); + + it("does not follow redirects", async () => { + const sdk = client("parity-key"); + await sdk.getCredits(); + expect(fake.only().redirect).toBe("error"); + }); + + it("percent-encodes query values", async () => { + const sdk = client("key with space"); + await sdk.validateEmail("a&b=c@example.com"); + const request = fake.only(); + expect(request.url).not.toContain("a&b=c@example.com"); + expect(request.url).not.toContain("key with space"); + expect(request.fields.email).toBe("a&b=c@example.com"); + expect(request.fields.api_key).toBe("key with space"); + }); + + it("omits unset validate options and sends false and enums as the API expects", async () => { + const sdk = client("parity-key"); + await sdk.validateEmail("a@example.com"); + let request = fake.only(); + expect(request.fields.timeout).toBeUndefined(); + expect(request.fields.activity_data).toBeUndefined(); + expect(request.fields.verify_plus).toBeUndefined(); + expect(request.fields.ip_address).toBeUndefined(); + + fake.records.length = 0; + await sdk.validateEmail("a@example.com", { + ip_address: "127.0.0.1", + timeout: 30, + activityData: false, + verifyPlus: true, + }); + request = fake.only(); + expect(request.fields.ip_address).toBe("127.0.0.1"); + expect(request.fields.timeout).toBe("30"); + expect(request.fields.activity_data).toBe("false"); + expect(request.fields.verify_plus).toBe("true"); + + fake.records.length = 0; + await sdk.validateEmail("a@example.com", { method: "POST", verifyPlus: false }); + request = fake.only(); + expect(request.method).toBe("POST"); + expect(request.query.api_key).toBeUndefined(); + expect(request.body.api_key).toBe("parity-key"); + expect(request.body.verify_plus).toBe("false"); + expect(request.body.activity_data).toBeUndefined(); + }); + + it("posts validateBatch to the JSON API and keeps partial errors on the result", async () => { + const sdk = client("batch-partial-key"); + const batch = [{ email_address: "good@example.com", ip_address: "127.0.0.1" }]; + const result = await sdk.validateBatch(batch, { activityData: false, verifyPlus: true }); + const request = fake.only(); + expect(request.host).toBe("api.zerobounce.net"); + expect(request.path).toBe("/v2/validatebatch"); + expect(request.json.activity_data).toBe(false); + expect(request.json.verify_plus).toBe(true); + expect(request.json.timeout).toBeUndefined(); + expect(request.json.email_batch).toEqual(batch); + expect(result.email_batch).toEqual([{ address: "good@example.com", status: "valid" }]); + expect(result.errors).toEqual([{ email_address: "bad@", error: "Invalid email address" }]); + }); + + it("rejects a validateBatch account-level error", async () => { + const sdk = client("batch-account-key"); + const error = await expectRejected( + sdk.validateBatch([{ email_address: "a@example.com" }]) + ); + expect(error.message).toBe("Invalid API Key or your account ran out of credits"); + }); + + it("rejects account-level invalid key and no-credits responses", async () => { + const invalid = await expectRejected(client("invalid-key-1").getCredits()); + expect(invalid.message).toBe("Invalid API Key"); + fake.records.length = 0; + const credits = await expectRejected(client("no-credits-1").getCredits()); + expect(credits.message).toBe("Your account ran out of credits"); + }); + + it("leaves a per-email scoring error on the resolved result", async () => { + const sdk = client("score-item-1"); + await expect(sdk.scoreEmail("not-an-email")).resolves.toEqual({ error: "invalid email" }); + }); + + it("sends scoreEmail and listFilters as form posts when method is POST", async () => { + const sdk = client("parity-key"); + await sdk.scoreEmail("a@example.com", { method: "POST" }); + let request = fake.only(); + expect(request.method).toBe("POST"); + expect(request.path).toBe("/v2/scoring"); + expect(request.query.api_key).toBeUndefined(); + expect(request.body).toEqual({ api_key: "parity-key", email: "a@example.com" }); + + fake.records.length = 0; + await sdk.listFilters({ method: "POST" }); + request = fake.only(); + expect(request.method).toBe("POST"); + expect(request.path).toBe("/v2/filters/list"); + expect(request.body.api_key).toBe("parity-key"); + expect(request.query.api_key).toBeUndefined(); + }); + + it("sends bulk booleans as true or false, including an explicit false header row", async () => { + const sdk = client("parity-key"); + await sdk.sendFile({ + file, + email_address_column: 1, + has_header_row: false, + remove_duplicate: true, + allowPhase2: false, + }); + let request = fake.only(); + expect(request.fields.has_header_row).toBe("false"); + expect(request.fields.remove_duplicate).toBe("true"); + expect(request.fields.allow_phase_2).toBe("false"); + + fake.records.length = 0; + await sdk.sendFile({ file, email_address_column: 1 }); + request = fake.only(); + expect(request.fields.allow_phase_2).toBeUndefined(); + expect(request.fields.has_header_row).toBe("false"); + expect(request.fields.remove_duplicate).toBe("false"); + + fake.records.length = 0; + await sdk.sendScoringFile({ file, email_address_column: 1, has_header_row: false, remove_duplicate: false }); + request = fake.only(); + expect(request.fields.allow_phase_2).toBeUndefined(); + expect(request.fields.has_header_row).toBe("false"); + expect(request.fields.remove_duplicate).toBe("false"); + expect(request.fields.first_name_column).toBeUndefined(); + + fake.records.length = 0; + await sdk.sendEmailFinderFile({ file, domain_column: 2, has_header_row: false, first_name_column: 0 }); + request = fake.only(); + expect(request.fields.has_header_row).toBe("false"); + expect(request.fields.domain_column).toBe("2"); + expect(request.fields.first_name_column).toBeUndefined(); + + fake.records.length = 0; + await sdk.sendDomainSearchFile({ file, domain_column: 1, has_header_row: false }); + expect(fake.only().fields.has_header_row).toBe("false"); + }); + + it("sends column index 0 for required columns and leaves optional 0 columns out", async () => { + const sdk = client("parity-key"); + await sdk.sendFile({ + file, + email_address_column: 0, + first_name_column: 0, + last_name_column: 3, + }); + let request = fake.only(); + expect(request.fields.email_address_column).toBe("0"); + expect(request.fields.first_name_column).toBeUndefined(); + expect(request.fields.last_name_column).toBe("3"); + + fake.records.length = 0; + await sdk.sendEmailFinderFile({ file, domain_column: 0 }); + expect(fake.only().fields.domain_column).toBe("0"); + + fake.records.length = 0; + await sdk.sendListEvaluatorFile({ file, email_address_column: 0 }); + expect(fake.only().fields.email_address_column).toBe("0"); + + fake.records.length = 0; + await sdk.validateS3file({ + file_name: "f.csv", + email_address_column: 0, + first_name_column: 0, + has_header_row: false, + remove_duplicate: false, + }); + request = fake.only(); + expect(request.fields.email_address_column).toBe("0"); + expect(request.fields.first_name_column).toBeUndefined(); + expect(request.fields.has_header_row).toBe("false"); + expect(request.fields.remove_duplicate).toBe("false"); + expect(request.fields.allow_phase_2).toBeUndefined(); + }); + + it("sends getFile download_type and activity_data only when set, and not for scoring", async () => { + const sdk = client("parity-key"); + await sdk.getFile("file-1", { + downloadType: ZeroBounceSDK.ZBDownloadType.PHASE_1, + activityData: false, + }); + let request = fake.only(); + expect(request.fields.download_type).toBe("phase_1"); + expect(request.fields.activity_data).toBe("false"); + + fake.records.length = 0; + await sdk.getFile("file-1"); + request = fake.only(); + expect(request.fields.download_type).toBeUndefined(); + expect(request.fields.activity_data).toBeUndefined(); + + fake.records.length = 0; + await sdk.getScoringFile("file-1", { + downloadType: ZeroBounceSDK.ZBDownloadType.COMBINED, + activityData: true, + }); + request = fake.only(); + expect(request.fields.download_type).toBe("combined"); + expect(request.fields.activity_data).toBeUndefined(); + }); + + it("omits unset email-finder names and does not send the string null", async () => { + const sdk = client("parity-key"); + jest.spyOn(console, "warn").mockImplementation(() => {}); + await sdk.findEmailByDomain({ domain: "example.com", first_name: "Ada" }); + let request = fake.only(); + expect(request.fields.domain).toBe("example.com"); + expect(request.fields.first_name).toBe("Ada"); + expect(request.fields.middle_name).toBeUndefined(); + expect(request.fields.last_name).toBeUndefined(); + expect(request.fields.company_name).toBeUndefined(); + expect(request.url).not.toContain("middle_name"); + expect(request.url).not.toContain("=null"); + + fake.records.length = 0; + await sdk.findEmailByCompanyName({ company_name: "Ada Inc", first_name: "Ada", last_name: "Lovelace" }); + request = fake.only(); + expect(request.fields.company_name).toBe("Ada Inc"); + expect(request.fields.last_name).toBe("Lovelace"); + expect(request.fields.domain).toBeUndefined(); + expect(request.fields.middle_name).toBeUndefined(); + + fake.records.length = 0; + await sdk.guessFormat({ domain: "example.com" }); + request = fake.only(); + expect(request.fields.domain).toBe("example.com"); + expect(request.fields.first_name).toBeUndefined(); + expect(request.fields.middle_name).toBeUndefined(); + expect(request.fields.last_name).toBeUndefined(); + }); + + it("omits unset listS3files and filter fields", async () => { + const sdk = client("parity-key"); + await sdk.listS3files(); + expect(fake.only().fields.bucket).toBeUndefined(); + expect(fake.records[0].fields.search).toBeUndefined(); + fake.records.length = 0; + await sdk.listS3files({ bucket: "inbox", search: "a.csv" }); + expect(fake.only().fields.bucket).toBe("inbox"); + expect(fake.records[0].fields.search).toBe("a.csv"); + fake.records.length = 0; + await sdk.addFilter({ rule: "include", target: "email", value: "a@example.com" }); + expect(fake.only().body).toEqual({ + api_key: "parity-key", + rule: "include", + target: "email", + value: "a@example.com", + }); + }); + + it("rejects missing and invalid arguments before any request", async () => { + const bare = new ZeroBounceSDK(); + await expectRejected(bare.getCredits()); + expect(fake.records).toHaveLength(0); + + expect(() => bare.init("")).toThrow(/api key/i); + expect(() => bare.init(" ")).toThrow(/api key/i); + expect(() => bare.init("key", "http://evil.example/v2")).toThrow(/https/i); + expect(fake.records).toHaveLength(0); + + const sdk = client("parity-key"); + await expectRejected(sdk.validateEmail("")); + await expectRejected(sdk.validateEmail("a@example.com", { timeout: 0 })); + await expectRejected(sdk.validateEmail("a@example.com", { timeout: 2 })); + await expectRejected(sdk.validateEmail("a@example.com", { timeout: 61 })); + await expectRejected(sdk.validateBatch(null)); + await expectRejected(sdk.validateBatch([{ email_address: "a@example.com" }], { timeout: 0 })); + await expectRejected(sdk.validateBatch([{ email_address: "a@example.com" }], { timeout: 9 })); + await expectRejected(sdk.getApiUsage("", "2020-01-31")); + await expectRejected(sdk.scoreEmail("")); + await expectRejected(sdk.sendFile({ email_address_column: 1 })); + await expectRejected(sdk.sendFile({ file })); + await expectRejected(sdk.findEmailByDomain({ first_name: "Ada" })); + await expectRejected(sdk.findEmailByDomain({ domain: "example.com" })); + await expectRejected(sdk.addFilter({ rule: "", target: "domain", value: "spam.com" })); + await expectRejected(sdk.getFile("")); + await expectRejected(sdk.exportS3file({ file_id: "id" })); + expect(fake.records).toHaveLength(0); + }); + + it("turns a 403 HTML access-denied page into a short error", async () => { + const error = await expectRejected(client("html-403-key").getCredits()); + expect(error.message).toContain("Access denied"); + expect(HTML_PAGE.length).toBeGreaterThan(100000); + }); + + it("turns a 403 HTML getFile page into a short error instead of the page", async () => { + const error = await expectRejected(client("html-403-file").getFile("file-1")); + expect(error.message).toContain("Access denied"); + }); + + it("rejects HTML with status 200 for JSON and file downloads", async () => { + const credits = await expectRejected(client("html-200-credits").getCredits()); + expect(credits.message).toContain("Access denied"); + const fileError = await expectRejected(client("html-200-file").getFile("file-1")); + expect(fileError.message).toContain("Access denied"); + const finder = await expectRejected(client("html-200-finder").getEmailFinderFile("file-1")); + expect(finder.message).toContain("Access denied"); + const domain = await expectRejected(client("html-200-domain").getDomainSearchFile("file-1")); + expect(domain.message).toContain("Access denied"); + }); + + it("rejects empty and malformed bodies without the parser text", async () => { + const empty = await expectRejected(client("empty-key").getCredits()); + expect(empty.message).toMatch(/Empty response/); + const malformed = await expectRejected(client("malformed-key").validateEmail("a@example.com")); + expect(malformed.message).toMatch(/not valid JSON/); + const fileEmpty = await expectRejected(client("empty-file").getFile("file-1")); + expect(fileEmpty.message).toMatch(/Empty response/); + }); + + it("strips a repeated Error prefix", async () => { + const error = await expectRejected(client("double-error-key").getCredits()); + expect(error.message).toBe("api_key is invalid"); + }); + + it("uses the Cloudflare JSON title for a 403 access-denied payload", async () => { + const error = await expectRejected(client("cf-json-key").getCredits()); + expect(error.message).toBe("Error 1020: Access denied"); + }); + + it("returns a getFile JSON error object and still downloads CSV", async () => { + const previous = global.fetch; + const sdk = client("parity-key"); + global.fetch = previous; + jest.spyOn(global, "fetch").mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => "application/json" }, + text: () => Promise.resolve(JSON.stringify({ success: false, message: "File not ready" })), + }) + ); + await expect(sdk.getFile("file-1")).resolves.toEqual({ success: false, message: "File not ready" }); + fake.restore(); + fake = installFakeFetch(); + const csvSdk = client("parity-key"); + jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + await expect(csvSdk.getFile("file-1")).resolves.toBe("result.csv"); + }); + + it("keeps API keys isolated across concurrent clients", async () => { + const clients = Array.from({ length: 12 }, (_, index) => { + const key = `client-${String(index).padStart(4, "0")}-secret`; + return { key, sdk: client(key) }; + }); + const results = await Promise.all( + clients.map(({ sdk }) => sdk.validateEmail(`person-${sdk._api_key}@example.com`)) + ); + expect(fake.records).toHaveLength(clients.length); + results.forEach((result, index) => { + expect(result.echo_api_key).toBe(clients[index].key); + const blob = JSON.stringify(result); + for (const other of clients) { + if (other.key !== clients[index].key) expect(blob).not.toContain(other.key); + } + }); + for (const request of fake.records) { + expect(request.apiKey).toBe(request.fields.api_key); + expect(request.fields.email).toBe(`person-${request.apiKey}@example.com`); + for (const other of clients) { + if (other.key !== request.apiKey) { + expect(request.url).not.toContain(other.key); + expect(JSON.stringify(request.fields)).not.toContain(other.key); + } + } + } + }); +}); diff --git a/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index 99ce400..ad82770 100644 --- a/tests/zero-bounce.test.js +++ b/tests/zero-bounce.test.js @@ -48,8 +48,7 @@ describe("ZeroBounceSDK", () => { describe("getCredits", () => { it("should throw an error if not initialized", async () => { - await zeroBounceSDK.getCredits(); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.getCredits()).rejects.toThrow(initErrorMessage); }); it("should return error response with invalid API key", async () => { @@ -83,24 +82,17 @@ describe("ZeroBounceSDK", () => { const endDate = "2023-12-12"; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.getApiUsage(startDate, endDate); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.getApiUsage(startDate, endDate)).rejects.toThrow(initErrorMessage); }); it("should throw an error if stardDate is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.getApiUsage(null, endDate); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.getApiUsage(null, endDate)).rejects.toThrow(missingParamMessage) }); it("should throw an error if endDate is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.getApiUsage(startDate, null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.getApiUsage(startDate, null)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { @@ -170,16 +162,12 @@ describe("ZeroBounceSDK", () => { const ip_address = "127.0.0.1"; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.validateEmail(email, ip_address); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.validateEmail(email, ip_address)).rejects.toThrow(initErrorMessage); }); it("should throw an error if email is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.validateEmail(null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.validateEmail(null)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { @@ -290,18 +278,12 @@ describe("ZeroBounceSDK", () => { it("should throw an error if timeout is less than 3", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.validateEmail(email, { timeout: 2 }); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("timeout parameter is invalid") - ); + await expect(zeroBounceSDK.validateEmail(email, { timeout: 2 })).rejects.toThrow("timeout parameter is invalid") }); it("should throw an error if timeout is greater than 60", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.validateEmail(email, { timeout: 61 }); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("timeout parameter is invalid") - ); + await expect(zeroBounceSDK.validateEmail(email, { timeout: 61 })).rejects.toThrow("timeout parameter is invalid") }); it("should pass activity_data and verify_plus via options object", async () => { @@ -349,16 +331,12 @@ describe("ZeroBounceSDK", () => { const email = "mark@gmail.com"; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.scoreEmail(email); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.scoreEmail(email)).rejects.toThrow(initErrorMessage); }); it("should throw an error if email is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.scoreEmail(null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.scoreEmail(null)).rejects.toThrow(missingParamMessage) }); it("should return scoring data via GET", async () => { @@ -414,25 +392,32 @@ describe("ZeroBounceSDK", () => { ]; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.validateBatch(emailBatch); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.validateBatch(emailBatch)).rejects.toThrow(initErrorMessage); }); it("should throw an error if email list is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.validateBatch(null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.validateBatch(null)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { + const body = { + email_batch: [], + errors: [{ email_address: "all", error: "Invalid API Key or your account ran out of credits" }], + }; + jest.spyOn(global, "fetch").mockImplementationOnce(() => + Promise.resolve({ + status: 200, + ok: true, + headers: { get: () => "application/json" }, + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body), + }) + ); zeroBounceSDK.init("invalid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - try { - await zeroBounceSDK.validateBatch(emailBatch); - } catch (error) { - expect(error.message).toEqual('TypeError: Network request failed'); - } + await expect(zeroBounceSDK.validateBatch(emailBatch)).rejects.toThrow( + "Invalid API Key or your account ran out of credits" + ); }); it("should return the validated emails data", async () => { @@ -519,10 +504,7 @@ describe("ZeroBounceSDK", () => { it("should throw an error if batch timeout is out of range", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.validateBatch(emailBatch, { timeout: 9 }); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("timeout parameter is invalid") - ); + await expect(zeroBounceSDK.validateBatch(emailBatch, { timeout: 9 })).rejects.toThrow("timeout parameter is invalid") }); }); @@ -531,16 +513,12 @@ describe("ZeroBounceSDK", () => { const email = "valid@example.com"; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.getEmailActivity(email); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.getEmailActivity(email)).rejects.toThrow(initErrorMessage); }); it("should throw an error if email is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.getEmailActivity(null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.getEmailActivity(null)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { @@ -580,8 +558,7 @@ describe("ZeroBounceSDK", () => { }; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.sendFile(payload); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.sendFile(payload)).rejects.toThrow(initErrorMessage); }); it("should throw an error if file is missing", async () => { @@ -590,10 +567,7 @@ describe("ZeroBounceSDK", () => { }; zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.sendFile(payload); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.sendFile(payload)).rejects.toThrow(missingParamMessage) }); it("should throw an error if email address column is missing", async () => { @@ -602,21 +576,26 @@ describe("ZeroBounceSDK", () => { }; zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.sendFile(payload); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.sendFile(payload)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { + const expected = { + success: "False", + message: ["api_key is invalid"], + }; + jest.spyOn(global, "fetch").mockImplementationOnce(() => + Promise.resolve({ + status: 200, + ok: true, + headers: { get: () => "application/json" }, + text: () => Promise.resolve(JSON.stringify(expected)), + json: () => Promise.resolve(expected), + }) + ); zeroBounceSDK.init("invalid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); const response = await zeroBounceSDK.sendFile(payload); - expect(response).toEqual({ - "success": "False", - "message": [ - "api_key is invalid" - ] - }); + expect(response).toEqual(expected); }); it("should return the data regarding sent file", async () => { @@ -663,8 +642,7 @@ describe("ZeroBounceSDK", () => { }; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.sendScoringFile(payload); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.sendScoringFile(payload)).rejects.toThrow(initErrorMessage); }); it("should throw an error if file is missing", async () => { @@ -673,10 +651,7 @@ describe("ZeroBounceSDK", () => { }; zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.sendScoringFile(payload); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.sendScoringFile(payload)).rejects.toThrow(missingParamMessage) }); it("should throw an error if email address column is missing", async () => { @@ -685,21 +660,26 @@ describe("ZeroBounceSDK", () => { }; zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.sendScoringFile(payload); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.sendScoringFile(payload)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { + const expected = { + success: "False", + message: ["api_key is invalid"], + }; + jest.spyOn(global, "fetch").mockImplementationOnce(() => + Promise.resolve({ + status: 200, + ok: true, + headers: { get: () => "application/json" }, + text: () => Promise.resolve(JSON.stringify(expected)), + json: () => Promise.resolve(expected), + }) + ); zeroBounceSDK.init("invalid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); const response = await zeroBounceSDK.sendScoringFile(payload); - expect(response).toEqual({ - "success": "False", - "message": [ - "api_key is invalid" - ] - }); + expect(response).toEqual(expected); }); it("should return the data regarding sent scoring file", async () => { @@ -726,16 +706,12 @@ describe("ZeroBounceSDK", () => { const fileId = "aaaaaaaa-zzzz-xxxx-yyyy-5003727fffff"; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.getFileStatus(fileId); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.getFileStatus(fileId)).rejects.toThrow(initErrorMessage); }); it("should throw an error if file id is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.getFileStatus(null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.getFileStatus(null)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { @@ -776,16 +752,12 @@ describe("ZeroBounceSDK", () => { const fileId = "aaaaaaaa-zzzz-xxxx-yyyy-5003727fffff"; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.getScoringFileStatus(fileId); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.getScoringFileStatus(fileId)).rejects.toThrow(initErrorMessage); }); it("should throw an error if file id is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.getScoringFileStatus(null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.getScoringFileStatus(null)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { @@ -829,16 +801,12 @@ describe("ZeroBounceSDK", () => { }); it("should throw an error if not initialized", async () => { - await zeroBounceSDK.getFile(fileId); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.getFile(fileId)).rejects.toThrow(initErrorMessage); }); it("should throw an error if file id is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.getFile(null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.getFile(null)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { @@ -933,16 +901,12 @@ describe("ZeroBounceSDK", () => { const fileId = "aaaaaaaa-zzzz-xxxx-yyyy-5003727fffff"; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.getScoringFile(fileId); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.getScoringFile(fileId)).rejects.toThrow(initErrorMessage); }); it("should throw an error if file id is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.getScoringFile(null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.getScoringFile(null)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { @@ -973,16 +937,12 @@ describe("ZeroBounceSDK", () => { const fileId = "aaaaaaaa-zzzz-xxxx-yyyy-5003727fffff"; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.deleteFile(fileId); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.deleteFile(fileId)).rejects.toThrow(initErrorMessage); }); it("should throw an error if file id is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.deleteFile(null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.deleteFile(null)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { @@ -1018,16 +978,12 @@ describe("ZeroBounceSDK", () => { const fileId = "aaaaaaaa-zzzz-xxxx-yyyy-5003727fffff"; it("should throw an error if not initialized", async () => { - await zeroBounceSDK.deleteScoringFile(fileId); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.deleteScoringFile(fileId)).rejects.toThrow(initErrorMessage); }); it("should throw an error if file id is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.deleteScoringFile(null); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.deleteScoringFile(null)).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { @@ -1067,16 +1023,12 @@ describe("ZeroBounceSDK", () => { } it("should throw an error if not initialized", async () => { - await zeroBounceSDK.findEmailByDomain(payload); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.findEmailByDomain(payload)).rejects.toThrow(initErrorMessage); }); it("should throw an error if domain is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.findEmailByDomain({domain: null}); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.findEmailByDomain({domain: null})).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => { @@ -1118,16 +1070,12 @@ describe("ZeroBounceSDK", () => { } it("should throw an error if not initialized", async () => { - await zeroBounceSDK.findEmailFormatByDomain(payload); - expect(console.error).toHaveBeenCalledWith(initErrorMessage); + await expect(zeroBounceSDK.findEmailFormatByDomain(payload)).rejects.toThrow(initErrorMessage); }); it("should throw an error if domain is missing", async () => { zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - await zeroBounceSDK.findEmailFormatByDomain({domain: null}); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining(missingParamMessage) - ); + await expect(zeroBounceSDK.findEmailFormatByDomain({domain: null})).rejects.toThrow(missingParamMessage) }); it("should return error response with invalid API key", async () => {