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 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 () => { From b47aa83e036915472df57061b92058975b5bda18 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:23:30 +0400 Subject: [PATCH 06/14] feat: validate WHOIS and greylist options, Inbox Placement Tester, list evaluator base URL Ports the Python SDK's parity changes (407f0a8, a6710e5) to this SDK. - validateEmail takes domainInfo, greylistProcessing and callbackUrl, sent as the strings "true"/"false" on both the GET and the form POST. callbackUrl without greylistProcessing: true, and a blank callbackUrl, are rejected before any request goes out. - The validate response type carries domain_website_exists, domain_registrant_company_name and message, the last read from the API's "Message" key while a greylist retry is still running. - Inbox Placement Tester on bulkapi.zerobounce.net/api/ipt: providers, test types, create/get/list/delete tests, and get/set webhook. The API key goes on the query string, bodies are camelCase JSON, and only fields the caller set are sent. createRequest gained a jsonBody option for this. - List evaluator requests use their own base URL rather than building the path from the bulk base, and the file id is URL-encoded as one path segment. sendEvaluatedFile stays on the bulk base. init's third argument overrides both this and the Inbox Placement Tester base. - An error body carrying a "detail" string (Django REST framework, used by the Inbox Placement Tester) rejects with that text. Removes listS3files, validateS3file, scoreS3file and exportS3file along with their helper, types, tests and changelog entry: https://www.zerobounce.net/docs/ does not document those endpoints, and AGENTS.md now records that the SDK covers only documented ones. Tests: 122 pass, plus dts-check and the webpack build. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 5 +- CHANGELOG.md | 8 +- README.md | 65 +++++++++ documentation.md | 65 +++++++++ src/utils.js | 9 +- src/zero-bounce.js | 294 ++++++++++++++++++++++++++------------ tests/offline-api.test.js | 172 ++++++++++++++++++---- tests/zero-bounce.test.js | 41 ------ zeroBounceSDK.d.ts | 122 +++++++++------- 9 files changed, 565 insertions(+), 216 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3a333d6..6167c41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,11 +30,14 @@ There is no separate lint command. CI (`.github/workflows/sdk_ci.yml`) runs `npm ## Conventions +- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server. - **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. +- **`allow_phase_2` is validation bulk only** (`sendFile`, `sendFileStream`), and only when `allowPhase2` is set. Scoring `sendFile` ignores it. `activity_data` on get-file is validation `getFile` only. +- **List evaluator and the Inbox Placement Tester have their own base URLs**, set on the instance by `init`'s third argument: `https://bulkapi.zerobounce.net/v2/listevaluator` and `https://bulkapi.zerobounce.net/api/ipt`. They are not built from the bulk base. `sendEvaluatedFile` stays on the bulk `/v2` base. +- **The Inbox Placement Tester takes the API key as the `api_key` query parameter and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`. - **`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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a25b8..cdd9583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,16 +16,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`. +- `validateEmail` accepts `domainInfo` (adds `domain_website_exists` and `domain_registrant_company_name` to the response), `greylistProcessing`, and `callbackUrl`. `callbackUrl` needs `greylistProcessing: true` and is rejected without it. +- The validate response carries `message`, read from the API's `Message` key, which a greylist pickup returns while the retry is still running. +- Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `getInboxPlacementProviders`, `getInboxPlacementTestTypes`, `createInboxPlacementTest`, `getInboxPlacementTest`, `listInboxPlacementTests`, `deleteInboxPlacementTest`, `getInboxPlacementWebhook`, `setInboxPlacementWebhook`. +- `init` takes a third argument to override the list evaluator and Inbox Placement Tester base URLs. ### Changed +- List evaluator requests use their own base URL (`https://bulkapi.zerobounce.net/v2/listevaluator`) instead of building the path from the bulk base, and the file id is URL-encoded as one path segment. `sendEvaluatedFile` stays on the bulk base. + - `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 +- An error body whose JSON carries a `detail` string (the Inbox Placement Tester, Django REST framework) rejects with that text instead of a generic message. - 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. diff --git a/README.md b/README.md index b3a6f81..171f6da 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,71 @@ try { } ``` +- ##### Validate with domain info and greylist processing + +```javascript +// domainInfo adds domain_website_exists and domain_registrant_company_name. +// greylistProcessing re-runs a greylisted address instead of returning unknown. +// callbackUrl is only allowed together with greylistProcessing: true. +try { + const response = await zeroBounce.validateEmail("", { + domainInfo: true, + greylistProcessing: true, + callbackUrl: "https://example.com/zerobounce-callback", + }); + // While the greylist retry is still running the API returns a message + // instead of a result; call validateEmail again in about 30 minutes. + console.log(response.message, response.domain_website_exists); +} catch (error) { + console.error(error); +} +``` + +- ##### Inbox Placement Tester + +```javascript +// Lives on https://bulkapi.zerobounce.net/api/ipt and spends inbox test credits. +try { + const providers = await zeroBounce.getInboxPlacementProviders(); + const testTypes = await zeroBounce.getInboxPlacementTestTypes(); + + // testType 1 (Email) needs senderEmail; 2 is Unique ID and 3 is Auto Alias. + const created = await zeroBounce.createInboxPlacementTest({ + name: "October campaign", // required, at most 64 characters + testType: 1, + senderEmail: "news@example.com", + providers: [{ id: 1, maxSeeds: 5 }], // optional per-provider seed cap + webhookUrl: "https://example.com/ipt-webhook", + }); + + // Send your campaign to created.emailAddressList, then read the results. + const result = await zeroBounce.getInboxPlacementTest(created.id); + + const tests = await zeroBounce.listInboxPlacementTests({ + status: "finished", + page: 1, + pageSize: 25, + }); + + await zeroBounce.setInboxPlacementWebhook("https://example.com/ipt-webhook"); + const webhook = await zeroBounce.getInboxPlacementWebhook(); + await zeroBounce.deleteInboxPlacementTest(created.id); +} catch (error) { + console.error(error); +} +``` + +- ##### Point an API at a different base URL + +```javascript +// The list evaluator and the Inbox Placement Tester have their own base URLs, +// separate from the main and bulk hosts. +zeroBounce.init("", ZeroBounceSDK.ApiURL.DEFAULT_API_URL, { + listEvaluatorBaseURL: "https://bulkapi.zerobounce.net/v2/listevaluator", + inboxPlacementBaseURL: "https://bulkapi.zerobounce.net/api/ipt", +}); +``` + - ##### Get api usage from a start date to an end date ```javascript diff --git a/documentation.md b/documentation.md index 6d47305..8b4ee1c 100644 --- a/documentation.md +++ b/documentation.md @@ -72,6 +72,71 @@ try { // const response = await zeroBounce.validateEmail(email, "127.0.0.1"); ``` +- ##### Validate with domain info and greylist processing + +```javascript +// domainInfo adds domain_website_exists and domain_registrant_company_name. +// greylistProcessing re-runs a greylisted address instead of returning unknown. +// callbackUrl is only allowed together with greylistProcessing: true. +try { + const response = await zeroBounce.validateEmail("", { + domainInfo: true, + greylistProcessing: true, + callbackUrl: "https://example.com/zerobounce-callback", + }); + // While the greylist retry is still running the API returns a message + // instead of a result; call validateEmail again in about 30 minutes. + console.log(response.message, response.domain_website_exists); +} catch (error) { + console.error(error); +} +``` + +- ##### Inbox Placement Tester + +```javascript +// Lives on https://bulkapi.zerobounce.net/api/ipt and spends inbox test credits. +try { + const providers = await zeroBounce.getInboxPlacementProviders(); + const testTypes = await zeroBounce.getInboxPlacementTestTypes(); + + // testType 1 (Email) needs senderEmail; 2 is Unique ID and 3 is Auto Alias. + const created = await zeroBounce.createInboxPlacementTest({ + name: "October campaign", // required, at most 64 characters + testType: 1, + senderEmail: "news@example.com", + providers: [{ id: 1, maxSeeds: 5 }], // optional per-provider seed cap + webhookUrl: "https://example.com/ipt-webhook", + }); + + // Send your campaign to created.emailAddressList, then read the results. + const result = await zeroBounce.getInboxPlacementTest(created.id); + + const tests = await zeroBounce.listInboxPlacementTests({ + status: "finished", + page: 1, + pageSize: 25, + }); + + await zeroBounce.setInboxPlacementWebhook("https://example.com/ipt-webhook"); + const webhook = await zeroBounce.getInboxPlacementWebhook(); + await zeroBounce.deleteInboxPlacementTest(created.id); +} catch (error) { + console.error(error); +} +``` + +- ##### Point an API at a different base URL + +```javascript +// The list evaluator and the Inbox Placement Tester have their own base URLs, +// separate from the main and bulk hosts. +zeroBounce.init("", ZeroBounceSDK.ApiURL.DEFAULT_API_URL, { + listEvaluatorBaseURL: "https://bulkapi.zerobounce.net/v2/listevaluator", + inboxPlacementBaseURL: "https://bulkapi.zerobounce.net/api/ipt", +}); +``` + - ####### Get api usage from a start date to an end date ```javascript diff --git a/src/utils.js b/src/utils.js index acd0947..c403c4b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -112,6 +112,8 @@ 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(); + // "detail" is how the Inbox Placement Tester (Django REST framework) reports errors. + if (typeof parsed.detail === "string" && parsed.detail.trim()) return parsed.detail.trim(); if (Array.isArray(parsed.message)) { const parts = parsed.message.filter((item) => typeof item === "string" && item.trim()); if (parts.length) return parts.join("; "); @@ -236,6 +238,7 @@ function buildRequestUrl({ batch, bulkBase, apiBaseURL, path, params }) { export async function createRequest({ requestType, body = null, + jsonBody = null, params = null, path, batch = false, @@ -250,8 +253,10 @@ export async function createRequest({ try { response = await fetch(url, { method: requestType, - headers: HEADERS, - body, + // The Inbox Placement Tester takes JSON; everything else sends query, + // form, or multipart bodies, which must not carry a JSON content type. + headers: jsonBody != null ? { ...HEADERS, "Content-Type": "application/json" } : HEADERS, + body: jsonBody != null ? JSON.stringify(jsonBody) : body, signal: fetchTimeoutSignal(), redirect: "error", }); diff --git a/src/zero-bounce.js b/src/zero-bounce.js index be8e3f3..40b4a9f 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -91,22 +91,40 @@ export class ZeroBounceSDK { static ZBDownloadType = ZBDownloadType; + /** Base URLs for APIs that do not sit under the main or bulk /v2 path. */ + static ListEvaluatorURL = Object.freeze({ + DEFAULT_URL: "https://bulkapi.zerobounce.net/v2/listevaluator", + }); + + static InboxPlacementURL = Object.freeze({ + DEFAULT_URL: "https://bulkapi.zerobounce.net/api/ipt", + }); + constructor() { this._initialized = false; this._api_key = null; this._api_base_url = ZeroBounceSDK.ApiURL.DEFAULT_API_URL; + this._list_evaluator_base_url = ZeroBounceSDK.ListEvaluatorURL.DEFAULT_URL; + this._inbox_placement_base_url = ZeroBounceSDK.InboxPlacementURL.DEFAULT_URL; } /** * @param apiKey - your private API key * @param apiBaseURL - your preferred API, possible values being ApiURL.DEFAULT_API_URL, ApiURL.USAAPIURL, ApiURL.EUAPIURL * */ - init(apiKey, apiBaseURL = ZeroBounceSDK.ApiURL.DEFAULT_API_URL) { + init(apiKey, apiBaseURL = ZeroBounceSDK.ApiURL.DEFAULT_API_URL, options = null) { 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); + const { listEvaluatorBaseURL, inboxPlacementBaseURL } = options || {}; + this._list_evaluator_base_url = requireHttpsUrl( + listEvaluatorBaseURL ?? ZeroBounceSDK.ListEvaluatorURL.DEFAULT_URL + ); + this._inbox_placement_base_url = requireHttpsUrl( + inboxPlacementBaseURL ?? ZeroBounceSDK.InboxPlacementURL.DEFAULT_URL + ); this._initialized = true; } @@ -143,6 +161,9 @@ export class ZeroBounceSDK { let timeout; let activityData; let verifyPlus; + let domainInfo; + let greylistProcessing; + let callbackUrl; if (typeof options === "string") { ip_address = options; } else if (options && typeof options === "object") { @@ -150,6 +171,9 @@ export class ZeroBounceSDK { timeout = options.timeout; activityData = options.activityData; verifyPlus = options.verifyPlus; + domainInfo = options.domainInfo; + greylistProcessing = options.greylistProcessing; + callbackUrl = options.callbackUrl; } if (timeout != null && (timeout < 3 || timeout > 60)) { @@ -157,6 +181,16 @@ export class ZeroBounceSDK { return; } + if (callbackUrl != null && greylistProcessing !== true) { + parameterIsInvalid("callbackUrl", "Requires greylistProcessing: true."); + return; + } + + if (callbackUrl != null && String(callbackUrl).trim() === "") { + parameterIsMissing("callbackUrl"); + return; + } + const params = { api_key: this._api_key, email, @@ -165,6 +199,11 @@ export class ZeroBounceSDK { if (timeout != null) params.timeout = timeout; if (activityData != null) params.activity_data = activityData ? "true" : "false"; if (verifyPlus != null) params.verify_plus = verifyPlus ? "true" : "false"; + if (domainInfo != null) params.domain_info = domainInfo ? "true" : "false"; + if (greylistProcessing != null) { + params.greylist_processing = greylistProcessing ? "true" : "false"; + } + if (callbackUrl != null) params.callback_url = callbackUrl; const usePost = options && typeof options === "object" && options.method === "POST"; @@ -661,22 +700,6 @@ export class ZeroBounceSDK { }); } - async _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 * */ @@ -850,10 +873,9 @@ export class ZeroBounceSDK { body.append("api_key", this._api_key); return createRequest({ requestType: "POST", - path: "/listevaluator/", + path: "/", body, - batch: true, - apiBaseURL: this._api_base_url, + apiBaseURL: this._list_evaluator_base_url, }); } @@ -877,10 +899,9 @@ export class ZeroBounceSDK { body.append("api_key", this._api_key); return createRequest({ requestType: "POST", - path: "/listevaluator/", + path: "/", body, - batch: true, - apiBaseURL: this._api_base_url, + apiBaseURL: this._list_evaluator_base_url, }); } @@ -896,9 +917,8 @@ export class ZeroBounceSDK { return createRequest({ requestType: "GET", params, - path: `/listevaluator/${fileId}/`, - batch: true, - apiBaseURL: this._api_base_url, + path: `/${encodeURIComponent(fileId)}/`, + apiBaseURL: this._list_evaluator_base_url, }); } @@ -1007,97 +1027,187 @@ export class ZeroBounceSDK { }); } - // AMAZON S3 BULK HELPERS (bulkapi root) - async listS3files({ bucket = null, search = null } = {}) { + // INBOX PLACEMENT TESTER (bulkapi.zerobounce.net/api/ipt) + // The API key goes on the query string and bodies are JSON with camelCase fields. + + _iptRequest(requestType, path, { params = null, jsonBody = null } = {}) { + return createRequest({ + requestType, + path, + params: { api_key: this._api_key, ...(params || {}) }, + jsonBody, + apiBaseURL: this._inbox_placement_base_url, + }); + } + + /** Seed providers available for new tests. GET /providers */ + async getInboxPlacementProviders() { 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, - }); + return this._iptRequest("GET", "/providers"); } - async 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, - }) { + /** Supported test types and the default one. GET /test-types */ + async getInboxPlacementTestTypes() { if (!this._initialized) { notInitialized(); return; - } else if (isMissing(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); + } + return this._iptRequest("GET", "/test-types"); } - async scoreS3file({ - file_name, - has_header_row, - remove_duplicates, - email_address_column = null, - remove_duplicate = null, - }) { + /** + * Create an inbox placement test. POST /test + * + * @param options.name - unique test name, at most 64 characters (required) + * @param options.testType - 1 (Email, needs senderEmail), 2 (Unique ID) or 3 (Auto Alias) + * @param options.senderEmail - the From address to match; required when testType is 1 + * @param options.providerIds - use every active seed of these providers + * @param options.seedIds - use these seed mailboxes + * @param options.providers - [{ id, maxSeeds }] to cap seeds per provider (maxSeeds >= 1) + * @param options.webhookUrl - where the finished result is posted + * */ + async createInboxPlacementTest({ + name, + testType = null, + senderEmail = null, + providerIds = null, + seedIds = null, + providers = null, + webhookUrl = null, + } = {}) { if (!this._initialized) { notInitialized(); return; - } else if (isMissing(file_name)) { - parameterIsMissing("file_name"); + } + if (isMissing(name)) { + parameterIsMissing("name"); + return; + } + if (String(name).length > 64) { + parameterIsInvalid("name", "Must be at most 64 characters."); + return; + } + if (testType != null && ![1, 2, 3].includes(testType)) { + parameterIsInvalid("testType", "Must be 1, 2 or 3."); + return; + } + if (testType === 1 && isMissing(senderEmail)) { + parameterIsMissing("senderEmail", "Required when testType is 1."); 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); + for (const provider of providers || []) { + if (!provider || typeof provider !== "object" || provider.id == null) { + parameterIsInvalid("providers", "Each entry needs an id."); + return; + } + if (provider.maxSeeds != null && provider.maxSeeds < 1) { + parameterIsInvalid("providers", "maxSeeds must be at least 1."); + return; + } + } + + const body = { name }; + if (testType != null) body.testType = testType; + if (senderEmail != null) body.senderEmail = senderEmail; + if (providerIds != null) body.providerIds = providerIds; + if (seedIds != null) body.seedIds = seedIds; + if (providers != null) body.providers = providers; + if (webhookUrl != null) body.webhookUrl = webhookUrl; + return this._iptRequest("POST", "/test", { jsonBody: body }); } - async exportS3file({ file_id, export_bucket }) { + /** A test and its placement results. GET /test/{id} */ + async getInboxPlacementTest(testId) { if (!this._initialized) { notInitialized(); return; - } else if (isMissing(file_id)) { - parameterIsMissing("file_id"); + } + if (isMissing(testId)) { + parameterIsMissing("testId"); return; - } else if (isMissing(export_bucket)) { - parameterIsMissing("export_bucket"); + } + return this._iptRequest("GET", `/test/${encodeURIComponent(String(testId).trim())}`); + } + + /** + * List tests, newest first. GET /tests + * + * @param options.status - "running" or "finished" + * @param options.createdAfter - ISO 8601 date or datetime + * @param options.createdBefore - ISO 8601 date or datetime + * @param options.page - page number, from 1 + * @param options.pageSize - tests per page, 1 to 100 + * */ + async listInboxPlacementTests({ + status = null, + createdAfter = null, + createdBefore = null, + page = null, + pageSize = null, + } = {}) { + if (!this._initialized) { + notInitialized(); return; } - return this._sendBulkRootFormPost("/exportS3file", { - file_id, - export_bucket, - }); + if (status != null && !["running", "finished"].includes(status)) { + parameterIsInvalid("status", "Must be running or finished."); + return; + } + if (page != null && page < 1) { + parameterIsInvalid("page", "Must be at least 1."); + return; + } + if (pageSize != null && (pageSize < 1 || pageSize > 100)) { + parameterIsInvalid("pageSize", "Must be between 1 and 100."); + return; + } + + const params = {}; + if (status != null) params.status = status; + if (createdAfter != null) params.createdAfter = createdAfter; + if (createdBefore != null) params.createdBefore = createdBefore; + if (page != null) params.page = page; + if (pageSize != null) params.page_size = pageSize; + return this._iptRequest("GET", "/tests", { params }); + } + + /** Delete a finished test. DELETE /test/{id} */ + async deleteInboxPlacementTest(testId) { + if (!this._initialized) { + notInitialized(); + return; + } + if (isMissing(testId)) { + parameterIsMissing("testId"); + return; + } + return this._iptRequest("DELETE", `/test/${encodeURIComponent(String(testId).trim())}`); + } + + /** The webhook URL saved on this API key. GET /webhook */ + async getInboxPlacementWebhook() { + if (!this._initialized) { + notInitialized(); + return; + } + return this._iptRequest("GET", "/webhook"); + } + + /** Save the URL finished results are posted to. PUT /webhook */ + async setInboxPlacementWebhook(webhookUrl) { + if (!this._initialized) { + notInitialized(); + return; + } + if (isMissing(webhookUrl)) { + parameterIsMissing("webhookUrl"); + return; + } + return this._iptRequest("PUT", "/webhook", { jsonBody: { webhookUrl } }); } // EMAIL FINDER diff --git a/tests/offline-api.test.js b/tests/offline-api.test.js index e6a6cd6..273bd43 100644 --- a/tests/offline-api.test.js +++ b/tests/offline-api.test.js @@ -159,6 +159,14 @@ function answer(request) { if (key.startsWith("double-error-")) { return httpResponse(400, "Error: Error: api_key is invalid", "text/plain"); } + if (key.startsWith("ipt-detail-")) { + // The Inbox Placement Tester (Django REST framework) reports errors as "detail". + return httpResponse( + 404, + JSON.stringify({ detail: "Inbox placement test not found." }), + "application/json" + ); + } if (key.startsWith("invalid-key-")) { return httpResponse(200, JSON.stringify({ error: "Invalid API Key" }), "application/json"); } @@ -309,14 +317,18 @@ describe("offline API", () => { ["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/"], + ["getInboxPlacementProviders", () => sdk.getInboxPlacementProviders(), "GET", "bulkapi.zerobounce.net", "/api/ipt/providers"], + ["getInboxPlacementTestTypes", () => sdk.getInboxPlacementTestTypes(), "GET", "bulkapi.zerobounce.net", "/api/ipt/test-types"], + ["createInboxPlacementTest", () => sdk.createInboxPlacementTest({ name: "t" }), "POST", "bulkapi.zerobounce.net", "/api/ipt/test"], + ["getInboxPlacementTest", () => sdk.getInboxPlacementTest(id), "GET", "bulkapi.zerobounce.net", "/api/ipt/test/file-1"], + ["listInboxPlacementTests", () => sdk.listInboxPlacementTests(), "GET", "bulkapi.zerobounce.net", "/api/ipt/tests"], + ["deleteInboxPlacementTest", () => sdk.deleteInboxPlacementTest(id), "DELETE", "bulkapi.zerobounce.net", "/api/ipt/test/file-1"], + ["getInboxPlacementWebhook", () => sdk.getInboxPlacementWebhook(), "GET", "bulkapi.zerobounce.net", "/api/ipt/webhook"], + ["setInboxPlacementWebhook", () => sdk.setInboxPlacementWebhook("https://example.com/hook"), "PUT", "bulkapi.zerobounce.net", "/api/ipt/webhook"], ["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"], @@ -515,20 +527,6 @@ describe("offline API", () => { 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 () => { @@ -587,16 +585,8 @@ describe("offline API", () => { expect(request.fields.last_name).toBeUndefined(); }); - it("omits unset listS3files and filter fields", async () => { + it("omits unset 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", @@ -632,10 +622,136 @@ describe("offline API", () => { 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" })); + await expectRejected(sdk.validateEmail("a@example.com", { callbackUrl: "https://example.com/cb" })); + await expectRejected( + sdk.validateEmail("a@example.com", { greylistProcessing: true, callbackUrl: " " }) + ); + await expectRejected(sdk.createInboxPlacementTest({ name: "" })); + await expectRejected(sdk.createInboxPlacementTest({ name: "x".repeat(65) })); + await expectRejected(sdk.createInboxPlacementTest({ name: "t", testType: 4 })); + await expectRejected(sdk.createInboxPlacementTest({ name: "t", testType: 1 })); + await expectRejected( + sdk.createInboxPlacementTest({ name: "t", providers: [{ maxSeeds: 2 }] }) + ); + await expectRejected( + sdk.createInboxPlacementTest({ name: "t", providers: [{ id: 1, maxSeeds: 0 }] }) + ); + await expectRejected(sdk.listInboxPlacementTests({ status: "queued" })); + await expectRejected(sdk.listInboxPlacementTests({ page: 0 })); + await expectRejected(sdk.listInboxPlacementTests({ pageSize: 101 })); + await expectRejected(sdk.getInboxPlacementTest("")); + await expectRejected(sdk.deleteInboxPlacementTest("")); + await expectRejected(sdk.setInboxPlacementWebhook("")); expect(fake.records).toHaveLength(0); }); + it("omits the new validate options until set and sends booleans as true or false", async () => { + const sdk = client("parity-key"); + await sdk.validateEmail("a@example.com"); + let request = fake.only(); + expect(request.query.domain_info).toBeUndefined(); + expect(request.query.greylist_processing).toBeUndefined(); + expect(request.query.callback_url).toBeUndefined(); + + fake.records.length = 0; + await sdk.validateEmail("a@example.com", { + domainInfo: true, + greylistProcessing: false, + }); + request = fake.only(); + expect(request.query.domain_info).toBe("true"); + expect(request.query.greylist_processing).toBe("false"); + + fake.records.length = 0; + await sdk.validateEmail("a@example.com", { + greylistProcessing: true, + callbackUrl: "https://example.com/cb", + method: "POST", + }); + request = fake.only(); + expect(request.method).toBe("POST"); + expect(request.body.greylist_processing).toBe("true"); + expect(request.body.callback_url).toBe("https://example.com/cb"); + }); + + it("sends inbox placement bodies as camelCase JSON and leaves unset fields out", async () => { + const sdk = client("parity-key"); + await sdk.createInboxPlacementTest({ name: "basic" }); + let request = fake.only(); + expect(request.json).toEqual({ name: "basic" }); + expect(request.query.api_key).toBe("parity-key"); + + fake.records.length = 0; + await sdk.createInboxPlacementTest({ + name: "full", + testType: 1, + senderEmail: "from@example.com", + providerIds: [1, 2], + seedIds: [3], + providers: [{ id: 4, maxSeeds: 2 }], + webhookUrl: "https://example.com/hook", + }); + expect(fake.only().json).toEqual({ + name: "full", + testType: 1, + senderEmail: "from@example.com", + providerIds: [1, 2], + seedIds: [3], + providers: [{ id: 4, maxSeeds: 2 }], + webhookUrl: "https://example.com/hook", + }); + + fake.records.length = 0; + await sdk.setInboxPlacementWebhook("https://example.com/hook"); + request = fake.only(); + expect(request.method).toBe("PUT"); + expect(request.json).toEqual({ webhookUrl: "https://example.com/hook" }); + }); + + it("sends only the inbox placement list filters that are set", async () => { + const sdk = client("parity-key"); + await sdk.listInboxPlacementTests(); + expect(Object.keys(fake.only().query)).toEqual(["api_key"]); + + fake.records.length = 0; + await sdk.listInboxPlacementTests({ + status: "finished", + createdAfter: "2026-01-01", + createdBefore: "2026-02-01", + page: 2, + pageSize: 50, + }); + const query = fake.only().query; + expect(query.status).toBe("finished"); + expect(query.createdAfter).toBe("2026-01-01"); + expect(query.createdBefore).toBe("2026-02-01"); + expect(query.page).toBe("2"); + expect(query.page_size).toBe("50"); + }); + + it("uses the list evaluator base URL and encodes the file id", async () => { + const sdk = client("parity-key"); + await sdk.getListEvaluatorResult("a b/c"); + const request = fake.only(); + expect(request.host).toBe("bulkapi.zerobounce.net"); + expect(request.path).toBe("/v2/listevaluator/a%20b%2Fc/"); + + fake.records.length = 0; + const overridden = new ZeroBounceSDK(); + overridden.init("parity-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL, { + listEvaluatorBaseURL: "https://example.com/custom/listevaluator", + }); + await overridden.getListEvaluatorResult("file-1"); + expect(fake.only().url).toBe( + "https://example.com/custom/listevaluator/file-1/?api_key=parity-key" + ); + }); + + it("turns a detail error body into a short message", async () => { + const error = await expectRejected(client("ipt-detail-key").getInboxPlacementProviders()); + expect(error.message).toContain("Inbox placement test not found"); + }); + 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"); diff --git a/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index ad82770..7074fec 100644 --- a/tests/zero-bounce.test.js +++ b/tests/zero-bounce.test.js @@ -1190,46 +1190,5 @@ 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" }) - ); - 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 3499fe3..9b6b120 100644 --- a/zeroBounceSDK.d.ts +++ b/zeroBounceSDK.d.ts @@ -87,6 +87,12 @@ export interface EmailValidationResult { city?: string | null; zipcode?: string | null; processed_at?: string | null; + /** With domainInfo: whether the domain has a website ("True", "False" or "unchecked"). */ + domain_website_exists?: string | null; + /** With domainInfo: WHOIS registrant company, "Registrant info not found" or "unchecked". */ + domain_registrant_company_name?: string | null; + /** Read from the API's "Message" key. Set while greylist processing is still running. */ + message?: string | null; } /** POST /validatebatch response body. */ @@ -256,6 +262,12 @@ export interface ValidateEmailOptions { activityData?: boolean; /** Maps to `verify_plus` on GET /validate. */ verifyPlus?: boolean; + /** Maps to `domain_info`. Adds domain_website_exists and domain_registrant_company_name. */ + domainInfo?: boolean; + /** Maps to `greylist_processing`. Reprocesses a greylisted address instead of returning unknown. */ + greylistProcessing?: boolean; + /** Maps to `callback_url`. Only allowed with `greylistProcessing: true`. */ + callbackUrl?: string; /** Use form-encoded POST instead of GET (same parameters). */ method?: "GET" | "POST"; } @@ -433,47 +445,38 @@ export interface ListFiltersOptions { 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; +/** Per-provider seed cap for createInboxPlacementTest. */ +export interface InboxPlacementProviderSelection { + id: number; + /** At least 1 when given. */ + maxSeeds?: number; } -/** Single entry from GET /listS3files. */ -export interface S3ListFileEntry { +/** Options for createInboxPlacementTest (POST /api/ipt/test). */ +export interface CreateInboxPlacementTestOptions { + /** Unique test name, at most 64 characters. */ name: string; - size: number; - last_modified: string; -} - -export interface S3BulkJobResponse { - success?: boolean; - message?: string; - file_id?: string; - export_path?: string; + /** 1 (Email, needs senderEmail), 2 (Unique ID) or 3 (Auto Alias). */ + testType?: 1 | 2 | 3; + /** The From address to match. Required when testType is 1. */ + senderEmail?: string; + providerIds?: number[]; + seedIds?: number[]; + providers?: InboxPlacementProviderSelection[]; + webhookUrl?: string; +} + +/** Options for listInboxPlacementTests (GET /api/ipt/tests). */ +export interface ListInboxPlacementTestsOptions { + status?: "running" | "finished"; + /** ISO 8601 date or datetime. */ + createdAfter?: string; + /** ISO 8601 date or datetime. */ + createdBefore?: string; + /** From 1. */ + page?: number; + /** 1 to 100. Sent as page_size. */ + pageSize?: number; } /** Options for findEmailByDomain / Email Finder. */ @@ -521,7 +524,16 @@ export class ZeroBounceSDK { * @param apiKey - Your private API key * @param apiBaseURL - Preferred API base (e.g. ZeroBounceSDK.ApiURL.DEFAULT_API_URL) */ - init(apiKey: string, apiBaseURL?: ApiURL): void; + init( + apiKey: string, + apiBaseURL?: ApiURL, + options?: { + /** Defaults to https://bulkapi.zerobounce.net/v2/listevaluator */ + listEvaluatorBaseURL?: string; + /** Defaults to https://bulkapi.zerobounce.net/api/ipt */ + inboxPlacementBaseURL?: string; + } | null + ): void; /** * Get remaining credits for your account. @@ -736,21 +748,29 @@ export class ZeroBounceSDK { options: FilterRuleParams ): Promise; - listS3files( - options?: { bucket?: string | null; search?: string | null } | null - ): Promise; + /** Inbox Placement Tester: seed providers. GET /api/ipt/providers */ + getInboxPlacementProviders(): Promise; + + /** Inbox Placement Tester: supported test types. GET /api/ipt/test-types */ + getInboxPlacementTestTypes(): Promise; + + /** Inbox Placement Tester: create a test. POST /api/ipt/test */ + createInboxPlacementTest(options: CreateInboxPlacementTestOptions): Promise; + + /** Inbox Placement Tester: a test and its results. GET /api/ipt/test/{id} */ + getInboxPlacementTest(testId: string | number): Promise; + + /** Inbox Placement Tester: list tests, newest first. GET /api/ipt/tests */ + listInboxPlacementTests(options?: ListInboxPlacementTestsOptions | null): Promise; - validateS3file( - options: ValidateS3FileOptions - ): Promise; + /** Inbox Placement Tester: delete a finished test. DELETE /api/ipt/test/{id} */ + deleteInboxPlacementTest(testId: string | number): Promise; - scoreS3file( - options: ScoreS3FileOptions - ): Promise; + /** Inbox Placement Tester: the webhook saved on this API key. GET /api/ipt/webhook */ + getInboxPlacementWebhook(): Promise; - exportS3file( - options: ExportS3FileOptions - ): Promise; + /** Inbox Placement Tester: save the result webhook. PUT /api/ipt/webhook */ + setInboxPlacementWebhook(webhookUrl: string): Promise; /** * Email Finder: find email by domain and name. From 408d0844781a8adc61c89d6a254e786e6ec1f065 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:37:05 +0400 Subject: [PATCH 07/14] revert: restore the S3 bulk methods They are documented, at https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, which lists listS3files, validateS3file, scoreS3file, exportS3file, emailFinderS3file and domainSearchS3file on bulkapi.zerobounce.net. The previous commit removed them on the incorrect basis that the public docs do not cover them; only the main API reference was considered. Restores listS3files, validateS3file, scoreS3file and exportS3file, the _sendBulkRootFormPost helper, the TypeScript types and declarations, the offline and legacy tests, and the changelog entry. The parity work in the previous commit is untouched. AGENTS.md keeps the rule that the SDK covers only documented endpoints and now points at where the S3 documentation lives, so the next reader does not repeat the same assumption. Not added here: emailFinderS3file and domainSearchS3file, and the SFTP variants listed on the Integrations index, are documented but have never been part of this SDK. Tests: 125 pass, plus dts-check and the webpack build. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 4 +- CHANGELOG.md | 1 + src/zero-bounce.js | 109 ++++++++++++++++++++++++++++++++++++++ tests/offline-api.test.js | 29 +++++++++- tests/zero-bounce.test.js | 41 ++++++++++++++ zeroBounceSDK.d.ts | 59 +++++++++++++++++++++ 6 files changed, 240 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6167c41..c41d771 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,12 +30,12 @@ There is no separate lint command. CI (`.github/workflows/sdk_ci.yml`) runs `npm ## Conventions -- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server. +- **Only endpoints documented at https://www.zerobounce.net/docs/.** Do not add a method for an endpoint that the public docs do not describe, even if it exists on the server. Check before concluding something is undocumented: the S3 bulk endpoints live under https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, not the main API reference. - **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`), and only when `allowPhase2` is set. Scoring `sendFile` ignores it. `activity_data` on get-file is validation `getFile` 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. - **List evaluator and the Inbox Placement Tester have their own base URLs**, set on the instance by `init`'s third argument: `https://bulkapi.zerobounce.net/v2/listevaluator` and `https://bulkapi.zerobounce.net/api/ipt`. They are not built from the bulk base. `sendEvaluatedFile` stays on the bulk `/v2` base. - **The Inbox Placement Tester takes the API key as the `api_key` query parameter and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`. - **`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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index cdd9583..b5d0e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`. - `validateEmail` accepts `domainInfo` (adds `domain_website_exists` and `domain_registrant_company_name` to the response), `greylistProcessing`, and `callbackUrl`. `callbackUrl` needs `greylistProcessing: true` and is rejected without it. - The validate response carries `message`, read from the API's `Message` key, which a greylist pickup returns while the retry is still running. - Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `getInboxPlacementProviders`, `getInboxPlacementTestTypes`, `createInboxPlacementTest`, `getInboxPlacementTest`, `listInboxPlacementTests`, `deleteInboxPlacementTest`, `getInboxPlacementWebhook`, `setInboxPlacementWebhook`. diff --git a/src/zero-bounce.js b/src/zero-bounce.js index 40b4a9f..6ecfe39 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -593,6 +593,22 @@ export class ZeroBounceSDK { }); } + async _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 * */ @@ -1028,6 +1044,99 @@ export class ZeroBounceSDK { } + // AMAZON S3 BULK HELPERS (bulkapi root) + + async 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, + }); + } + + async 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 (isMissing(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); + } + + async scoreS3file({ + file_name, + has_header_row, + remove_duplicates, + email_address_column = null, + remove_duplicate = null, + }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (isMissing(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); + } + + async exportS3file({ file_id, export_bucket }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (isMissing(file_id)) { + parameterIsMissing("file_id"); + return; + } else if (isMissing(export_bucket)) { + parameterIsMissing("export_bucket"); + return; + } + return this._sendBulkRootFormPost("/exportS3file", { + file_id, + export_bucket, + }); + } + // INBOX PLACEMENT TESTER (bulkapi.zerobounce.net/api/ipt) // The API key goes on the query string and bodies are JSON with camelCase fields. diff --git a/tests/offline-api.test.js b/tests/offline-api.test.js index 273bd43..1c672b8 100644 --- a/tests/offline-api.test.js +++ b/tests/offline-api.test.js @@ -317,6 +317,10 @@ describe("offline API", () => { ["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/"], + ["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"], ["getInboxPlacementProviders", () => sdk.getInboxPlacementProviders(), "GET", "bulkapi.zerobounce.net", "/api/ipt/providers"], ["getInboxPlacementTestTypes", () => sdk.getInboxPlacementTestTypes(), "GET", "bulkapi.zerobounce.net", "/api/ipt/test-types"], ["createInboxPlacementTest", () => sdk.createInboxPlacementTest({ name: "t" }), "POST", "bulkapi.zerobounce.net", "/api/ipt/test"], @@ -526,6 +530,20 @@ describe("offline API", () => { 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(); }); @@ -585,8 +603,16 @@ describe("offline API", () => { expect(request.fields.last_name).toBeUndefined(); }); - it("omits unset filter fields", async () => { + 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", @@ -622,6 +648,7 @@ describe("offline API", () => { 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" })); await expectRejected(sdk.validateEmail("a@example.com", { callbackUrl: "https://example.com/cb" })); await expectRejected( sdk.validateEmail("a@example.com", { greylistProcessing: true, callbackUrl: " " }) diff --git a/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index 7074fec..ad82770 100644 --- a/tests/zero-bounce.test.js +++ b/tests/zero-bounce.test.js @@ -1190,5 +1190,46 @@ 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" }) + ); + 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 9b6b120..1cd2187 100644 --- a/zeroBounceSDK.d.ts +++ b/zeroBounceSDK.d.ts @@ -445,6 +445,49 @@ export interface ListFiltersOptions { 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; +} + +/** Single entry from GET /listS3files. */ +export interface S3ListFileEntry { + name: string; + size: number; + last_modified: string; +} + +export interface S3BulkJobResponse { + success?: boolean; + message?: string; + file_id?: string; + export_path?: string; +} + /** Per-provider seed cap for createInboxPlacementTest. */ export interface InboxPlacementProviderSelection { id: number; @@ -748,6 +791,22 @@ export class ZeroBounceSDK { options: FilterRuleParams ): Promise; + listS3files( + options?: { bucket?: string | null; search?: string | null } | null + ): Promise; + + validateS3file( + options: ValidateS3FileOptions + ): Promise; + + scoreS3file( + options: ScoreS3FileOptions + ): Promise; + + exportS3file( + options: ExportS3FileOptions + ): Promise; + /** Inbox Placement Tester: seed providers. GET /api/ipt/providers */ getInboxPlacementProviders(): Promise; From e608ae410933a8b9fae6e2350c8b4b8d4236a3cc Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:50:47 +0400 Subject: [PATCH 08/14] feat: S3 email finder / domain search and SFTP/FTP bulk endpoints Ports b0444f8 from the Python SDK. These are documented but had never been in this SDK: - POST /emailFinderS3file and /domainSearchS3file, from https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api - POST /validateSFTPfile, /scoreSFTPfile, /emailFinderSFTPfile, /domainSearchSFTPfile and /exportSFTPfile, from https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api All six file jobs share _bulkRootFileJob, which sends has_header_row and remove_duplicates as the strings true/false and leaves remove_duplicates and return_url out until set. Two shapes differ from the S3 ones and are easy to get wrong: - validateSFTPfile's file_name is optional; omitting it uses the input bucket from the account's settings. Every other job requires it. - exportSFTPfile takes export_path, not export_bucket, and omitting it uses the export path from those settings. Tests: 126 pass, plus dts-check and the webpack build. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 +- README.md | 24 ++++++++++ documentation.md | 24 ++++++++++ src/zero-bounce.js | 93 +++++++++++++++++++++++++++++++++++++++ tests/offline-api.test.js | 53 ++++++++++++++++++++++ zeroBounceSDK.d.ts | 45 +++++++++++++++++++ 6 files changed, 241 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d0e6f..7367839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`. +- S3 bulk: `validateS3file`, `scoreS3file`, `exportS3file`, `listS3files`, `emailFinderS3file`, `domainSearchS3file`. +- SFTP/FTP bulk: `validateSFTPfile`, `scoreSFTPfile`, `emailFinderSFTPfile`, `domainSearchSFTPfile`, `exportSFTPfile`. The server, path and protocol come from your ZeroBounce settings. `validateSFTPfile`'s `file_name` is optional; `exportSFTPfile` takes `export_path` rather than `export_bucket`. - `validateEmail` accepts `domainInfo` (adds `domain_website_exists` and `domain_registrant_company_name` to the response), `greylistProcessing`, and `callbackUrl`. `callbackUrl` needs `greylistProcessing: true` and is rejected without it. - The validate response carries `message`, read from the API's `Message` key, which a greylist pickup returns while the retry is still running. - Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `getInboxPlacementProviders`, `getInboxPlacementTestTypes`, `createInboxPlacementTest`, `getInboxPlacementTest`, `listInboxPlacementTests`, `deleteInboxPlacementTest`, `getInboxPlacementWebhook`, `setInboxPlacementWebhook`. diff --git a/README.md b/README.md index 171f6da..eb3d327 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,30 @@ try { } ``` +- ##### S3 and SFTP/FTP bulk jobs + +```javascript +// Amazon S3: https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api +await zeroBounce.emailFinderS3file({ file_name: "lists/leads.csv", has_header_row: true }); +await zeroBounce.domainSearchS3file({ + file_name: "lists/leads.csv", + has_header_row: true, + remove_duplicates: true, + return_url: "https://example.com/zerobounce-done", +}); + +// SFTP/FTP: https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api +// The server, path and protocol come from your ZeroBounce settings. +// file_name is optional for validateSFTPfile only. +await zeroBounce.validateSFTPfile({ has_header_row: true }); +await zeroBounce.scoreSFTPfile({ file_name: "my_file.txt", has_header_row: true }); +await zeroBounce.emailFinderSFTPfile({ file_name: "my_file.txt", has_header_row: true }); +await zeroBounce.domainSearchSFTPfile({ file_name: "my_file.txt", has_header_row: true }); + +// Without export_path the path from your settings is used. +await zeroBounce.exportSFTPfile({ file_id: "", export_path: "/exports" }); +``` + - ##### Inbox Placement Tester ```javascript diff --git a/documentation.md b/documentation.md index 8b4ee1c..e3232ef 100644 --- a/documentation.md +++ b/documentation.md @@ -92,6 +92,30 @@ try { } ``` +- ##### S3 and SFTP/FTP bulk jobs + +```javascript +// Amazon S3: https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api +await zeroBounce.emailFinderS3file({ file_name: "lists/leads.csv", has_header_row: true }); +await zeroBounce.domainSearchS3file({ + file_name: "lists/leads.csv", + has_header_row: true, + remove_duplicates: true, + return_url: "https://example.com/zerobounce-done", +}); + +// SFTP/FTP: https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api +// The server, path and protocol come from your ZeroBounce settings. +// file_name is optional for validateSFTPfile only. +await zeroBounce.validateSFTPfile({ has_header_row: true }); +await zeroBounce.scoreSFTPfile({ file_name: "my_file.txt", has_header_row: true }); +await zeroBounce.emailFinderSFTPfile({ file_name: "my_file.txt", has_header_row: true }); +await zeroBounce.domainSearchSFTPfile({ file_name: "my_file.txt", has_header_row: true }); + +// Without export_path the path from your settings is used. +await zeroBounce.exportSFTPfile({ file_id: "", export_path: "/exports" }); +``` + - ##### Inbox Placement Tester ```javascript diff --git a/src/zero-bounce.js b/src/zero-bounce.js index 6ecfe39..bf6e10c 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -1137,6 +1137,99 @@ export class ZeroBounceSDK { }); } + + /** + * Shared shape for the documented S3 and SFTP file jobs on the bulkapi root. + * Only fields the caller set are sent; booleans go as the strings true/false. + * + * @param fileNameRequired - false for validateSFTPfile, whose file_name is optional + * */ + async _bulkRootFileJob(path, { file_name, has_header_row, remove_duplicates, return_url }, fileNameRequired = true) { + if (!this._initialized) { + notInitialized(); + return; + } + if (fileNameRequired && isMissing(file_name)) { + parameterIsMissing("file_name"); + return; + } + if (file_name != null && String(file_name).trim() === "") { + parameterIsMissing("file_name"); + return; + } + if (has_header_row == null) { + parameterIsMissing("has_header_row"); + return; + } + + const fields = { has_header_row: booleanText(has_header_row) }; + if (file_name != null) fields.file_name = file_name; + if (remove_duplicates != null) fields.remove_duplicates = booleanText(remove_duplicates); + if (return_url != null) fields.return_url = return_url; + return this._sendBulkRootFormPost(path, fields); + } + + /** Run the email finder on a file in your Amazon S3 bucket. POST /emailFinderS3file */ + async emailFinderS3file(options) { + return this._bulkRootFileJob("/emailFinderS3file", options || {}); + } + + /** Run domain search on a file in your Amazon S3 bucket. POST /domainSearchS3file */ + async domainSearchS3file(options) { + return this._bulkRootFileJob("/domainSearchS3file", options || {}); + } + + // SFTP / FTP BULK (bulkapi root) + // The server, path and protocol come from your ZeroBounce settings. + + /** + * Validate a file from your SFTP/FTP server. POST /validateSFTPfile + * + * The docs list file_name as optional here; leave it out to use the input + * bucket configured in your settings. + * */ + async validateSFTPfile(options) { + return this._bulkRootFileJob("/validateSFTPfile", options || {}, false); + } + + /** Score a file from your SFTP/FTP server. POST /scoreSFTPfile */ + async scoreSFTPfile(options) { + return this._bulkRootFileJob("/scoreSFTPfile", options || {}); + } + + /** Run the email finder on a file from your SFTP/FTP server. POST /emailFinderSFTPfile */ + async emailFinderSFTPfile(options) { + return this._bulkRootFileJob("/emailFinderSFTPfile", options || {}); + } + + /** Run domain search on a file from your SFTP/FTP server. POST /domainSearchSFTPfile */ + async domainSearchSFTPfile(options) { + return this._bulkRootFileJob("/domainSearchSFTPfile", options || {}); + } + + /** + * Export a validated or scored file back to your SFTP/FTP server. POST /exportSFTPfile + * + * Without export_path the path from your ZeroBounce settings is used. + * */ + async exportSFTPfile({ file_id, export_path = null } = {}) { + if (!this._initialized) { + notInitialized(); + return; + } + if (isMissing(file_id)) { + parameterIsMissing("file_id"); + return; + } + if (export_path != null && String(export_path).trim() === "") { + parameterIsMissing("export_path"); + return; + } + const fields = { file_id }; + if (export_path != null) fields.export_path = export_path; + return this._sendBulkRootFormPost("/exportSFTPfile", fields); + } + // INBOX PLACEMENT TESTER (bulkapi.zerobounce.net/api/ipt) // The API key goes on the query string and bodies are JSON with camelCase fields. diff --git a/tests/offline-api.test.js b/tests/offline-api.test.js index 1c672b8..27787b4 100644 --- a/tests/offline-api.test.js +++ b/tests/offline-api.test.js @@ -321,6 +321,13 @@ describe("offline API", () => { ["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"], + ["emailFinderS3file", () => sdk.emailFinderS3file({ file_name: "f.csv", has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/emailFinderS3file"], + ["domainSearchS3file", () => sdk.domainSearchS3file({ file_name: "f.csv", has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/domainSearchS3file"], + ["validateSFTPfile", () => sdk.validateSFTPfile({ has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/validateSFTPfile"], + ["scoreSFTPfile", () => sdk.scoreSFTPfile({ file_name: "f.csv", has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/scoreSFTPfile"], + ["emailFinderSFTPfile", () => sdk.emailFinderSFTPfile({ file_name: "f.csv", has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/emailFinderSFTPfile"], + ["domainSearchSFTPfile", () => sdk.domainSearchSFTPfile({ file_name: "f.csv", has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/domainSearchSFTPfile"], + ["exportSFTPfile", () => sdk.exportSFTPfile({ file_id: "fid" }), "POST", "bulkapi.zerobounce.net", "/exportSFTPfile"], ["getInboxPlacementProviders", () => sdk.getInboxPlacementProviders(), "GET", "bulkapi.zerobounce.net", "/api/ipt/providers"], ["getInboxPlacementTestTypes", () => sdk.getInboxPlacementTestTypes(), "GET", "bulkapi.zerobounce.net", "/api/ipt/test-types"], ["createInboxPlacementTest", () => sdk.createInboxPlacementTest({ name: "t" }), "POST", "bulkapi.zerobounce.net", "/api/ipt/test"], @@ -649,6 +656,11 @@ describe("offline API", () => { await expectRejected(sdk.addFilter({ rule: "", target: "domain", value: "spam.com" })); await expectRejected(sdk.getFile("")); await expectRejected(sdk.exportS3file({ file_id: "id" })); + await expectRejected(sdk.emailFinderS3file({ has_header_row: true })); + await expectRejected(sdk.scoreSFTPfile({ file_name: " ", has_header_row: true })); + await expectRejected(sdk.domainSearchSFTPfile({ file_name: "f.csv" })); + await expectRejected(sdk.exportSFTPfile({})); + await expectRejected(sdk.exportSFTPfile({ file_id: "fid", export_path: " " })); await expectRejected(sdk.validateEmail("a@example.com", { callbackUrl: "https://example.com/cb" })); await expectRejected( sdk.validateEmail("a@example.com", { greylistProcessing: true, callbackUrl: " " }) @@ -779,6 +791,47 @@ describe("offline API", () => { expect(error.message).toContain("Inbox placement test not found"); }); + it("sends the documented S3 and SFTP job fields and omits unset ones", async () => { + const sdk = client("parity-key"); + await sdk.emailFinderS3file({ file_name: "inbox.csv", has_header_row: false }); + let request = fake.only(); + expect(request.path).toBe("/emailFinderS3file"); + expect(request.body.file_name).toBe("inbox.csv"); + expect(request.body.has_header_row).toBe("false"); + expect(request.body.remove_duplicates).toBeUndefined(); + expect(request.body.return_url).toBeUndefined(); + + fake.records.length = 0; + await sdk.domainSearchS3file({ + file_name: "inbox.csv", + has_header_row: true, + remove_duplicates: false, + return_url: "https://example.com/done", + }); + request = fake.only(); + expect(request.body.has_header_row).toBe("true"); + expect(request.body.remove_duplicates).toBe("false"); + expect(request.body.return_url).toBe("https://example.com/done"); + + // validateSFTPfile is the one job whose file_name the docs mark optional. + fake.records.length = 0; + await sdk.validateSFTPfile({ has_header_row: true }); + request = fake.only(); + expect(request.path).toBe("/validateSFTPfile"); + expect(request.body.file_name).toBeUndefined(); + + fake.records.length = 0; + await sdk.exportSFTPfile({ file_id: "fid" }); + request = fake.only(); + expect(request.path).toBe("/exportSFTPfile"); + expect(request.body.file_id).toBe("fid"); + expect(request.body.export_path).toBeUndefined(); + + fake.records.length = 0; + await sdk.exportSFTPfile({ file_id: "fid", export_path: "/out" }); + expect(fake.only().body.export_path).toBe("/out"); + }); + 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"); diff --git a/zeroBounceSDK.d.ts b/zeroBounceSDK.d.ts index 1cd2187..5d39f7b 100644 --- a/zeroBounceSDK.d.ts +++ b/zeroBounceSDK.d.ts @@ -522,6 +522,22 @@ export interface ListInboxPlacementTestsOptions { pageSize?: number; } +/** Shared options for the documented S3 and SFTP file jobs. */ +export interface BulkRootFileJobOptions { + /** Full path as returned by listS3files. Optional only for validateSFTPfile. */ + file_name?: string; + has_header_row: boolean; + remove_duplicates?: boolean; + return_url?: string; +} + +/** Options for exportSFTPfile. */ +export interface ExportSFTPFileOptions { + file_id: string; + /** Defaults to the export path in your ZeroBounce settings. */ + export_path?: string; +} + /** Options for findEmailByDomain / Email Finder. */ export interface FindEmailByDomainOptions { domain: string; @@ -807,6 +823,35 @@ export class ZeroBounceSDK { options: ExportS3FileOptions ): Promise; + emailFinderS3file( + options: BulkRootFileJobOptions + ): Promise; + + domainSearchS3file( + options: BulkRootFileJobOptions + ): Promise; + + /** file_name is optional here; omit it to use the configured input bucket. */ + validateSFTPfile( + options: BulkRootFileJobOptions | { has_header_row: boolean; remove_duplicates?: boolean; return_url?: string } + ): Promise; + + scoreSFTPfile( + options: BulkRootFileJobOptions + ): Promise; + + emailFinderSFTPfile( + options: BulkRootFileJobOptions + ): Promise; + + domainSearchSFTPfile( + options: BulkRootFileJobOptions + ): Promise; + + exportSFTPfile( + options: ExportSFTPFileOptions + ): Promise; + /** Inbox Placement Tester: seed providers. GET /api/ipt/providers */ getInboxPlacementProviders(): Promise; From e59e06f940dc1a6ed2ff14581c41536ac75fc37d Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:43:57 +0400 Subject: [PATCH 09/14] feat: add ai_agent_mailbox sub_status (RD-66) The API now returns ai_agent_mailbox under both the valid and catch-all statuses. No new status was added. Adds AI_AGENT_MAILBOX to the ZBValidateSubStatus helper in src/zero-bounce.js and to its type in zeroBounceSDK.d.ts, following ROLE_BASED_CATCH_ALL. sub_status is forwarded as a raw string rather than mapped onto the helper, so an unknown value was already safe here. A test now pins that, alongside valid + ai_agent_mailbox and catch-all + ai_agent_mailbox. No sub_status_ai_agent_mailbox usage counter: it is not documented. Co-Authored-By: Claude Opus 5 --- src/zero-bounce.js | 3 ++- tests/zero-bounce.test.js | 57 ++++++++++++++++++++++++++++++++++++++- zeroBounceSDK.d.ts | 1 + 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/zero-bounce.js b/src/zero-bounce.js index bf6e10c..18c55dd 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -52,7 +52,8 @@ export const ZBValidateSubStatus = Object.freeze({ ALLOWED: "allowed", ACCEPT_ALL: "accept_all", ROLE_BASED_ACCEPT_ALL: "role_based_accept_all", - GOLD: "gold" + GOLD: "gold", + AI_AGENT_MAILBOX: "ai_agent_mailbox" }); /** diff --git a/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index ad82770..4addf90 100644 --- a/tests/zero-bounce.test.js +++ b/tests/zero-bounce.test.js @@ -1,4 +1,4 @@ -import { ZeroBounceSDK } from "../src/zero-bounce.js"; +import { ZeroBounceSDK, ZBValidateSubStatus } from "../src/zero-bounce.js"; import * as utils from "../src/utils.js"; import 'whatwg-fetch' @@ -213,6 +213,61 @@ describe("ZeroBounceSDK", () => { expect(response).toEqual(expectedResponse); }); + it("returns ai_agent_mailbox under the valid status", async () => { + const expectedResponse = { + address: "agent@example.com", + status: "valid", + sub_status: "ai_agent_mailbox", + }; + + jest.spyOn(global, "fetch").mockImplementationOnce(() => 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("agent@example.com"); + expect(response.status).toEqual("valid"); + expect(response.sub_status).toEqual(ZBValidateSubStatus.AI_AGENT_MAILBOX); + }); + + it("returns ai_agent_mailbox under the catch-all status", async () => { + const expectedResponse = { + address: "agent@example.com", + status: "catch-all", + sub_status: "ai_agent_mailbox", + }; + + jest.spyOn(global, "fetch").mockImplementationOnce(() => 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("agent@example.com"); + expect(response.status).toEqual("catch-all"); + expect(response.sub_status).toEqual(ZBValidateSubStatus.AI_AGENT_MAILBOX); + }); + + it("passes an unknown sub_status through untouched", async () => { + // sub_status is forwarded as a raw string, so a value the SDK has never + // heard of must survive rather than being dropped or throwing. + const expectedResponse = { + address: "future@example.com", + status: "valid", + sub_status: "some_future_sub_status", + }; + + jest.spyOn(global, "fetch").mockImplementationOnce(() => 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("future@example.com"); + expect(response.sub_status).toEqual("some_future_sub_status"); + }); + it("should pass options object to the API", async () => { const expectedResponse = { "address": "valid@example.com", diff --git a/zeroBounceSDK.d.ts b/zeroBounceSDK.d.ts index 5d39f7b..b16a52a 100644 --- a/zeroBounceSDK.d.ts +++ b/zeroBounceSDK.d.ts @@ -48,6 +48,7 @@ export type ZBValidateSubStatusType = Readonly<{ ACCEPT_ALL: "accept_all"; ROLE_BASED_ACCEPT_ALL: "role_based_accept_all"; GOLD: "gold"; + AI_AGENT_MAILBOX: "ai_agent_mailbox"; }>; /** `status` field on validate / validateBatch results (matches {@link ZBValidateStatusType} values). */ From 7f9fe443242cc09adf2a47cbafd62fa617f3cfa0 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:02:17 +0400 Subject: [PATCH 10/14] fix: follow the live API where it disagrees with the docs Verified against the live API on 2026-10-24 and ported from the Python SDK. - validateBatch sends `timeout` as a JSON string ("30"). As a number the API rejects the whole batch with the account-level "Invalid API Key or your account ran out of credits" error. activity_data and verify_plus stay JSON booleans. - sendEvaluatedFile takes the list evaluator's file_id as a form field, not a file upload as the docs describe: an upload is rejected with "file_id: This field is required". The signature is now sendEvaluatedFile(fileId); it returns a new validation job's file_id, which answers "File cannot be found" for a short while after it is accepted. - The Inbox Placement Tester authenticates with the documented "Authorization: Bearer " header instead of an api_key query parameter. Alongside those: - ZBApiError carries statusCode, so callers can tell a 403 (Cloudflare) from a 400/404 (API) or a 429 (Warmup). - Error bodies also read error_message, a "detail" validation list ([{loc, msg}] becomes "field: msg"), and a top-level JSON list of strings (["In-progress tests cannot be deleted."]). - {"success": false} on an HTTP 200 now raises instead of being returned. Two existing tests encoded the old behaviour and were updated. - A 3xx is raised rather than followed. - Keys are rejected when they carry surrounding whitespace or control characters, which fetch would otherwise quote unredacted in its own error. - Network errors are redacted for every form of the key and raised on their own, so the original (which can hold the key in its URL) is never the cause. - Request URLs must be https, including every overridable base URL. - A once-per-client warning when a regional (EU/US) base URL is set but a request goes to bulkapi.zerobounce.net: there is no regional bulk host. - The email finder and domain search families get their own overridable base URLs, like the list evaluator and the Tester already had. The offline fake now records the Authorization header, so it can answer requests whose key travels there. Tests: 129 pass. Co-Authored-By: Claude Opus 5 --- src/utils.js | 187 ++++++++++++++++++++++++++++++++------ src/zero-bounce.js | 75 ++++++++++++--- tests/offline-api.test.js | 19 +++- tests/zero-bounce.test.js | 12 ++- 4 files changed, 244 insertions(+), 49 deletions(-) diff --git a/src/utils.js b/src/utils.js index c403c4b..a8170fc 100644 --- a/src/utils.js +++ b/src/utils.js @@ -8,13 +8,72 @@ export const HEADERS = { Connection: "keep-alive", }; -export function requireHttpsUrl(url) { +/** + * An error from the API, carrying the HTTP status so callers can tell a 403 + * (Cloudflare) from a 400/404 (API) or a 429 (Warmup rate limit). + */ +export class ZBApiError extends Error { + constructor(message, statusCode = 0) { + super(message); + this.name = "ZBApiError"; + this.statusCode = statusCode; + } +} + +export function apiError(message, statusCode = 0) { + return new ZBApiError(message, statusCode); +} + +/** + * Every request carries a key: refuse to send one anywhere but https. + * Covers each overridable base URL, not just the main one. + */ +export function requireHttpsUrl(url, name = "apiBaseURL") { if (typeof url !== "string" || !/^https:\/\//i.test(url)) { - throw new Error("apiBaseURL must be an https:// URL"); + throw new Error(`ZeroBounce: ${name} must be an https:// URL`); } return url; } +/** + * Reject a key with surrounding whitespace or control characters — for example + * one read from a file with its trailing newline. `fetch` would otherwise + * reject it as a header value and quote it, unredacted, in its own error. + */ +export function checkApiKey(apiKey, name = "api key") { + if (typeof apiKey !== "string" || !apiKey.trim()) { + throw new Error(`ZeroBounce: ${name} parameter is missing.`); + } + if (apiKey !== apiKey.trim() || /[\u0000-\u001f\u007f]/.test(apiKey)) { + throw new Error( + `ZeroBounce: ${name} has surrounding whitespace or control characters.` + ); + } + return apiKey; +} + +/** A caller value as one URL path segment: percent-encoded, never "." or "..". */ +export function pathSegment(value, name) { + const text = value == null ? "" : String(value).trim(); + if (!text) throw new Error(`ZeroBounce: ${name} parameter is missing.`); + if (text === "." || text === "..") { + throw new Error(`ZeroBounce: ${name} parameter is invalid.`); + } + return encodeURIComponent(text); +} + +/** Replace every form a secret could take in a message with ***. */ +export function redactSecrets(text, secrets = []) { + let value = String(text); + for (const secret of secrets) { + if (!secret) continue; + for (const form of [secret, encodeURIComponent(secret), escape(secret)]) { + if (form) value = value.split(form).join("***"); + } + } + return value.replace(/([?&](?:api_key|API-Key)=)[^&\s"']+/gi, "$1***"); +} + function fetchTimeoutSignal() { if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") { return AbortSignal.timeout(HTTP_TIMEOUT_MS); @@ -109,14 +168,28 @@ function isAccountLevelText(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(); - // "detail" is how the Inbox Placement Tester (Django REST framework) reports errors. - if (typeof parsed.detail === "string" && parsed.detail.trim()) return parsed.detail.trim(); - if (Array.isArray(parsed.message)) { - const parts = parsed.message.filter((item) => typeof item === "string" && item.trim()); - if (parts.length) return parts.join("; "); + // A top-level JSON list of strings, e.g. the Inbox Placement Tester's + // ["In-progress tests cannot be deleted."]. + if (Array.isArray(parsed)) { + const first = parsed.find((item) => typeof item === "string" && item.trim()); + return first ? first.trim() : null; + } + if (!parsed || typeof parsed !== "object") return null; + // "detail" is how the Inbox Placement Tester (Django REST framework) and the + // Warmup API report errors. + for (const key of ["error", "message", "error_message", "detail"]) { + const value = parsed[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + if (Array.isArray(value) && value.length) { + const strings = value.filter((item) => typeof item === "string" && item.trim()); + if (strings.length) return strings.join("; "); + // A validation error list: [{loc: ["body", "field"], msg: "..."}]. + const first = value[0]; + if (first && typeof first === "object" && first.msg) { + const loc = Array.isArray(first.loc) ? first.loc.slice(1).join(".") : ""; + return loc ? `${loc}: ${first.msg}` : String(first.msg); + } + } } return null; } @@ -173,11 +246,37 @@ function messageFromBody(body, statusCode) { return message || `Empty response from ZeroBounce (HTTP ${statusCode})`; } -function transportError(error) { +/** + * A network failure, with every form of the secret removed. + * + * The redacted copy is returned on its own: the original is never attached as + * `cause`, because its message and request URL can carry the key. + */ +function transportError(error, secrets = []) { 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); + return new Error(redactSecrets(`${name}: ${message}`, secrets)); +} + +/** + * ZeroBounce documents no regional bulk host, so an EU or US base URL still + * sends bulk traffic to bulkapi.zerobounce.net. Warn once per client. + */ +const REGIONAL_API_HOSTS = ["api-eu.zerobounce.net", "api-us.zerobounce.net"]; + +export function warnOnRegionalBulkHost(url, apiBaseURL, state) { + if (!state || state.warned) return; + const base = String(apiBaseURL || "").toLowerCase(); + if (!REGIONAL_API_HOSTS.some((host) => base.includes(host))) return; + if (!String(url).toLowerCase().includes("bulkapi.zerobounce.net")) return; + state.warned = true; + const warn = typeof console !== "undefined" && console.warn ? console.warn : null; + if (warn) { + warn( + "ZeroBounce: a regional base URL is set, but this request goes to " + + "bulkapi.zerobounce.net. ZeroBounce documents no regional bulk host." + ); + } } async function readBodyText(response) { @@ -193,10 +292,10 @@ function processGetFileResponseBody(finalResult, response, scoring) { const status = responseStatus(response); const ct = responseContentType(response); if (looksLikeHtml(finalResult) || ct.includes("text/html") || status >= 400) { - throw new Error(messageFromBody(finalResult, status)); + throw apiError(messageFromBody(finalResult, status), status); } if (!String(finalResult).trim()) { - throw new Error(`Empty response from ZeroBounce (HTTP ${status})`); + throw apiError(`Empty response from ZeroBounce (HTTP ${status})`, status); } const trimmed = finalResult.trim(); @@ -210,7 +309,7 @@ function processGetFileResponseBody(finalResult, response, scoring) { return parsed; } } catch { - throw new Error("ZeroBounce returned a response that is not valid JSON."); + throw apiError("ZeroBounce returned a response that is not valid JSON.", status); } } @@ -226,8 +325,10 @@ 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; +function buildRequestUrl({ batch, bulkBase, apiBaseURL, baseURL, path, params }) { + // An explicit baseURL wins: the list evaluator, the Inbox Placement Tester and + // the finder/domain-search families each have their own, overridable host. + const base = baseURL != null ? baseURL : batch ? bulkApiBase(bulkBase) : apiBaseURL; const query = params != null && Object.keys(params).length > 0 ? `?${new URLSearchParams(params)}` @@ -245,31 +346,52 @@ export async function createRequest({ bulkBase = "v2", returnText = false, scoring = false, - apiBaseURL + apiBaseURL, + baseURL = null, + bearerToken = null, + extraHeaders = null, + secrets = [], + regionalWarningState = null, }) { - const url = buildRequestUrl({ batch, bulkBase, apiBaseURL, path, params }); + const url = buildRequestUrl({ batch, bulkBase, apiBaseURL, baseURL, path, params }); + requireHttpsUrl(url, "request URL"); + warnOnRegionalBulkHost(url, apiBaseURL, regionalWarningState); + + const headers = { ...HEADERS }; + // The Inbox Placement Tester takes JSON; everything else sends query, form, + // or multipart bodies, which must not carry a JSON content type. + if (jsonBody != null) headers["Content-Type"] = "application/json"; + // The Inbox Placement Tester documents Authorization: Bearer, not a query key. + if (bearerToken) headers.Authorization = `Bearer ${bearerToken}`; + if (extraHeaders) Object.assign(headers, extraHeaders); let response; try { response = await fetch(url, { method: requestType, - // The Inbox Placement Tester takes JSON; everything else sends query, - // form, or multipart bodies, which must not carry a JSON content type. - headers: jsonBody != null ? { ...HEADERS, "Content-Type": "application/json" } : HEADERS, + headers, body: jsonBody != null ? JSON.stringify(jsonBody) : body, signal: fetchTimeoutSignal(), redirect: "error", }); } catch (error) { - // Some fetch polyfills embed the request URL, including api_key, in errors. - throw transportError(error); + // Some fetch polyfills embed the request URL, including the key, in errors. + throw transportError(error, secrets); + } + + const redirectStatus = responseStatus(response); + if (redirectStatus >= 300 && redirectStatus < 400) { + throw apiError( + "ZeroBounce returned a redirect, which this client does not follow.", + redirectStatus + ); } let finalResult; try { finalResult = await readBodyText(response); } catch (error) { - throw transportError(error); + throw transportError(error, secrets); } if (returnText) { @@ -284,17 +406,24 @@ export async function createRequest({ status >= 400 || !String(finalResult).trim() ) { - throw new Error(messageFromBody(finalResult, status)); + throw apiError(redactSecrets(messageFromBody(finalResult, status), secrets), status); } let parsed; try { parsed = JSON.parse(finalResult); } catch { - throw new Error(messageFromBody(finalResult, status)); + throw apiError(redactSecrets(messageFromBody(finalResult, status), secrets), status); } const account = accountLevelMessage(parsed); - if (account) throw new Error(account); + if (account) throw apiError(account, status); + // {"success": false, ...} on an HTTP 200 is still a failure. + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const failed = parsed.success === false || parsed.success === "False" || parsed.success === "false"; + if (failed) { + throw apiError(messageFromBody(finalResult, status), status); + } + } return parsed; } diff --git a/src/zero-bounce.js b/src/zero-bounce.js index 18c55dd..d4678de 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -1,4 +1,4 @@ -import { createRequest, notInitialized, parameterIsMissing, parameterIsInvalid, requireHttpsUrl } from "./utils.js"; +import { createRequest, notInitialized, parameterIsMissing, parameterIsInvalid, requireHttpsUrl, checkApiKey, pathSegment } from "./utils.js"; /** * Validation status values returned by the API (validate, validateBatch). @@ -101,12 +101,24 @@ export class ZeroBounceSDK { DEFAULT_URL: "https://bulkapi.zerobounce.net/api/ipt", }); + static EmailFinderURL = Object.freeze({ + DEFAULT_URL: "https://bulkapi.zerobounce.net/email-finder", + }); + + static DomainSearchURL = Object.freeze({ + DEFAULT_URL: "https://bulkapi.zerobounce.net/domain-search", + }); + constructor() { this._initialized = false; this._api_key = null; this._api_base_url = ZeroBounceSDK.ApiURL.DEFAULT_API_URL; this._list_evaluator_base_url = ZeroBounceSDK.ListEvaluatorURL.DEFAULT_URL; this._inbox_placement_base_url = ZeroBounceSDK.InboxPlacementURL.DEFAULT_URL; + this._email_finder_base_url = ZeroBounceSDK.EmailFinderURL.DEFAULT_URL; + this._domain_search_base_url = ZeroBounceSDK.DomainSearchURL.DEFAULT_URL; + // One warning per client: see warnOnRegionalBulkHost. + this._regional_warning = { warned: false }; } /** @@ -117,15 +129,32 @@ export class ZeroBounceSDK { if (apiKey == null || String(apiKey).trim() === "") { throw new Error("ZeroBounce: Api key parameter is missing. Please provide a valid API key."); } + checkApiKey(apiKey, "Api key"); this._api_key = apiKey; this._api_base_url = requireHttpsUrl(apiBaseURL); - const { listEvaluatorBaseURL, inboxPlacementBaseURL } = options || {}; + const { + listEvaluatorBaseURL, + inboxPlacementBaseURL, + emailFinderBaseURL, + domainSearchBaseURL, + } = options || {}; this._list_evaluator_base_url = requireHttpsUrl( - listEvaluatorBaseURL ?? ZeroBounceSDK.ListEvaluatorURL.DEFAULT_URL + listEvaluatorBaseURL ?? ZeroBounceSDK.ListEvaluatorURL.DEFAULT_URL, + "listEvaluatorBaseURL" ); this._inbox_placement_base_url = requireHttpsUrl( - inboxPlacementBaseURL ?? ZeroBounceSDK.InboxPlacementURL.DEFAULT_URL + inboxPlacementBaseURL ?? ZeroBounceSDK.InboxPlacementURL.DEFAULT_URL, + "inboxPlacementBaseURL" ); + this._email_finder_base_url = requireHttpsUrl( + emailFinderBaseURL ?? ZeroBounceSDK.EmailFinderURL.DEFAULT_URL, + "emailFinderBaseURL" + ); + this._domain_search_base_url = requireHttpsUrl( + domainSearchBaseURL ?? ZeroBounceSDK.DomainSearchURL.DEFAULT_URL, + "domainSearchBaseURL" + ); + this._regional_warning = { warned: false }; this._initialized = true; } @@ -283,7 +312,11 @@ export class ZeroBounceSDK { api_key: this._api_key, email_batch: emailBatch, }; - if (timeout != null) body.timeout = timeout; + // LIVE: the API requires `timeout` as a JSON string ("30"). Sent as a + // number it rejects the whole batch with the account-level + // "Invalid API Key or your account ran out of credits" error. + // activity_data and verify_plus stay JSON booleans. + if (timeout != null) body.timeout = String(timeout); if (activityData != null) body.activity_data = activityData; if (verifyPlus != null) body.verify_plus = verifyPlus; return createRequest({ @@ -939,23 +972,38 @@ export class ZeroBounceSDK { }); } - async sendEvaluatedFile({ file }) { + /** + * Submit a finished evaluation for validation. POST /v2/send-evaluated-file/ + * + * LIVE: this takes the list evaluator's `file_id` as a form field, not a file + * upload as the docs describe — an upload is rejected with + * "file_id: This field is required". It returns a new validation job's + * file_id, which answers "File cannot be found" for a short while after it is + * accepted. + * + * @param fileId - the file_id returned by the list evaluator + */ + async sendEvaluatedFile(fileId) { if (!this._initialized) { notInitialized(); return; - } else if (!file) { - parameterIsMissing("file"); + } + const id = typeof fileId === "object" && fileId !== null ? fileId.file_id : fileId; + if (isMissing(id)) { + parameterIsMissing("File id"); return; } const body = new FormData(); - body.append("file", file); body.append("api_key", this._api_key); + body.append("file_id", String(id)); return createRequest({ requestType: "POST", path: "/send-evaluated-file/", body, batch: true, + secrets: [this._api_key], apiBaseURL: this._api_base_url, + regionalWarningState: this._regional_warning, }); } @@ -1238,9 +1286,14 @@ export class ZeroBounceSDK { return createRequest({ requestType, path, - params: { api_key: this._api_key, ...(params || {}) }, + params, jsonBody, - apiBaseURL: this._inbox_placement_base_url, + // Documented auth for this API is a Bearer header, not an api_key query. + bearerToken: this._api_key, + secrets: [this._api_key], + apiBaseURL: this._api_base_url, + baseURL: this._inbox_placement_base_url, + regionalWarningState: this._regional_warning, }); } diff --git a/tests/offline-api.test.js b/tests/offline-api.test.js index 27787b4..b898235 100644 --- a/tests/offline-api.test.js +++ b/tests/offline-api.test.js @@ -215,16 +215,24 @@ function installFakeFetch() { const parsed = new URL(String(url)); const body = await readBody(init.body); const query = Object.fromEntries(parsed.searchParams.entries()); + const headers = init.headers || {}; + const authorization = String(headers.Authorization || headers.authorization || ""); + // The Inbox Placement Tester authenticates with a Bearer header rather than + // an api_key parameter, so the key can arrive either way. + const bearer = authorization.startsWith("Bearer ") ? authorization.slice(7) : ""; const recorded = { method: String(init.method || "GET").toUpperCase(), host: parsed.host, path: parsed.pathname, url: String(url), query, + headers, + authorization, + bearer, body: body.fields, json: body.json, fields: { ...query, ...body.fields }, - apiKey: body.fields.api_key || query.api_key || "", + apiKey: body.fields.api_key || query.api_key || bearer || "", redirect: init.redirect, }; records.push(recorded); @@ -336,7 +344,7 @@ describe("offline API", () => { ["deleteInboxPlacementTest", () => sdk.deleteInboxPlacementTest(id), "DELETE", "bulkapi.zerobounce.net", "/api/ipt/test/file-1"], ["getInboxPlacementWebhook", () => sdk.getInboxPlacementWebhook(), "GET", "bulkapi.zerobounce.net", "/api/ipt/webhook"], ["setInboxPlacementWebhook", () => sdk.setInboxPlacementWebhook("https://example.com/hook"), "PUT", "bulkapi.zerobounce.net", "/api/ipt/webhook"], - ["sendEvaluatedFile", () => sdk.sendEvaluatedFile({ file }), "POST", "bulkapi.zerobounce.net", "/v2/send-evaluated-file/"], + ["sendEvaluatedFile", () => sdk.sendEvaluatedFile("eval-file-1"), "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"], @@ -718,7 +726,9 @@ describe("offline API", () => { await sdk.createInboxPlacementTest({ name: "basic" }); let request = fake.only(); expect(request.json).toEqual({ name: "basic" }); - expect(request.query.api_key).toBe("parity-key"); + // The Inbox Placement Tester documents Authorization: Bearer, not a query key. + expect(request.authorization).toBe("Bearer parity-key"); + expect(request.query.api_key).toBeUndefined(); fake.records.length = 0; await sdk.createInboxPlacementTest({ @@ -750,7 +760,8 @@ describe("offline API", () => { it("sends only the inbox placement list filters that are set", async () => { const sdk = client("parity-key"); await sdk.listInboxPlacementTests(); - expect(Object.keys(fake.only().query)).toEqual(["api_key"]); + // The key travels in the Bearer header, so an unfiltered list sends no query. + expect(Object.keys(fake.only().query)).toEqual([]); fake.records.length = 0; await sdk.listInboxPlacementTests({ diff --git a/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index 4addf90..bfcf388 100644 --- a/tests/zero-bounce.test.js +++ b/tests/zero-bounce.test.js @@ -552,7 +552,9 @@ describe("ZeroBounceSDK", () => { }); const init = fetch.mock.calls[0][1]; const body = JSON.parse(init.body); - expect(body.timeout).toBe(60); + // LIVE: the API requires timeout as a JSON string; a number makes it + // reject the whole batch with the account-level credits error. + expect(body.timeout).toBe("60"); expect(body.activity_data).toBe(true); expect(body.verify_plus).toBe(true); }); @@ -649,8 +651,8 @@ describe("ZeroBounceSDK", () => { }) ); zeroBounceSDK.init("invalid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - const response = await zeroBounceSDK.sendFile(payload); - expect(response).toEqual(expected); + // {"success": false} on an HTTP 200 is a failure, not a result. + await expect(zeroBounceSDK.sendFile(payload)).rejects.toThrow("api_key is invalid"); }); it("should return the data regarding sent file", async () => { @@ -733,8 +735,8 @@ describe("ZeroBounceSDK", () => { }) ); zeroBounceSDK.init("invalid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); - const response = await zeroBounceSDK.sendScoringFile(payload); - expect(response).toEqual(expected); + // {"success": false} on an HTTP 200 is a failure, not a result. + await expect(zeroBounceSDK.sendScoringFile(payload)).rejects.toThrow("api_key is invalid"); }); it("should return the data regarding sent scoring file", async () => { From 4de85b1dfdc836ea31058830ce43cdd3daa51117 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:07:13 +0400 Subject: [PATCH 11/14] feat: ZeroBounceWarmup, a client for the Warmup API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A separate product from email validation, so a separate client rather than methods on ZeroBounceSDK: its own team-scoped key, its own host (https://warmup-api.zerobounce.net, overridable), an API-Key header, and JSON bodies. One method per documented endpoint, 26 in all: - sending accounts: list, get, add (self_managed needs engagement_rule_id 0-4; zb_managed takes email_content and managed_sending), update, delete, changeFromAddress (PUT /smtp-credentials, which stores no credentials), and listFromAddresses; - engagement: rules catalogue, set a standard rule, and get / create / update / delete a custom one; - statistics, seeds and utilities: seeds, seed count, today's progress and sends, emails per day, four statistics endpoints, DMARC check and the SMTP configuration test. The documented ranges are checked before sending: engagement_rule_id 0-4, status_id 0-8, reply_pct 5-45, important_pct 5-40, click_pct 0-70 and at most important_pct x 1.75, timezone_offset -11 to 14, port 1-65535, search_from's character set, and start_date <= end_date. LIVE, verified 2026-10-24: - checkDomainDmarc takes an EMAIL ADDRESS. A bare domain is rejected with "Please provide a valid email address", although the docs describe a domain, so a value without "@" is refused before sending. - testSmtpConfiguration answers any failed SMTP login with HTTP 500. That is an API bug; it is documented on the method. 204 and empty 200 return null. The two downloads return CSV text. Errors carry the HTTP status, read message / error / error_message / detail (including a [{loc, msg}] validation list as "field: msg") and a top-level list of strings, and mask the API key and the SMTP username and password — in network failures too, which are raised on their own so the original is never the cause. The webpack bundle ships only the default export, so Warmup is also hung off ZeroBounceSDK.Warmup to stay reachable from the UMD build. Tests: 143 pass (14 new), plus the .d.ts and TypeScript consumer checks. Note: `npm run dts-check` fails on this machine for a reason unrelated to this change — TypeScript 7.0.2's @typescript/typescript-darwin-arm64 binary is not installed. Installing that package makes it pass. Co-Authored-By: Claude Opus 5 --- src/index.js | 10 + src/warmup.js | 506 +++++++++++++++++++++++++++++++++++ tests/warmup-offline.test.js | 337 +++++++++++++++++++++++ zeroBounceSDK.d.ts | 115 ++++++++ 4 files changed, 968 insertions(+) create mode 100644 src/warmup.js create mode 100644 tests/warmup-offline.test.js diff --git a/src/index.js b/src/index.js index 8cf47e9..910f6da 100644 --- a/src/index.js +++ b/src/index.js @@ -1,2 +1,12 @@ import { ZeroBounceSDK } from "./zero-bounce.js"; +import { ZeroBounceWarmup } from "./warmup.js"; + +// The Warmup API is a separate product with its own key and host, so it is a +// separate client rather than methods on ZeroBounceSDK. +// +// The webpack bundle ships only the default export (libraryExport: "default"), +// so Warmup is also hung off it to stay reachable from the UMD build. +ZeroBounceSDK.Warmup = ZeroBounceWarmup; + +export { ZeroBounceSDK, ZeroBounceWarmup }; export default ZeroBounceSDK; diff --git a/src/warmup.js b/src/warmup.js new file mode 100644 index 0000000..052eeec --- /dev/null +++ b/src/warmup.js @@ -0,0 +1,506 @@ +/** + * Client for the ZeroBounce Warmup API (https://www.zerobounce.net/docs/warmup-api). + * + * The Warmup API is separate from the email validation API: its own host, its + * own team-scoped key, and an `API-Key` header instead of an `api_key` + * parameter. Use `ZeroBounceWarmup` with a Warmup API key; `ZeroBounceSDK` + * stays for the validation API key. + */ +import { + apiError, + checkApiKey, + pathSegment, + redactSecrets, + requireHttpsUrl, +} from "./utils.js"; + +const DEFAULT_BASE_URL = "https://warmup-api.zerobounce.net"; +const BASE_PATH = "/api/v1/warmup"; +const HTTP_TIMEOUT_MS = 120000; + +const ONBOARDING_MODELS = ["self_managed", "zb_managed"]; +const EMAIL_TYPES = ["transactional", "newsletter", "marketing"]; +/** 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. */ +const STANDARD_RULE_IDS = [0, 4]; +/** 4 Paused, 5 Disabled. */ +const STATUS_IDS = [0, 8]; +/** Required by the schema, always replaced with the key's own team. */ +const ANY_TEAM_ID = 1; + +const SEARCH_FROM = /^[A-Za-z0-9.\-_@+]*$/; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +function clientError(message) { + return new Error(`ZeroBounce: ${message}`); +} + +function required(value, name) { + if (value == null || !String(value).trim()) { + throw clientError(`${name} parameter is missing.`); + } + return value; +} + +function inRange(value, [low, high], name) { + if (typeof value !== "number" || !Number.isInteger(value) || value < low || value > high) { + throw clientError(`${name} parameter is invalid. Must be ${low} to ${high}.`); + } + return value; +} + +function isoDate(value, name) { + if (value instanceof Date) return value.toISOString().slice(0, 10); + if (typeof value !== "string" || !ISO_DATE.test(value)) { + throw clientError(`${name} parameter is invalid. Expected YYYY-MM-DD.`); + } + return value; +} + +function fetchTimeoutSignal() { + if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") { + return AbortSignal.timeout(HTTP_TIMEOUT_MS); + } + return undefined; +} + +export class ZeroBounceWarmup { + static DEFAULT_BASE_URL = DEFAULT_BASE_URL; + + /** + * @param apiKey - your Warmup API key, sent in the `API-Key` header + * @param baseURL - the base URL ZeroBounce gave you; defaults to + * https://warmup-api.zerobounce.net + */ + constructor(apiKey, baseURL = DEFAULT_BASE_URL) { + checkApiKey(apiKey, "Warmup api key"); + const base = requireHttpsUrl(baseURL || DEFAULT_BASE_URL, "baseURL"); + this._api_key = apiKey; + this._root = `${base.replace(/\/+$/, "")}${BASE_PATH}`; + } + + // -- transport -------------------------------------------------------------- + + async _send(method, path, { params = null, jsonBody = null, secrets = [] } = {}) { + const query = + params && Object.keys(params).length ? `?${new URLSearchParams(params)}` : ""; + const url = `${this._root}${path}${query}`; + requireHttpsUrl(url, "request URL"); + const all = [this._api_key, ...secrets]; + + const headers = { + "API-Key": this._api_key, + Accept: "application/json", + }; + if (jsonBody != null) headers["Content-Type"] = "application/json"; + + let response; + try { + response = await fetch(url, { + method, + headers, + body: jsonBody != null ? JSON.stringify(jsonBody) : undefined, + signal: fetchTimeoutSignal(), + redirect: "error", + }); + } catch (error) { + // Raised on its own: the original can carry the key or the SMTP password. + const name = error && error.name ? error.name : "Error"; + const message = error && error.message ? error.message : String(error); + throw new Error(redactSecrets(`${name}: ${message}`, all)); + } + + const status = typeof response.status === "number" ? response.status : 200; + if (status >= 300 && status < 400) { + throw apiError( + "ZeroBounce returned a redirect, which this client does not follow.", + status + ); + } + const body = typeof response.text === "function" ? await response.text() : ""; + if (status > 299) { + throw apiError(redactSecrets(warmupMessage(body, status), all), status); + } + return { status, body, response }; + } + + /** The JSON payload of a 200, or null for a documented empty success (204, empty 200). */ + async _json(method, path, options = {}) { + const { status, body } = await this._send(method, path, options); + if (status === 204 || !String(body).trim()) return null; + try { + return JSON.parse(body); + } catch { + throw apiError( + redactSecrets(warmupMessage(body, status), [this._api_key, ...(options.secrets || [])]), + status + ); + } + } + + /** A CSV download, returned as text. */ + async _csv(path, params = null) { + const { status, body, response } = await this._send("GET", path, { params }); + const contentType = + response && response.headers && typeof response.headers.get === "function" + ? String(response.headers.get("content-type") || "").toLowerCase() + : ""; + if (!String(body).trim() || contentType.includes("application/json")) { + throw apiError(redactSecrets(warmupMessage(body, status), [this._api_key]), status); + } + return body; + } + + // -- sending accounts ------------------------------------------------------- + + /** Every sending account of the key's team. GET /sending-accounts */ + listSendingAccounts() { + return this._json("GET", "/sending-accounts"); + } + + /** One sending account. GET /sending-account */ + getSendingAccount(fromAddress) { + return this._json("GET", "/sending-account", { + params: { from_address: required(fromAddress, "from_address") }, + }); + } + + /** + * Add a from-address to warmup. POST /sending-accounts + * + * `self_managed` runs your own mailbox and needs `engagementRuleId` 0-4. + * `zb_managed` has ZeroBounce provision it and takes `emailContent` and + * `managedSending` instead; a 200 means accepted, and the mailbox is + * provisioned afterwards. + */ + addSendingAccount({ + fromAddress, + onboardingModel, + engagementRuleId = null, + ignoreConflict = null, + emailContent = null, + managedSending = null, + } = {}) { + required(fromAddress, "from_address"); + if (!ONBOARDING_MODELS.includes(onboardingModel)) { + throw clientError("onboarding_model parameter is invalid. Must be self_managed or zb_managed."); + } + const body = { from_address: fromAddress, onboarding_model: onboardingModel }; + if (onboardingModel === "self_managed") { + if (engagementRuleId == null) { + throw clientError("engagement_rule_id parameter is missing. Required for self_managed."); + } + body.engagement_rule_id = inRange(engagementRuleId, STANDARD_RULE_IDS, "engagement_rule_id"); + if (emailContent != null || managedSending != null) { + throw clientError("email_content and managed_sending are for zb_managed."); + } + } + if (ignoreConflict != null) body.ignore_conflict = Boolean(ignoreConflict); + if (emailContent != null) { + const content = { ...emailContent }; + const types = content.types_of_emails; + if (types != null && (!Array.isArray(types) || types.some((t) => !EMAIL_TYPES.includes(t)))) { + throw clientError( + "types_of_emails parameter is invalid. transactional, newsletter and/or marketing." + ); + } + body.email_content = content; + } + if (managedSending != null) { + const sending = { ...managedSending }; + const offset = sending.timezone_offset; + if (offset != null && (typeof offset !== "number" || offset < -11 || offset > 14)) { + throw clientError("timezone_offset parameter is invalid. Must be -11 to 14."); + } + if (sending.warmup_start_date != null) { + sending.warmup_start_date = isoDate(sending.warmup_start_date, "warmup_start_date"); + } + body.managed_sending = sending; + } + return this._json("POST", "/sending-accounts", { jsonBody: body }); + } + + /** + * Change an account's engagement rule, status and auto-enable flag. + * PUT /sending-accounts + * + * `statusId` is 0 to 8 (4 Paused, 5 Disabled). `autoEnable` true is accepted + * only for an account that is not active. + */ + updateSendingAccount({ fromAddress, engagementRuleId, statusId, autoEnable } = {}) { + required(fromAddress, "from_address"); + if (typeof autoEnable !== "boolean") { + throw clientError("auto_enable parameter is missing. Must be true or false."); + } + return this._json("PUT", "/sending-accounts", { + jsonBody: { + team_id: ANY_TEAM_ID, + from_address: fromAddress, + engagement_rule_id: inRange(engagementRuleId, STANDARD_RULE_IDS, "engagement_rule_id"), + status_id: inRange(statusId, STATUS_IDS, "status_id"), + auto_enable: autoEnable, + }, + }); + } + + /** Remove a sending account. DELETE /sending-accounts. Destructive. */ + deleteSendingAccount(fromAddress) { + return this._json("DELETE", "/sending-accounts", { + params: { from_address: required(fromAddress, "from_address") }, + }); + } + + /** + * Move an account and its history to a new from-address. + * PUT /smtp-credentials — despite the path, no SMTP credentials are stored. + */ + changeFromAddress(oldFromAddress, newFromAddress) { + return this._json("PUT", "/smtp-credentials", { + jsonBody: { + team_id: ANY_TEAM_ID, + old_from_address: required(oldFromAddress, "old_from_address"), + new_from_address: required(newFromAddress, "new_from_address"), + }, + }); + } + + /** + * Registered from-addresses. GET /from-addresses + * + * `searchFrom` takes letters, digits and `. - _ @ +` only. `offset` and + * `limit` go on the wire as strings and default to 0 and 100 on the API. + */ + listFromAddresses({ searchFrom = null, offset = null, limit = null } = {}) { + const params = {}; + if (searchFrom != null) { + if (!SEARCH_FROM.test(searchFrom)) { + throw clientError("search_from parameter is invalid. Letters, digits and . - _ @ + only."); + } + params.search_from = searchFrom; + } + for (const [name, value] of [["offset", offset], ["limit", limit]]) { + if (value != null) { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + throw clientError(`${name} parameter is invalid. Must be a non-negative integer.`); + } + params[name] = String(value); + } + } + return this._json("GET", "/from-addresses", { params }); + } + + // -- engagement rules ------------------------------------------------------- + + /** The rule catalogue: -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. */ + getEngagementRules() { + return this._json("GET", "/engagement-rules"); + } + + /** Set a standard rule (0 to 4) on an address. PUT /engagement-rule */ + setEngagementRule(cFrom, ruleId) { + required(cFrom, "c_from"); + return this._json("PUT", "/engagement-rule", { + params: { c_from: cFrom }, + jsonBody: { rule_id: inRange(ruleId, STANDARD_RULE_IDS, "rule_id") }, + }); + } + + /** An address's custom rule, with can_update and remaining_seconds. */ + getCustomEngagement(cFrom) { + return this._json("GET", `/custom-engagement/${pathSegment(cFrom, "c_from")}`); + } + + /** Create a custom rule. POST /custom-engagement. Starts a 7 day update cooldown. */ + createCustomEngagement({ cFrom, replyPct, importantPct, clickPct } = {}) { + return this._json("POST", "/custom-engagement", { + jsonBody: engagementBody(cFrom, replyPct, importantPct, clickPct), + }); + } + + /** + * Update a custom rule. PUT /custom-engagement. + * During the 7 day cooldown this raises with statusCode 429. + */ + updateCustomEngagement({ cFrom, replyPct, importantPct, clickPct } = {}) { + return this._json("PUT", "/custom-engagement", { + jsonBody: engagementBody(cFrom, replyPct, importantPct, clickPct), + }); + } + + /** Delete a custom rule and fall back to standard rule 1-4. DELETE /custom-engagement */ + deleteCustomEngagement(cFrom, engagementRuleId) { + return this._json("DELETE", "/custom-engagement", { + jsonBody: { + c_from: required(cFrom, "c_from"), + engagement_rule_id: inRange(engagementRuleId, [1, 4], "engagement_rule_id"), + }, + }); + } + + // -- statistics, seeds and utilities ---------------------------------------- + + /** The seed list. GET /seeds */ + getSeeds() { + return this._json("GET", "/seeds"); + } + + /** The seed list as CSV (columns seed,provider). GET /seeds/download */ + downloadSeeds() { + return this._csv("/seeds/download"); + } + + /** Number of seeds. GET /seed-count */ + getSeedCount() { + return this._json("GET", "/seed-count"); + } + + /** Today's warmup progress. GET /progress-today */ + getProgressToday() { + return this._json("GET", "/progress-today"); + } + + /** Messages sent today. GET /sent-today */ + getSentToday() { + return this._json("GET", "/sent-today"); + } + + /** Current daily volume. GET /emails-per-day */ + getEmailsPerDay() { + return this._json("GET", "/emails-per-day"); + } + + /** Statistics for an address. An address with no history returns zeros. */ + getStatistics(fromAddress) { + return this._json("GET", "/statistics", { + params: { from_address: required(fromAddress, "from_address") }, + }); + } + + /** Daily statistics between two dates, inclusive. GET /statistics-per-day */ + getStatisticsPerDay({ fromAddress, startDate, endDate } = {}) { + required(fromAddress, "from_address"); + const start = isoDate(startDate, "start_date"); + const end = isoDate(endDate, "end_date"); + if (start > end) { + throw clientError("start_date parameter is invalid. It is later than end_date."); + } + return this._json("GET", "/statistics-per-day", { + params: { from_address: fromAddress, start_date: start, end_date: end }, + }); + } + + /** Detailed statistics for an address. GET /statistics-detailed */ + getStatisticsDetailed(cFrom) { + return this._json("GET", "/statistics-detailed", { + params: { c_from: required(cFrom, "c_from") }, + }); + } + + /** An address's statistics as CSV. GET /statistics/download */ + downloadStatistics(cFrom) { + return this._csv("/statistics/download", { c_from: required(cFrom, "c_from") }); + } + + /** Statistics for one from-address. GET /from-address-statistics */ + getFromAddressStatistics(fromAddress) { + return this._json("GET", "/from-address-statistics", { + params: { from_address: required(fromAddress, "from_address") }, + }); + } + + /** + * SPF, DKIM and DMARC status of a from-address's domain. GET /check-domain-dmarc + * + * LIVE: pass an email address. The live API answers "Please provide a valid + * email address" for a bare domain, although the docs describe a domain. + */ + checkDomainDmarc(fromAddress, dkimSelector = null) { + required(fromAddress, "from_address"); + if (!String(fromAddress).includes("@")) { + throw clientError( + "from_address parameter is invalid. Pass an email address, e.g. sender@example.com." + ); + } + const params = { from_address: fromAddress }; + if (dkimSelector != null) params.dkim_selector = required(dkimSelector, "dkim_selector"); + return this._json("GET", "/check-domain-dmarc", { params }); + } + + /** + * Check that SMTP credentials connect and hand off a message. + * POST /test-smtp-configuration + * + * Nothing is stored, and the test message goes to a ZeroBounce test + * recipient, not `fromAddress`. + * + * LIVE: the API answers any failed SMTP login with HTTP 500 — an API bug. + * The username and password are masked in every error from this method. + */ + testSmtpConfiguration({ fromAddress, username, password, server, port } = {}) { + if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) { + throw clientError("port parameter is invalid. Must be 1 to 65535."); + } + const body = { + from_address: required(fromAddress, "from_address"), + username: required(username, "username"), + password: required(password, "password"), + server: required(server, "server"), + port, + }; + return this._json("POST", "/test-smtp-configuration", { + jsonBody: body, + secrets: [password, username], + }); + } +} + +function engagementBody(cFrom, replyPct, importantPct, clickPct) { + required(cFrom, "c_from"); + inRange(replyPct, [5, 45], "reply_pct"); + inRange(importantPct, [5, 40], "important_pct"); + inRange(clickPct, [0, 70], "click_pct"); + if (clickPct > importantPct * 1.75) { + throw clientError("click_pct parameter is invalid. At most important_pct x 1.75."); + } + return { c_from: cFrom, reply_pct: replyPct, important_pct: importantPct, click_pct: clickPct }; +} + +/** A Warmup error body as a short message. Mirrors the validation client's rules. */ +function warmupMessage(body, status) { + const trimmed = String(body || "").trim(); + if (!trimmed) return `Empty response from ZeroBounce (HTTP ${status})`; + const head = trimmed.slice(0, 2000).toLowerCase(); + if (head.includes(" typeof item === "string" && item.trim()); + if (first) return first.trim(); + } else if (parsed && typeof parsed === "object") { + for (const key of ["message", "error", "error_message", "detail"]) { + const value = parsed[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + if (Array.isArray(value) && value.length) { + const strings = value.filter((item) => typeof item === "string" && item.trim()); + if (strings.length) return strings.join("; "); + const firstItem = value[0]; + if (firstItem && typeof firstItem === "object" && firstItem.msg) { + const loc = Array.isArray(firstItem.loc) ? firstItem.loc.slice(1).join(".") : ""; + return loc ? `${loc}: ${firstItem.msg}` : String(firstItem.msg); + } + } + } + } + return `ZeroBounce returned an error (HTTP ${status}).`; + } + if (trimmed.length > 1000) return "ZeroBounce returned a response that is not valid JSON."; + return trimmed; +} + +export default ZeroBounceWarmup; diff --git a/tests/warmup-offline.test.js b/tests/warmup-offline.test.js new file mode 100644 index 0000000..04ae1f9 --- /dev/null +++ b/tests/warmup-offline.test.js @@ -0,0 +1,337 @@ +/** + * Offline suite for the Warmup API client. Replaces global fetch; no network + * and no real key. + */ +import { ZeroBounceWarmup } from "../src/warmup.js"; + +const KEY = "warmup-test-key"; + +function httpResponse(status, body, contentType = "application/json") { + return { + status, + ok: status >= 200 && status < 300, + headers: { + get: (name) => + String(name).toLowerCase() === "content-type" ? contentType : null, + }, + text: async () => body, + json: async () => JSON.parse(body), + }; +} + +function installFake({ status = 200, body = '{"ok":true}', contentType = "application/json" } = {}) { + const records = []; + global.fetch = async (url, init = {}) => { + const parsed = new URL(String(url)); + records.push({ + method: String(init.method || "GET").toUpperCase(), + host: parsed.host, + path: parsed.pathname, + url: String(url), + query: Object.fromEntries(parsed.searchParams.entries()), + headers: init.headers || {}, + json: init.body ? JSON.parse(init.body) : null, + redirect: init.redirect, + }); + return httpResponse(status, body, contentType); + }; + return { + records, + only() { + expect(records.length).toBe(1); + return records[0]; + }, + }; +} + +async function rejects(promise) { + let thrown = null; + try { + await promise; + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + return thrown; +} + +describe("Warmup API client", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + it("sends the key in the API-Key header, never in the URL", async () => { + const fake = installFake(); + const client = new ZeroBounceWarmup(KEY); + + await client.listSendingAccounts(); + + const request = fake.only(); + expect(request.host).toBe("warmup-api.zerobounce.net"); + expect(request.path).toBe("/api/v1/warmup/sending-accounts"); + expect(request.headers["API-Key"]).toBe(KEY); + expect(request.url).not.toContain(KEY); + expect(request.query.api_key).toBeUndefined(); + // A redirect must never be followed: it could carry the key elsewhere. + expect(request.redirect).toBe("error"); + }); + + it("covers every documented endpoint with the right method and path", async () => { + const fake = installFake(); + const c = new ZeroBounceWarmup(KEY); + const from = "sender@example.com"; + + const calls = [ + [() => c.listSendingAccounts(), "GET", "/sending-accounts"], + [() => c.getSendingAccount(from), "GET", "/sending-account"], + [() => c.addSendingAccount({ fromAddress: from, onboardingModel: "self_managed", engagementRuleId: 0 }), "POST", "/sending-accounts"], + [() => c.updateSendingAccount({ fromAddress: from, engagementRuleId: 1, statusId: 4, autoEnable: false }), "PUT", "/sending-accounts"], + [() => c.deleteSendingAccount(from), "DELETE", "/sending-accounts"], + [() => c.changeFromAddress(from, "new@example.com"), "PUT", "/smtp-credentials"], + [() => c.listFromAddresses(), "GET", "/from-addresses"], + [() => c.getEngagementRules(), "GET", "/engagement-rules"], + [() => c.setEngagementRule(from, 2), "PUT", "/engagement-rule"], + [() => c.getCustomEngagement(from), "GET", `/custom-engagement/${encodeURIComponent(from)}`], + [() => c.createCustomEngagement({ cFrom: from, replyPct: 10, importantPct: 10, clickPct: 5 }), "POST", "/custom-engagement"], + [() => c.updateCustomEngagement({ cFrom: from, replyPct: 10, importantPct: 10, clickPct: 5 }), "PUT", "/custom-engagement"], + [() => c.deleteCustomEngagement(from, 1), "DELETE", "/custom-engagement"], + [() => c.getSeeds(), "GET", "/seeds"], + [() => c.getSeedCount(), "GET", "/seed-count"], + [() => c.getProgressToday(), "GET", "/progress-today"], + [() => c.getSentToday(), "GET", "/sent-today"], + [() => c.getEmailsPerDay(), "GET", "/emails-per-day"], + [() => c.getStatistics(from), "GET", "/statistics"], + [() => c.getStatisticsPerDay({ fromAddress: from, startDate: "2026-01-01", endDate: "2026-01-31" }), "GET", "/statistics-per-day"], + [() => c.getStatisticsDetailed(from), "GET", "/statistics-detailed"], + [() => c.getFromAddressStatistics(from), "GET", "/from-address-statistics"], + [() => c.checkDomainDmarc(from), "GET", "/check-domain-dmarc"], + [() => c.testSmtpConfiguration({ fromAddress: from, username: "u", password: "p", server: "smtp.example.com", port: 587 }), "POST", "/test-smtp-configuration"], + ]; + + for (const [call, method, path] of calls) { + fake.records.length = 0; + await call(); + const request = fake.only(); + expect(request.method).toBe(method); + expect(request.path).toBe(`/api/v1/warmup${path}`); + expect(request.headers["API-Key"]).toBe(KEY); + } + }); + + it("returns CSV text for the two downloads", async () => { + const fake = installFake({ body: "seed,provider\na@example.com,gmail\n", contentType: "text/csv" }); + const c = new ZeroBounceWarmup(KEY); + + expect(await c.downloadSeeds()).toContain("seed,provider"); + expect(fake.only().path).toBe("/api/v1/warmup/seeds/download"); + + fake.records.length = 0; + expect(await c.downloadStatistics("sender@example.com")).toContain("seed,provider"); + const request = fake.only(); + expect(request.path).toBe("/api/v1/warmup/statistics/download"); + expect(request.query.c_from).toBe("sender@example.com"); + }); + + it("returns null for the documented empty successes", async () => { + installFake({ status: 204, body: "" }); + const c = new ZeroBounceWarmup(KEY); + expect(await c.deleteSendingAccount("sender@example.com")).toBeNull(); + expect(await c.setEngagementRule("sender@example.com", 1)).toBeNull(); + + installFake({ status: 200, body: "" }); + expect(await c.changeFromAddress("a@example.com", "b@example.com")).toBeNull(); + }); + + it("omits optional query parameters until they are set", async () => { + const fake = installFake(); + const c = new ZeroBounceWarmup(KEY); + + await c.listFromAddresses(); + expect(Object.keys(fake.only().query)).toEqual([]); + + fake.records.length = 0; + await c.listFromAddresses({ searchFrom: "sender@example.com", offset: 0, limit: 50 }); + // offset and limit travel as strings. + expect(fake.only().query).toEqual({ + search_from: "sender@example.com", + offset: "0", + limit: "50", + }); + + fake.records.length = 0; + await c.checkDomainDmarc("sender@example.com"); + expect(fake.only().query.dkim_selector).toBeUndefined(); + + fake.records.length = 0; + await c.checkDomainDmarc("sender@example.com", "selector1"); + expect(fake.only().query.dkim_selector).toBe("selector1"); + }); + + it("builds the documented sending-account bodies", async () => { + const fake = installFake(); + const c = new ZeroBounceWarmup(KEY); + + await c.addSendingAccount({ + fromAddress: "sender@example.com", + onboardingModel: "self_managed", + engagementRuleId: 3, + ignoreConflict: true, + }); + expect(fake.only().json).toEqual({ + from_address: "sender@example.com", + onboarding_model: "self_managed", + engagement_rule_id: 3, + ignore_conflict: true, + }); + + fake.records.length = 0; + await c.addSendingAccount({ + fromAddress: "sender@example.com", + onboardingModel: "zb_managed", + emailContent: { types_of_emails: ["newsletter"] }, + managedSending: { timezone_offset: 2, warmup_start_date: "2026-02-01" }, + }); + expect(fake.only().json).toEqual({ + from_address: "sender@example.com", + onboarding_model: "zb_managed", + email_content: { types_of_emails: ["newsletter"] }, + managed_sending: { timezone_offset: 2, warmup_start_date: "2026-02-01" }, + }); + + fake.records.length = 0; + await c.updateSendingAccount({ + fromAddress: "sender@example.com", + engagementRuleId: 1, + statusId: 5, + autoEnable: true, + }); + // team_id is required by the schema and replaced with the key's own team. + expect(fake.only().json).toEqual({ + team_id: 1, + from_address: "sender@example.com", + engagement_rule_id: 1, + status_id: 5, + auto_enable: true, + }); + }); + + it("checks arguments before sending anything", async () => { + const fake = installFake(); + const c = new ZeroBounceWarmup(KEY); + + const bad = [ + () => c.addSendingAccount({ fromAddress: "a@example.com", onboardingModel: "nope" }), + () => c.addSendingAccount({ fromAddress: "a@example.com", onboardingModel: "self_managed" }), + () => c.addSendingAccount({ fromAddress: "a@example.com", onboardingModel: "self_managed", engagementRuleId: 9 }), + () => c.updateSendingAccount({ fromAddress: "a@example.com", engagementRuleId: 1, statusId: 1 }), + () => c.setEngagementRule("a@example.com", 7), + () => c.createCustomEngagement({ cFrom: "a@example.com", replyPct: 1, importantPct: 10, clickPct: 5 }), + // click_pct may be at most important_pct x 1.75. + () => c.createCustomEngagement({ cFrom: "a@example.com", replyPct: 10, importantPct: 10, clickPct: 60 }), + () => c.deleteCustomEngagement("a@example.com", 0), + () => c.listFromAddresses({ searchFrom: "bad space" }), + () => c.getStatisticsPerDay({ fromAddress: "a@example.com", startDate: "2026-02-01", endDate: "2026-01-01" }), + () => c.getStatisticsPerDay({ fromAddress: "a@example.com", startDate: "01/02/2026", endDate: "2026-01-01" }), + // LIVE: check-domain-dmarc needs an email address, not a bare domain. + () => c.checkDomainDmarc("example.com"), + () => c.testSmtpConfiguration({ fromAddress: "a@example.com", username: "u", password: "p", server: "s", port: 0 }), + () => c.getCustomEngagement(".."), + ]; + + for (const call of bad) { + expect(call).toThrow(); + } + expect(fake.records.length).toBe(0); + }); + + it("rejects a bad key or a non-https base URL before sending", () => { + expect(() => new ZeroBounceWarmup("")).toThrow(); + // A key read from a file with its newline would be quoted, unredacted, by fetch. + expect(() => new ZeroBounceWarmup("key-with-newline\n")).toThrow(/whitespace or control/); + expect(() => new ZeroBounceWarmup(KEY, "http://warmup-api.zerobounce.net")).toThrow(/https/); + }); + + it("reports the HTTP status and a short message on failure", async () => { + installFake({ status: 429, body: JSON.stringify({ detail: "Too many requests" }) }); + const c = new ZeroBounceWarmup(KEY); + + const error = await rejects(c.updateCustomEngagement({ cFrom: "a@example.com", replyPct: 10, importantPct: 10, clickPct: 5 })); + expect(error.statusCode).toBe(429); + expect(error.message).toBe("Too many requests"); + }); + + it("reads a validation error list as field: message", async () => { + installFake({ + status: 422, + body: JSON.stringify({ detail: [{ loc: ["body", "from_address"], msg: "field required" }] }), + }); + const c = new ZeroBounceWarmup(KEY); + + const error = await rejects(c.getSeeds()); + expect(error.message).toBe("from_address: field required"); + expect(error.statusCode).toBe(422); + }); + + it("keeps an HTML page and a huge body out of the message", async () => { + installFake({ + status: 403, + body: `Access denied${"x".repeat(50000)}`, + contentType: "text/html", + }); + const c = new ZeroBounceWarmup(KEY); + + const error = await rejects(c.getSeeds()); + expect(error.message.length).toBeLessThan(200); + expect(error.message).not.toContain(" { + const password = "super-secret-password"; + const username = "smtp-user"; + installFake({ + status: 500, + body: JSON.stringify({ + detail: `SMTP login failed for ${username} with ${password} and key ${KEY}`, + }), + }); + const c = new ZeroBounceWarmup(KEY); + + const error = await rejects( + c.testSmtpConfiguration({ + fromAddress: "a@example.com", + username, + password, + server: "smtp.example.com", + port: 587, + }) + ); + // LIVE: a failed SMTP login comes back as HTTP 500, an API bug. + expect(error.statusCode).toBe(500); + expect(error.message).not.toContain(password); + expect(error.message).not.toContain(username); + expect(error.message).not.toContain(KEY); + expect(error.message).toContain("***"); + }); + + it("keeps the key out of a network failure and its cause chain", async () => { + global.fetch = async () => { + throw new Error(`connect ECONNREFUSED https://warmup-api.zerobounce.net?API-Key=${KEY}`); + }; + const c = new ZeroBounceWarmup(KEY); + + const error = await rejects(c.getSeeds()); + expect(error.message).not.toContain(KEY); + // The original, unredacted error must not survive as the cause. + expect(error.cause).toBeUndefined(); + }); + + it("does not follow a redirect", async () => { + installFake({ status: 302, body: "" }); + const c = new ZeroBounceWarmup(KEY); + + const error = await rejects(c.getSeeds()); + expect(error.statusCode).toBe(302); + expect(error.message).toContain("redirect"); + }); +}); diff --git a/zeroBounceSDK.d.ts b/zeroBounceSDK.d.ts index b16a52a..acd6583 100644 --- a/zeroBounceSDK.d.ts +++ b/zeroBounceSDK.d.ts @@ -914,4 +914,119 @@ export class ZeroBounceSDK { }): Promise; } +/** An error from the API, carrying the HTTP status of the failed response. */ +export class ZBApiError extends Error { + statusCode: number; +} + +/** `self_managed` runs your own mailbox; `zb_managed` has ZeroBounce provision one. */ +export type WarmupOnboardingModel = "self_managed" | "zb_managed"; + +export type WarmupEmailType = "transactional" | "newsletter" | "marketing"; + +export interface WarmupEmailContent { + company_name?: string; + company_website?: string; + company_description?: string; + types_of_emails?: WarmupEmailType[]; + example_email?: string; +} + +export interface WarmupManagedSending { + weekdays_only?: boolean; + timezone?: string; + /** -11 to 14. */ + timezone_offset?: number; + /** YYYY-MM-DD, or a Date. */ + warmup_start_date?: string | Date; +} + +/** + * The ZeroBounce Warmup API (https://www.zerobounce.net/docs/warmup-api). + * + * A separate product from email validation: its own team-scoped key, its own + * host, and an `API-Key` header. One instance per Warmup key. + */ +export class ZeroBounceWarmup { + static DEFAULT_BASE_URL: string; + + constructor(apiKey: string, baseURL?: string); + + listSendingAccounts(): Promise; + getSendingAccount(fromAddress: string): Promise; + addSendingAccount(options: { + fromAddress: string; + onboardingModel: WarmupOnboardingModel; + /** 0 to 4. Required for self_managed. */ + engagementRuleId?: number | null; + ignoreConflict?: boolean | null; + emailContent?: WarmupEmailContent | null; + managedSending?: WarmupManagedSending | null; + }): Promise; + updateSendingAccount(options: { + fromAddress: string; + /** 0 to 4. */ + engagementRuleId: number; + /** 0 to 8 (4 Paused, 5 Disabled). */ + statusId: number; + autoEnable: boolean; + }): Promise; + deleteSendingAccount(fromAddress: string): Promise; + changeFromAddress(oldFromAddress: string, newFromAddress: string): Promise; + listFromAddresses(options?: { + /** Letters, digits and `. - _ @ +` only. */ + searchFrom?: string | null; + offset?: number | null; + limit?: number | null; + }): Promise; + + getEngagementRules(): Promise; + setEngagementRule(cFrom: string, ruleId: number): Promise; + getCustomEngagement(cFrom: string): Promise; + createCustomEngagement(options: { + cFrom: string; + /** 5 to 45. */ + replyPct: number; + /** 5 to 40. */ + importantPct: number; + /** 0 to 70, and at most importantPct x 1.75. */ + clickPct: number; + }): Promise; + updateCustomEngagement(options: { + cFrom: string; + replyPct: number; + importantPct: number; + clickPct: number; + }): Promise; + deleteCustomEngagement(cFrom: string, engagementRuleId: number): Promise; + + getSeeds(): Promise; + /** CSV text (columns `seed,provider`). */ + downloadSeeds(): Promise; + getSeedCount(): Promise; + getProgressToday(): Promise; + getSentToday(): Promise; + getEmailsPerDay(): Promise; + getStatistics(fromAddress: string): Promise; + getStatisticsPerDay(options: { + fromAddress: string; + startDate: string | Date; + endDate: string | Date; + }): Promise; + getStatisticsDetailed(cFrom: string): Promise; + /** CSV text. */ + downloadStatistics(cFrom: string): Promise; + getFromAddressStatistics(fromAddress: string): Promise; + /** Pass an email address: the live API rejects a bare domain. */ + checkDomainDmarc(fromAddress: string, dkimSelector?: string | null): Promise; + /** The live API answers a failed SMTP login with HTTP 500. */ + testSmtpConfiguration(options: { + fromAddress: string; + username: string; + password: string; + server: string; + port: number; + }): Promise; +} + export default ZeroBounceSDK; From 491bdb894bd8e14fe57307b3aaad4534cc13f6fb Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:09:05 +0400 Subject: [PATCH 12/14] fix: require what the live API requires on bulk jobs and filters Verified against the live API on 2026-10-24. - The six S3 / SFTP file jobs require return_url, which the docs mark optional: without it the API answers "Provided return URL is not permitted: None". file_name is required for every job including validateSFTPfile, which the docs also mark optional ("file_name: This field is required"). Both are checked before sending, so no request goes out that the API would reject. remove_duplicates stays the only optional field of the four. - Filter rule and target are closed sets, checked before sending: rule is allow or block, target is email, domain, mx or tld. The previous fixtures used "exclude"/"include", which are not documented values. Tests: 143 pass. Co-Authored-By: Claude Opus 5 --- src/zero-bounce.js | 47 +++++++++++++++++++++++++++++++-------- tests/offline-api.test.js | 47 +++++++++++++++++++++++++-------------- 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/src/zero-bounce.js b/src/zero-bounce.js index d4678de..02b8fcf 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -78,6 +78,10 @@ function optionalColumn(value) { return value ? value : null; } +/** Documented filter rules and targets, checked before a request is sent. */ +const FILTER_RULES = ["allow", "block"]; +const FILTER_TARGETS = ["email", "domain", "mx", "tld"]; + export class ZeroBounceSDK { static ApiURL = Object.freeze({ DEFAULT_API_URL: "https://api.zerobounce.net/v2", @@ -1016,9 +1020,15 @@ export class ZeroBounceSDK { } else if (isMissing(rule)) { parameterIsMissing("rule"); return; + } else if (!FILTER_RULES.includes(rule)) { + parameterIsInvalid("rule", `Must be one of ${FILTER_RULES.join(", ")}.`); + return; } else if (isMissing(target)) { parameterIsMissing("target"); return; + } else if (!FILTER_TARGETS.includes(target)) { + parameterIsInvalid("target", `Must be one of ${FILTER_TARGETS.join(", ")}.`); + return; } else if (isMissing(value)) { parameterIsMissing("value"); return; @@ -1070,9 +1080,15 @@ export class ZeroBounceSDK { } else if (isMissing(rule)) { parameterIsMissing("rule"); return; + } else if (!FILTER_RULES.includes(rule)) { + parameterIsInvalid("rule", `Must be one of ${FILTER_RULES.join(", ")}.`); + return; } else if (isMissing(target)) { parameterIsMissing("target"); return; + } else if (!FILTER_TARGETS.includes(target)) { + parameterIsInvalid("target", `Must be one of ${FILTER_TARGETS.join(", ")}.`); + return; } else if (isMissing(value)) { parameterIsMissing("value"); return; @@ -1193,16 +1209,23 @@ export class ZeroBounceSDK { * * @param fileNameRequired - false for validateSFTPfile, whose file_name is optional * */ - async _bulkRootFileJob(path, { file_name, has_header_row, remove_duplicates, return_url }, fileNameRequired = true) { + /** + * One of the six documented S3 / SFTP file jobs on the bulkapi root. + * + * LIVE: `return_url` is required, although the docs mark it optional — + * without it the API answers "Provided return URL is not permitted: None". + * `file_name` is required for every job including validateSFTPfile, which + * the docs also mark optional ("file_name: This field is required"). + * All three are checked here so no request goes out without them. + * + * The documented success is {success, message} with no file ID. + */ + async _bulkRootFileJob(path, { file_name, has_header_row, remove_duplicates, return_url }) { if (!this._initialized) { notInitialized(); return; } - if (fileNameRequired && isMissing(file_name)) { - parameterIsMissing("file_name"); - return; - } - if (file_name != null && String(file_name).trim() === "") { + if (isMissing(file_name) || String(file_name).trim() === "") { parameterIsMissing("file_name"); return; } @@ -1210,11 +1233,17 @@ export class ZeroBounceSDK { parameterIsMissing("has_header_row"); return; } + if (isMissing(return_url) || String(return_url).trim() === "") { + parameterIsMissing("return_url", "The API rejects a job without it."); + return; + } - const fields = { has_header_row: booleanText(has_header_row) }; - if (file_name != null) fields.file_name = file_name; + const fields = { + file_name, + has_header_row: booleanText(has_header_row), + return_url, + }; if (remove_duplicates != null) fields.remove_duplicates = booleanText(remove_duplicates); - if (return_url != null) fields.return_url = return_url; return this._sendBulkRootFormPost(path, fields); } diff --git a/tests/offline-api.test.js b/tests/offline-api.test.js index b898235..34e8d54 100644 --- a/tests/offline-api.test.js +++ b/tests/offline-api.test.js @@ -326,15 +326,15 @@ describe("offline API", () => { ["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/"], ["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"], + ["validateS3file", () => sdk.validateS3file({ file_name: "f.csv", has_header_row: true, return_url: "https://example.com/done" }), "POST", "bulkapi.zerobounce.net", "/validateS3file"], + ["scoreS3file", () => sdk.scoreS3file({ file_name: "f.csv", has_header_row: true, return_url: "https://example.com/done" }), "POST", "bulkapi.zerobounce.net", "/scoreS3file"], ["exportS3file", () => sdk.exportS3file({ file_id: id, export_bucket: "bucket" }), "POST", "bulkapi.zerobounce.net", "/exportS3file"], - ["emailFinderS3file", () => sdk.emailFinderS3file({ file_name: "f.csv", has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/emailFinderS3file"], - ["domainSearchS3file", () => sdk.domainSearchS3file({ file_name: "f.csv", has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/domainSearchS3file"], - ["validateSFTPfile", () => sdk.validateSFTPfile({ has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/validateSFTPfile"], - ["scoreSFTPfile", () => sdk.scoreSFTPfile({ file_name: "f.csv", has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/scoreSFTPfile"], - ["emailFinderSFTPfile", () => sdk.emailFinderSFTPfile({ file_name: "f.csv", has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/emailFinderSFTPfile"], - ["domainSearchSFTPfile", () => sdk.domainSearchSFTPfile({ file_name: "f.csv", has_header_row: true }), "POST", "bulkapi.zerobounce.net", "/domainSearchSFTPfile"], + ["emailFinderS3file", () => sdk.emailFinderS3file({ file_name: "f.csv", has_header_row: true, return_url: "https://example.com/done" }), "POST", "bulkapi.zerobounce.net", "/emailFinderS3file"], + ["domainSearchS3file", () => sdk.domainSearchS3file({ file_name: "f.csv", has_header_row: true, return_url: "https://example.com/done" }), "POST", "bulkapi.zerobounce.net", "/domainSearchS3file"], + ["validateSFTPfile", () => sdk.validateSFTPfile({ file_name: "f.csv", has_header_row: true, return_url: "https://example.com/done" }), "POST", "bulkapi.zerobounce.net", "/validateSFTPfile"], + ["scoreSFTPfile", () => sdk.scoreSFTPfile({ file_name: "f.csv", has_header_row: true, return_url: "https://example.com/done" }), "POST", "bulkapi.zerobounce.net", "/scoreSFTPfile"], + ["emailFinderSFTPfile", () => sdk.emailFinderSFTPfile({ file_name: "f.csv", has_header_row: true, return_url: "https://example.com/done" }), "POST", "bulkapi.zerobounce.net", "/emailFinderSFTPfile"], + ["domainSearchSFTPfile", () => sdk.domainSearchSFTPfile({ file_name: "f.csv", has_header_row: true, return_url: "https://example.com/done" }), "POST", "bulkapi.zerobounce.net", "/domainSearchSFTPfile"], ["exportSFTPfile", () => sdk.exportSFTPfile({ file_id: "fid" }), "POST", "bulkapi.zerobounce.net", "/exportSFTPfile"], ["getInboxPlacementProviders", () => sdk.getInboxPlacementProviders(), "GET", "bulkapi.zerobounce.net", "/api/ipt/providers"], ["getInboxPlacementTestTypes", () => sdk.getInboxPlacementTestTypes(), "GET", "bulkapi.zerobounce.net", "/api/ipt/test-types"], @@ -345,9 +345,9 @@ describe("offline API", () => { ["getInboxPlacementWebhook", () => sdk.getInboxPlacementWebhook(), "GET", "bulkapi.zerobounce.net", "/api/ipt/webhook"], ["setInboxPlacementWebhook", () => sdk.setInboxPlacementWebhook("https://example.com/hook"), "PUT", "bulkapi.zerobounce.net", "/api/ipt/webhook"], ["sendEvaluatedFile", () => sdk.sendEvaluatedFile("eval-file-1"), "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"], + ["addFilter", () => sdk.addFilter({ rule: "allow", 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"], + ["deleteFilter", () => sdk.deleteFilter({ rule: "allow", target: "domain", value: "spam.com" }), "POST", "api.zerobounce.net", "/v2/filters/delete"], ["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"], @@ -628,10 +628,10 @@ describe("offline API", () => { 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" }); + await sdk.addFilter({ rule: "allow", target: "email", value: "a@example.com" }); expect(fake.only().body).toEqual({ api_key: "parity-key", - rule: "include", + rule: "allow", target: "email", value: "a@example.com", }); @@ -667,6 +667,13 @@ describe("offline API", () => { await expectRejected(sdk.emailFinderS3file({ has_header_row: true })); await expectRejected(sdk.scoreSFTPfile({ file_name: " ", has_header_row: true })); await expectRejected(sdk.domainSearchSFTPfile({ file_name: "f.csv" })); + // LIVE: return_url is required even though the docs mark it optional. + await expectRejected( + sdk.emailFinderSFTPfile({ file_name: "f.csv", has_header_row: true }) + ); + await expectRejected( + sdk.validateSFTPfile({ has_header_row: true, return_url: "https://example.com/done" }) + ); await expectRejected(sdk.exportSFTPfile({})); await expectRejected(sdk.exportSFTPfile({ file_id: "fid", export_path: " " })); await expectRejected(sdk.validateEmail("a@example.com", { callbackUrl: "https://example.com/cb" })); @@ -804,13 +811,18 @@ describe("offline API", () => { it("sends the documented S3 and SFTP job fields and omits unset ones", async () => { const sdk = client("parity-key"); - await sdk.emailFinderS3file({ file_name: "inbox.csv", has_header_row: false }); + await sdk.emailFinderS3file({ + file_name: "inbox.csv", + has_header_row: false, + return_url: "https://example.com/done", + }); let request = fake.only(); expect(request.path).toBe("/emailFinderS3file"); expect(request.body.file_name).toBe("inbox.csv"); expect(request.body.has_header_row).toBe("false"); + expect(request.body.return_url).toBe("https://example.com/done"); + // remove_duplicates is the only one of the four that stays optional. expect(request.body.remove_duplicates).toBeUndefined(); - expect(request.body.return_url).toBeUndefined(); fake.records.length = 0; await sdk.domainSearchS3file({ @@ -824,12 +836,13 @@ describe("offline API", () => { expect(request.body.remove_duplicates).toBe("false"); expect(request.body.return_url).toBe("https://example.com/done"); - // validateSFTPfile is the one job whose file_name the docs mark optional. + // LIVE: validateSFTPfile needs file_name too, although the docs mark it + // optional ("file_name: This field is required"). fake.records.length = 0; - await sdk.validateSFTPfile({ has_header_row: true }); + await sdk.validateSFTPfile({ file_name: "f.csv", has_header_row: true, return_url: "https://example.com/done" }); request = fake.only(); expect(request.path).toBe("/validateSFTPfile"); - expect(request.body.file_name).toBeUndefined(); + expect(request.body.file_name).toBe("f.csv"); fake.records.length = 0; await sdk.exportSFTPfile({ file_id: "fid" }); From a8b07cd3f7383d077482b92bfbe3cede0891b2d6 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:56:31 +0400 Subject: [PATCH 13/14] test: opt-in live API suite, and format Date inputs for getapiusage tests/live-api.mjs calls every method of both clients against the real API. Jest does not match .mjs, so `npm test` and CI never run it; `npm run test:live` does. It skips without ZEROBOUNCE_API_KEY / ZEROBOUNCE_WARMUP_API_KEY, runs free calls by default and checks the credit balance did not change, and gates the rest behind ZEROBOUNCE_LIVE_SPEND=1 (credit-spending calls and a bulk round trip, deleted again) and ZEROBOUNCE_LIVE_CHANGES=1 (a test filter and a self-managed Warmup sending account, both undone in cleanup even on failure). It never creates an Inbox Placement test, sets the Inbox Placement webhook, or starts ZB-managed Warmup onboarding: the API cannot undo those. No key is written to a file or printed; keys come from the environment and are redacted out of every message the suite prints. getApiUsage now formats a Date argument as the documented YYYY-MM-DD. Passed through unformatted a Date stringifies to "Fri Oct 24 2026 01:00:00 GMT+0400 (Gulf Standard Time)"; in a query string Cloudflare answered that with an HTML "Access denied" page instead of the API answering at all. Strings are untouched. Live run on 2026-10-24 with both flags: 39 passed, 0 failed, 4 skipped (the Warmup statistics endpoints need a registered sending account, and this team has none). It confirmed the four behaviours this branch had taken from the Python SDK's notes rather than from the docs: - validateBatch is accepted with `timeout` as a JSON string; - a complete S3 job payload is accepted, and one without return_url is refused before it is sent; - the Inbox Placement Tester accepts the Authorization: Bearer key; - check-domain-dmarc rejects a bare domain and accepts an email address. Two further live findings, both now reflected in the suite: - activity_data, verify_plus and domain_info are billable add-ons and are charged even for a sandbox address, so the option-heavy validate call sits in the SPEND tier rather than the free one. - A just-accepted bulk job answers "File cannot be found" for a short while, so the round trip retries the delete instead of leaving the job behind. Offline tests: 143 pass. Co-Authored-By: Claude Opus 5 --- package.json | 3 +- src/zero-bounce.js | 23 ++- tests/live-api.mjs | 492 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 513 insertions(+), 5 deletions(-) create mode 100644 tests/live-api.mjs diff --git a/package.json b/package.json index ee65592..553d1ca 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "prepublishOnly": "npm run build", "dts-check": "tsc --noEmit", "test:types": "tsc --noEmit -p tsconfig.test.json", - "test:real-api": "node tests/real-api-prod.mjs" + "test:real-api": "node tests/real-api-prod.mjs", + "test:live": "node tests/live-api.mjs" }, "repository": { "type": "git", diff --git a/src/zero-bounce.js b/src/zero-bounce.js index 02b8fcf..ab1d2fb 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -78,6 +78,21 @@ function optionalColumn(value) { return value ? value : null; } +/** + * A date for the API, as the documented YYYY-MM-DD. + * + * A Date left unformatted stringifies to "Fri Oct 24 2026 01:00:00 GMT+0400 + * (Gulf Standard Time)". In a query string that is long, spaced and + * parenthesised, and Cloudflare answers it with an HTML "Access denied" page + * rather than the API answering at all. Strings pass through untouched. + */ +function apiDate(value) { + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return value.toISOString().slice(0, 10); + } + return value; +} + /** Documented filter rules and targets, checked before a request is sent. */ const FILTER_RULES = ["allow", "block"]; const FILTER_TARGETS = ["email", "domain", "mx", "tld"]; @@ -273,14 +288,14 @@ export class ZeroBounceSDK { const params = { api_key: this._api_key, - start_date: startDate, - end_date: endDate, + start_date: apiDate(startDate), + end_date: apiDate(endDate), }; return createRequest({ requestType: "GET", params, - path: "/getapiusage", - apiBaseURL: this._api_base_url + path: "/getapiusage", + apiBaseURL: this._api_base_url, }); } diff --git a/tests/live-api.mjs b/tests/live-api.mjs new file mode 100644 index 0000000..05e18bd --- /dev/null +++ b/tests/live-api.mjs @@ -0,0 +1,492 @@ +/** + * Opt-in live API suite: calls every SDK method against the real API to check + * payloads and response shapes. + * + * Jest does not match this file (.mjs), so `npm test` and CI never run it. Run + * it explicitly: + * + * export ZEROBOUNCE_API_KEY=... # validation API (skipped without it) + * export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API (skipped without it) + * npm run test:live + * + * By default only free calls run: sandbox addresses (no credits), reads, and + * requests expected to fail (unknown file IDs, S3/SFTP without an integration) + * that still prove the API accepts the payload. The free tier checks that the + * credit balance did not change. Two flags opt in to more: + * + * ZEROBOUNCE_LIVE_SPEND=1 spends credits: scoring, activity, email + * finder, and bulk round trips (every job is + * deleted again) + * ZEROBOUNCE_LIVE_CHANGES=1 reversible account changes, each undone in + * cleanup even when a test fails: a test filter, + * and a self-managed Warmup sending account + * + * Never called, because the API cannot undo them: creating an Inbox Placement + * test (it cannot be deleted while running), setting the Inbox Placement + * webhook (it cannot be cleared), and ZB-managed Warmup onboarding (it starts + * mailbox provisioning). + * + * No key is ever written to a file or printed. Keys come from the environment. + */ +import { ZeroBounceSDK, ZeroBounceWarmup } from "../src/index.js"; + +const API_KEY = (process.env.ZEROBOUNCE_API_KEY || "").trim(); +const WARMUP_KEY = (process.env.ZEROBOUNCE_WARMUP_API_KEY || "").trim(); +const SPEND = process.env.ZEROBOUNCE_LIVE_SPEND === "1"; +const CHANGES = process.env.ZEROBOUNCE_LIVE_CHANGES === "1"; + +/** Well-formed but matching no file: a malformed ID can be Cloudflare-blocked instead of answered. */ +const UNKNOWN_ID = "0d1b7a4e-1111-2222-3333-444455556666"; +const CALLBACK = "https://example.com/zerobounce-sdk-live-test"; +const SANDBOX = "valid@example.com"; + +let passed = 0; +let failed = 0; +let skipped = 0; +const failures = []; + +function redact(text) { + let value = String(text); + for (const secret of [API_KEY, WARMUP_KEY]) { + if (secret) value = value.split(secret).join("***"); + } + return value; +} + +async function test(name, fn, { when = true, reason = "" } = {}) { + if (!when) { + skipped += 1; + console.log(` - SKIP ${name}${reason ? ` (${reason})` : ""}`); + return; + } + try { + await fn(); + passed += 1; + console.log(` + ${name}`); + } catch (error) { + failed += 1; + const message = redact(error && error.message ? error.message : String(error)); + failures.push(`${name}: ${message}`); + console.log(` X ${name}\n ${message}`); + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +/** Run `call`, expecting it to fail, and hand back the (redacted) message. */ +async function expectFailure(call, what) { + try { + await call(); + } catch (error) { + return redact(error.message || String(error)); + } + throw new Error(`${what} unexpectedly succeeded`); +} + +function sdk() { + const client = new ZeroBounceSDK(); + client.init(API_KEY); + return client; +} + +async function creditBalance(client) { + const response = await client.getCredits(); + return Number(response && response.Credits); +} + +async function main() { + console.log("ZeroBounce live API suite"); + console.log(` validation key: ${API_KEY ? "set" : "MISSING"}`); + console.log(` warmup key: ${WARMUP_KEY ? "set" : "MISSING"}`); + console.log(` flags: SPEND=${SPEND ? 1 : 0} CHANGES=${CHANGES ? 1 : 0}\n`); + + if (API_KEY) await validationFree(); + if (API_KEY && SPEND) await validationSpend(); + if (API_KEY && CHANGES) await validationChanges(); + if (WARMUP_KEY) await warmupFree(); + if (WARMUP_KEY && CHANGES) await warmupChanges(); + + console.log(`\n${passed} passed, ${failed} failed, ${skipped} skipped`); + if (failures.length) { + console.log("\nFailures:"); + for (const line of failures) console.log(` - ${line}`); + } + process.exit(failed ? 1 : 0); +} + +// -- validation API, free ----------------------------------------------------- + +async function validationFree() { + console.log("Validation API (free calls)"); + const client = sdk(); + const before = await creditBalance(client); + + await test("getCredits returns a balance", async () => { + assert(Number.isFinite(before), `Credits was ${before}`); + }); + + await test("getApiUsage parses the documented date formats", async () => { + const end = new Date(); + const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000); + const usage = await client.getApiUsage(start, end); + assert(usage && typeof usage.total === "number", "no total in response"); + }); + + await test("validateEmail on a sandbox address spends nothing", async () => { + const response = await client.validateEmail(SANDBOX); + assert(response && response.address === SANDBOX, "address did not echo"); + assert(typeof response.status === "string", "no status"); + }); + + await test("validateEmail works over POST as well as GET", async () => { + // No billable add-on here: activity_data, verify_plus and domain_info are + // charged even on a sandbox address, so they live in the SPEND tier. + const response = await client.validateEmail(SANDBOX, { usePost: true, timeout: 30 }); + assert(response && typeof response === "object", "no response"); + }); + + await test("LIVE: validateBatch accepts timeout as a string", async () => { + // Sent as a JSON number the API rejects the whole batch with the + // account-level "Invalid API Key or your account ran out of credits". + const response = await client.validateBatch([{ email_address: SANDBOX }], { + timeout: 30, + activityData: false, + verifyPlus: false, + }); + assert(Array.isArray(response.email_batch), "no email_batch"); + assert( + !(response.errors || []).some((e) => String(e.email_address).toLowerCase() === "all"), + `account-level error: ${JSON.stringify(response.errors)}` + ); + }); + + await test("an unknown file id is a short, clean error", async () => { + const message = await expectFailure(() => client.getFileStatus(UNKNOWN_ID), "getFileStatus"); + assert(message.length < 500, `message too long: ${message.length}`); + assert(!message.includes(" { + try { + const files = await client.listS3files(); + assert(files !== undefined, "no response"); + } catch (error) { + // No S3 integration configured is an expected, documented failure. + assert(error.message.length < 500, "message too long"); + } + }); + + await test("LIVE: S3/SFTP jobs reject a payload without return_url before sending", async () => { + const message = await expectFailure( + () => client.emailFinderS3file({ file_name: "f.csv", has_header_row: true }), + "emailFinderS3file without return_url" + ); + assert(/return_url/.test(message), `unexpected message: ${message}`); + }); + + await test("LIVE: a complete S3 job payload is accepted by the API", async () => { + // Without an S3 integration this fails on the integration, not the payload. + try { + await client.emailFinderS3file({ + file_name: "f.csv", + has_header_row: true, + return_url: CALLBACK, + }); + } catch (error) { + const message = redact(error.message); + assert( + !/return URL is not permitted|This field is required/i.test(message), + `payload rejected: ${message}` + ); + } + }); + + await test("filters list", async () => { + const response = await client.listFilters(); + assert(response !== undefined, "no response"); + }); + + await test("LIVE: the Inbox Placement Tester accepts a Bearer key", async () => { + const providers = await client.getInboxPlacementProviders(); + assert(providers && typeof providers === "object", "no providers payload"); + }); + + await test("inbox placement test types", async () => { + const types = await client.getInboxPlacementTestTypes(); + assert(types && typeof types === "object", "no test types"); + }); + + await test("inbox placement list and webhook read", async () => { + await client.listInboxPlacementTests({ page: 1, pageSize: 10 }); + await client.getInboxPlacementWebhook(); + }); + + await test("list evaluator result for an unknown id fails cleanly", async () => { + const message = await expectFailure( + () => client.getListEvaluatorResult(UNKNOWN_ID), + "getListEvaluatorResult" + ); + assert(message.length < 500, "message too long"); + }); + + await test("the free calls did not change the credit balance", async () => { + const after = await creditBalance(sdk()); + assert(after === before, `credits went from ${before} to ${after}`); + }); +} + +// -- validation API, spends credits ------------------------------------------- + +async function validationSpend() { + console.log("\nValidation API (ZEROBOUNCE_LIVE_SPEND=1)"); + const client = sdk(); + + await test("validateEmail accepts every documented option", async () => { + // LIVE: activity_data, verify_plus and domain_info are billable add-ons and + // are charged even for a sandbox address, so this is not a free call. + const response = await client.validateEmail(SANDBOX, { + timeout: 30, + activityData: true, + verifyPlus: true, + domainInfo: true, + greylistProcessing: true, + callbackUrl: CALLBACK, + }); + assert(response && typeof response === "object", "no response"); + }); + + await test("scoreEmail", async () => { + const response = await client.scoreEmail(SANDBOX); + assert(response && response.email !== undefined, "no email in response"); + }); + + await test("scoreEmail over POST", async () => { + const response = await client.scoreEmail(SANDBOX, { usePost: true }); + assert(response && typeof response === "object", "no response"); + }); + + await test("getEmailActivity", async () => { + const response = await client.getEmailActivity(SANDBOX); + assert(response && response.found !== undefined, "no found flag"); + }); + + await test("email finder", async () => { + const response = await client.findEmailByDomain({ + domain: "example.com", + first_name: "John", + last_name: "Doe", + }); + assert(response && typeof response === "object", "no response"); + }); + + await test("domain search", async () => { + const response = await client.findEmailFormatByDomain({ domain: "example.com" }); + assert(response && typeof response === "object", "no response"); + }); + + let fileId = null; + await test("bulk validation round trip: upload, status, delete", async () => { + const csv = "email\nvalid@example.com\ninvalid@example.com\n"; + const blob = new Blob([csv], { type: "text/csv" }); + const sent = await client.sendFileStream(blob, "zb-sdk-live.csv", { + email_address_column: 1, + has_header_row: true, + }); + assert(sent && sent.file_id, `no file_id: ${JSON.stringify(sent)}`); + fileId = sent.file_id; + const status = await client.getFileStatus(fileId); + assert(status && status.file_id === fileId, "status did not echo the file id"); + }); + + await test( + "the uploaded validation job is deleted again", + async () => { + // LIVE: a just-accepted job answers "File cannot be found" for a short + // while, so the delete is retried rather than leaving the job behind. + let lastError = null; + for (let attempt = 0; attempt < 12; attempt += 1) { + try { + await client.deleteFile(fileId); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 10000)); + } + } + throw new Error( + `could not delete ${fileId} after retrying: ${redact(lastError && lastError.message)}` + ); + }, + { when: Boolean(fileId), reason: "upload did not produce a file id" } + ); +} + +// -- validation API, reversible account changes ------------------------------- + +async function validationChanges() { + console.log("\nValidation API (ZEROBOUNCE_LIVE_CHANGES=1)"); + const client = sdk(); + const value = `zb-sdk-live-${Date.now()}.example.com`; + let added = false; + + try { + await test("addFilter creates a reversible allow rule", async () => { + const response = await client.addFilter({ rule: "allow", target: "domain", value }); + added = true; + assert(response !== undefined, "no response"); + }); + } finally { + await test( + "the test filter is removed again", + async () => { + await client.deleteFilter({ rule: "allow", target: "domain", value }); + }, + { when: added, reason: "filter was not created" } + ); + } +} + +// -- Warmup API --------------------------------------------------------------- + +function warmup() { + return new ZeroBounceWarmup(WARMUP_KEY); +} + +async function warmupFree() { + console.log("\nWarmup API (reads)"); + const client = warmup(); + + const reads = [ + ["listSendingAccounts", () => client.listSendingAccounts()], + ["listFromAddresses", () => client.listFromAddresses()], + ["getEngagementRules", () => client.getEngagementRules()], + ["getSeeds", () => client.getSeeds()], + ["getSeedCount", () => client.getSeedCount()], + ["getProgressToday", () => client.getProgressToday()], + ["getSentToday", () => client.getSentToday()], + ["getEmailsPerDay", () => client.getEmailsPerDay()], + ]; + for (const [name, call] of reads) { + await test(name, async () => { + const response = await call(); + assert(response !== undefined, "no response"); + }); + } + + await test("downloadSeeds returns CSV text", async () => { + const csv = await client.downloadSeeds(); + assert(typeof csv === "string" && csv.length > 0, "no CSV body"); + assert(!csv.trim().startsWith("{"), "got JSON instead of CSV"); + }); + + await test("LIVE: checkDomainDmarc rejects a bare domain before sending", async () => { + const message = await expectFailure( + () => client.checkDomainDmarc("example.com"), + "checkDomainDmarc with a domain" + ); + assert(/email address/i.test(message), `unexpected message: ${message}`); + }); + + await test("LIVE: checkDomainDmarc accepts an email address", async () => { + const response = await client.checkDomainDmarc("sender@example.com"); + assert(response && typeof response === "object", "no response"); + }); + + // The statistics endpoints need a from-address this team actually has; the + // API answers "Sending account ... was not found" for anything else. + let known = null; + try { + const accounts = await client.listSendingAccounts(); + const list = Array.isArray(accounts) ? accounts : accounts && accounts.data; + const first = Array.isArray(list) ? list[0] : null; + known = first && (first.from_address || first.c_from || first.fromAddress); + } catch { + known = null; + } + + await test( + "statistics for a registered from-address", + async () => { + const response = await client.getStatistics(known); + assert(response !== undefined, "no response"); + }, + { when: Boolean(known), reason: "this team has no sending account" } + ); + + await test( + "statistics per day over a short range", + async () => { + const end = new Date().toISOString().slice(0, 10); + const start = new Date(Date.now() - 6 * 24 * 3600 * 1000).toISOString().slice(0, 10); + const response = await client.getStatisticsPerDay({ + fromAddress: known, + startDate: start, + endDate: end, + }); + assert(response !== undefined, "no response"); + }, + { when: Boolean(known), reason: "this team has no sending account" } + ); + + await test( + "detailed statistics and the CSV download for that address", + async () => { + await client.getStatisticsDetailed(known); + const csv = await client.downloadStatistics(known); + assert(typeof csv === "string", "no CSV body"); + }, + { when: Boolean(known), reason: "this team has no sending account" } + ); + + await test( + "from-address statistics", + async () => { + const response = await client.getFromAddressStatistics(known); + assert(response !== undefined, "no response"); + }, + { when: Boolean(known), reason: "this team has no sending account" } + ); + + await test("an unknown Warmup resource fails with a status code", async () => { + try { + await client.getCustomEngagement("no-such-address@example.com"); + } catch (error) { + assert(typeof error.statusCode === "number", "no statusCode on the error"); + assert(!String(error.message).includes(WARMUP_KEY), "the key leaked into the message"); + } + }); +} + +async function warmupChanges() { + console.log("\nWarmup API (ZEROBOUNCE_LIVE_CHANGES=1)"); + const client = warmup(); + const address = `zb-sdk-live-${Date.now()}@example.com`; + let added = false; + + try { + await test("a self-managed sending account can be added", async () => { + await client.addSendingAccount({ + fromAddress: address, + onboardingModel: "self_managed", + engagementRuleId: 0, + ignoreConflict: true, + }); + added = true; + }); + } finally { + await test( + "the test sending account is removed again", + async () => { + await client.deleteSendingAccount(address); + }, + { when: added, reason: "account was not created" } + ); + } +} + +main().catch((error) => { + console.error(redact(error && error.stack ? error.stack : String(error))); + process.exit(1); +}); From c16e4a7eb9fa7576b0a6fad6dc680bb3fe18eb07 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:09:28 +0400 Subject: [PATCH 14/14] fix: compare the bulk host exactly, and drop a backtracking regex Two CodeQL findings on this PR. warnOnRegionalBulkHost matched "bulkapi.zerobounce.net" as a substring of the whole URL, which also accepts https://evil.example/bulkapi.zerobounce.net and https://bulkapi.zerobounce.net.evil.example. The host is now parsed out and compared on its own, the same way the PHP, C and Pascal SDKs already do it. The regional base URL is matched the same way rather than by substring. The Warmup client trimmed trailing slashes with /\/+$/, which backtracks quadratically on a string of many slashes when the base URL comes from the caller. Replaced with a linear scan. Co-Authored-By: Claude Opus 5 --- src/utils.js | 22 +++++++++++++++++++--- src/warmup.js | 13 ++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/utils.js b/src/utils.js index a8170fc..56aacf9 100644 --- a/src/utils.js +++ b/src/utils.js @@ -263,12 +263,28 @@ function transportError(error, secrets = []) { * sends bulk traffic to bulkapi.zerobounce.net. Warn once per client. */ const REGIONAL_API_HOSTS = ["api-eu.zerobounce.net", "api-us.zerobounce.net"]; +const BULK_HOST = "bulkapi.zerobounce.net"; + +/** + * The host of a URL, lowercased, or "" when it will not parse. + * + * Matching the host as a substring of the whole URL would also accept + * `https://evil.example/bulkapi.zerobounce.net` and + * `https://bulkapi.zerobounce.net.evil.example`, so the host is compared on + * its own. + */ +function hostOf(value) { + try { + return new URL(String(value)).hostname.toLowerCase(); + } catch { + return ""; + } +} export function warnOnRegionalBulkHost(url, apiBaseURL, state) { if (!state || state.warned) return; - const base = String(apiBaseURL || "").toLowerCase(); - if (!REGIONAL_API_HOSTS.some((host) => base.includes(host))) return; - if (!String(url).toLowerCase().includes("bulkapi.zerobounce.net")) return; + if (!REGIONAL_API_HOSTS.includes(hostOf(apiBaseURL))) return; + if (hostOf(url) !== BULK_HOST) return; state.warned = true; const warn = typeof console !== "undefined" && console.warn ? console.warn : null; if (warn) { diff --git a/src/warmup.js b/src/warmup.js index 052eeec..65a3e05 100644 --- a/src/warmup.js +++ b/src/warmup.js @@ -63,6 +63,17 @@ function fetchTimeoutSignal() { return undefined; } +/** + * Drops trailing slashes without a regular expression: `/\/+$/` backtracks + * quadratically on a string of many slashes, which CodeQL flags as a + * polynomial ReDoS when the value comes from a caller. + */ +function trimTrailingSlashes(value) { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 47 /* "/" */) end -= 1; + return value.slice(0, end); +} + export class ZeroBounceWarmup { static DEFAULT_BASE_URL = DEFAULT_BASE_URL; @@ -75,7 +86,7 @@ export class ZeroBounceWarmup { checkApiKey(apiKey, "Warmup api key"); const base = requireHttpsUrl(baseURL || DEFAULT_BASE_URL, "baseURL"); this._api_key = apiKey; - this._root = `${base.replace(/\/+$/, "")}${BASE_PATH}`; + this._root = `${trimTrailingSlashes(base)}${BASE_PATH}`; } // -- transport --------------------------------------------------------------