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..c41d771 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# 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 + +- **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`, `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`. +- **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..7367839 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,109 @@ +# 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`, `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`. +- `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. +- `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/README.md b/README.md index b3a6f81..eb3d327 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,95 @@ 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); +} +``` + +- ##### 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 +// 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..e3232ef 100644 --- a/documentation.md +++ b/documentation.md @@ -72,6 +72,95 @@ 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); +} +``` + +- ##### 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 +// 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/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/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/utils.js b/src/utils.js index ea3e60c..56aacf9 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: "*/*", @@ -7,14 +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)) { - parameterIsInvalid("apiBaseURL", "Must be an https:// 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); @@ -64,8 +123,197 @@ 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) { + // 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; +} + +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})`; +} + +/** + * 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); + 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"]; +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; + 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) { + 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) { + 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 apiError(messageFromBody(finalResult, status), status); + } + if (!String(finalResult).trim()) { + throw apiError(`Empty response from ZeroBounce (HTTP ${status})`, status); + } + const trimmed = finalResult.trim(); const looksJson = ct.includes("application/json") || trimmed.startsWith("{") || trimmed.startsWith("["); @@ -77,15 +325,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 apiError("ZeroBounce returned a response that is not valid JSON.", status); } } @@ -97,41 +337,110 @@ 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, 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)}` + : ""; + return `${base}${path}${query}`; +} + export async function createRequest({ requestType, body = null, + jsonBody = null, params = null, path, batch = false, + bulkBase = "v2", returnText = false, scoring = false, - apiBaseURL + apiBaseURL, + baseURL = null, + bearerToken = null, + extraHeaders = null, + secrets = [], + regionalWarningState = null, }) { - const url = `${ - batch ? API_BULK_BASE_URL : apiBaseURL - }${path}?${new URLSearchParams(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 { - const response = await fetch(url, { + response = await fetch(url, { method: requestType, - headers: HEADERS, - body, + headers, + body: jsonBody != null ? JSON.stringify(jsonBody) : 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 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, secrets); + } + + 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 apiError(redactSecrets(messageFromBody(finalResult, status), secrets), status); } + + let parsed; + try { + parsed = JSON.parse(finalResult); + } catch { + throw apiError(redactSecrets(messageFromBody(finalResult, status), secrets), status); + } + const account = accountLevelMessage(parsed); + 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; } function saveFile(blob, filename) { @@ -157,17 +466,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/warmup.js b/src/warmup.js new file mode 100644 index 0000000..65a3e05 --- /dev/null +++ b/src/warmup.js @@ -0,0 +1,517 @@ +/** + * 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; +} + +/** + * 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; + + /** + * @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 = `${trimTrailingSlashes(base)}${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/src/zero-bounce.js b/src/zero-bounce.js index 786c6d4..ab1d2fb 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). @@ -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" }); /** @@ -65,6 +66,37 @@ 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; +} + +/** + * 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"]; + export class ZeroBounceSDK { static ApiURL = Object.freeze({ DEFAULT_API_URL: "https://api.zerobounce.net/v2", @@ -79,27 +111,73 @@ 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", + }); + + 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 }; } /** * @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) { - 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; + 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."); } + checkApiKey(apiKey, "Api key"); + this._api_key = apiKey; + this._api_base_url = requireHttpsUrl(apiBaseURL); + const { + listEvaluatorBaseURL, + inboxPlacementBaseURL, + emailFinderBaseURL, + domainSearchBaseURL, + } = options || {}; + this._list_evaluator_base_url = requireHttpsUrl( + listEvaluatorBaseURL ?? ZeroBounceSDK.ListEvaluatorURL.DEFAULT_URL, + "listEvaluatorBaseURL" + ); + this._inbox_placement_base_url = requireHttpsUrl( + 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; } - getCredits() { + async getCredits() { if (!this._initialized) { notInitialized(); return; @@ -115,23 +193,36 @@ 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) + * @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; } let ip_address; 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") { ip_address = options.ip_address; 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)) { @@ -139,12 +230,42 @@ 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, }; 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"; + 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"; + + 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 }); } @@ -153,48 +274,70 @@ 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; } 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, }); } /** * @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) { + async validateBatch(emailBatch, options = null) { if (!this._initialized) { notInitialized(); return; - } else if (!emailBatch) { + } else if (emailBatch == null) { parameterIsMissing("Email list"); 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, }; + // 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({ requestType: "POST", path: "/validatebatch", @@ -206,11 +349,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; } @@ -226,6 +369,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) + * */ + async scoreEmail(email, options = null) { + if (!this._initialized) { + notInitialized(); + return; + } else if (isMissing(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. @@ -238,7 +421,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, @@ -256,7 +439,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; } @@ -280,11 +463,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({ @@ -302,7 +485,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, @@ -319,10 +502,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; } @@ -335,11 +518,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({ @@ -358,7 +541,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, @@ -371,7 +554,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; } @@ -384,9 +567,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", @@ -403,7 +586,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, @@ -415,10 +598,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; } @@ -427,9 +610,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", @@ -440,11 +623,11 @@ export class ZeroBounceSDK { }); } - _getStatusUtil(fileId, path) { + async _getStatusUtil(fileId, path, { bulkRoot = false } = {}) { if (!this._initialized) { notInitialized(); return; - } else if (!fileId) { + } else if (isMissing(fileId)) { parameterIsMissing("File id"); return; } @@ -458,29 +641,46 @@ export class ZeroBounceSDK { params, path, batch: true, + bulkBase: bulkRoot ? "root" : "v2", apiBaseURL: this._api_base_url }); } + 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 * */ - 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) { + 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; } @@ -501,6 +701,7 @@ export class ZeroBounceSDK { params, path, batch: true, + bulkBase: bulkRoot ? "root" : "v2", returnText: true, scoring, apiBaseURL: this._api_base_url @@ -511,7 +712,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); } @@ -519,15 +720,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) { + async _deleteFileUtil(fileId, path, scoring = false, { bulkRoot = false } = {}) { if (!this._initialized) { notInitialized(); return; - } else if (!fileId) { + } else if (isMissing(fileId)) { parameterIsMissing("File id"); return; } @@ -540,25 +741,775 @@ export class ZeroBounceSDK { params, path, batch: true, + bulkBase: bulkRoot ? "root" : "v2", scoring, apiBaseURL: this._api_base_url }); } + async _sendBulkRootMultipartFile(path, { file, fileName = null, fields = {} }) { + const body = new FormData(); + for (const [key, value] of Object.entries(fields)) { + if (value == null) continue; + body.append(key, typeof value === "boolean" ? booleanText(value) : 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, + }); + } + /** * @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/*) + + async 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 (isMissing(domain_column)) { + parameterIsMissing("domain_column"); + return; + } + return this._sendBulkRootMultipartFile("/email-finder/sendfile", { + file, + fields: { + domain_column, + 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), + }, + }); + } + + async 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 (isMissing(fileName)) { + parameterIsMissing("fileName"); + return; + } else if (isMissing(domain_column)) { + parameterIsMissing("domain_column"); + return; + } + return this._sendBulkRootMultipartFile("/email-finder/sendfile", { + file: fileStreamOrBlob, + fileName, + fields: { + domain_column, + 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), + }, + }); + } + + async getEmailFinderFileStatus(fileId) { + return this._getStatusUtil(fileId, "/email-finder/filestatus", { bulkRoot: true }); + } + + async getEmailFinderFile(fileId) { + return this._getFileUtil(fileId, "/email-finder/getfile", false, null, { bulkRoot: true }); + } + + async deleteEmailFinderFile(fileId) { + return this._deleteFileUtil(fileId, "/email-finder/deletefile", false, { bulkRoot: true }); + } + + // BULK DOMAIN SEARCH (bulkapi.zerobounce.net/domain-search/*) + + async sendDomainSearchFile({ + file, + domain_column, + has_header_row = false, + }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!file) { + parameterIsMissing("file"); + return; + } else if (isMissing(domain_column)) { + parameterIsMissing("domain_column"); + return; + } + return this._sendBulkRootMultipartFile("/domain-search/sendfile", { + file, + fields: { domain_column, has_header_row: Boolean(has_header_row) }, + }); + } + + async sendDomainSearchFileStream(fileStreamOrBlob, fileName, { + domain_column, + has_header_row = false, + } = {}) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!fileStreamOrBlob) { + parameterIsMissing("fileStreamOrBlob"); + return; + } else if (isMissing(fileName)) { + parameterIsMissing("fileName"); + return; + } else if (isMissing(domain_column)) { + parameterIsMissing("domain_column"); + return; + } + return this._sendBulkRootMultipartFile("/domain-search/sendfile", { + file: fileStreamOrBlob, + fileName, + fields: { domain_column, has_header_row: Boolean(has_header_row) }, + }); + } + + async getDomainSearchFileStatus(fileId) { + return this._getStatusUtil(fileId, "/domain-search/filestatus", { bulkRoot: true }); + } + + async getDomainSearchFile(fileId) { + return this._getFileUtil(fileId, "/domain-search/getfile", false, null, { bulkRoot: true }); + } + + async deleteDomainSearchFile(fileId) { + return this._deleteFileUtil(fileId, "/domain-search/deletefile", false, { bulkRoot: true }); + } + + // LIST EVALUATOR (bulkapi.zerobounce.net/v2/listevaluator/) + + async sendListEvaluatorFile({ file, email_address_column }) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!file) { + parameterIsMissing("file"); + return; + } else if (isMissing(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: "/", + body, + apiBaseURL: this._list_evaluator_base_url, + }); + } + + async sendListEvaluatorFileStream(fileStreamOrBlob, fileName, { email_address_column } = {}) { + if (!this._initialized) { + notInitialized(); + return; + } else if (!fileStreamOrBlob) { + parameterIsMissing("fileStreamOrBlob"); + return; + } else if (isMissing(fileName)) { + parameterIsMissing("fileName"); + return; + } else if (isMissing(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: "/", + body, + apiBaseURL: this._list_evaluator_base_url, + }); + } + + async getListEvaluatorResult(fileId) { + if (!this._initialized) { + notInitialized(); + return; + } else if (isMissing(fileId)) { + parameterIsMissing("File id"); + return; + } + const params = { api_key: this._api_key }; + return createRequest({ + requestType: "GET", + params, + path: `/${encodeURIComponent(fileId)}/`, + apiBaseURL: this._list_evaluator_base_url, + }); + } + + /** + * 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; + } + const id = typeof fileId === "object" && fileId !== null ? fileId.file_id : fileId; + if (isMissing(id)) { + parameterIsMissing("File id"); + return; + } + const body = new FormData(); + 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, + }); + } + + // CUSTOM FILTERS (main API /v2/filters/*) + + async addFilter({ rule, target, value }) { + if (!this._initialized) { + notInitialized(); + return; + } 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; + } + 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, + }); + } + + async 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, + }); + } + + async deleteFilter({ rule, target, value }) { + if (!this._initialized) { + notInitialized(); + return; + } 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; + } + 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) + + 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, + }); + } + + + /** + * 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 + * */ + /** + * 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 (isMissing(file_name) || String(file_name).trim() === "") { + parameterIsMissing("file_name"); + return; + } + if (has_header_row == null) { + 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 = { + file_name, + has_header_row: booleanText(has_header_row), + return_url, + }; + if (remove_duplicates != null) fields.remove_duplicates = booleanText(remove_duplicates); + 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. + + _iptRequest(requestType, path, { params = null, jsonBody = null } = {}) { + return createRequest({ + requestType, + path, + params, + jsonBody, + // 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, + }); + } + + /** Seed providers available for new tests. GET /providers */ + async getInboxPlacementProviders() { + if (!this._initialized) { + notInitialized(); + return; + } + return this._iptRequest("GET", "/providers"); + } + + /** Supported test types and the default one. GET /test-types */ + async getInboxPlacementTestTypes() { + if (!this._initialized) { + notInitialized(); + return; + } + return this._iptRequest("GET", "/test-types"); + } + + /** + * 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; + } + 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; + } + 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 }); + } + + /** A test and its placement results. GET /test/{id} */ + async getInboxPlacementTest(testId) { + if (!this._initialized) { + notInitialized(); + return; + } + if (isMissing(testId)) { + parameterIsMissing("testId"); + return; + } + 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; + } + 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 /** @@ -567,7 +1518,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, @@ -587,7 +1538,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, @@ -601,7 +1552,7 @@ export class ZeroBounceSDK { }); } - _findEmail({ + async _findEmail({ domain = null, company_name = null, first_name, @@ -611,11 +1562,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; } @@ -623,9 +1574,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; @@ -647,7 +1598,7 @@ export class ZeroBounceSDK { /** * @param domain str - domain name * */ - findEmailFormatByDomain({ + async findEmailFormatByDomain({ domain }) { return this._findEmailFormat({ @@ -658,7 +1609,7 @@ export class ZeroBounceSDK { /** * @param company_name str - company name * */ - findEmailFormatByCompanyName({ + async findEmailFormatByCompanyName({ company_name }) { return this._findEmailFormat({ @@ -666,14 +1617,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; @@ -705,7 +1656,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, @@ -715,7 +1666,7 @@ export class ZeroBounceSDK { if (!this._initialized) { notInitialized(); return; - } else if (!domain) { + } else if (isMissing(domain)) { parameterIsMissing("domain"); return; } @@ -723,10 +1674,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/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); +}); diff --git a/tests/offline-api.test.js b/tests/offline-api.test.js new file mode 100644 index 0000000..34e8d54 --- /dev/null +++ b/tests/offline-api.test.js @@ -0,0 +1,947 @@ +/** + * 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("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"); + } + 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 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 || bearer || "", + 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/"], + ["listS3files", () => sdk.listS3files(), "GET", "bulkapi.zerobounce.net", "/listS3files"], + ["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, 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"], + ["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("eval-file-1"), "POST", "bulkapi.zerobounce.net", "/v2/send-evaluated-file/"], + ["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: "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"], + ["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: "allow", target: "email", value: "a@example.com" }); + expect(fake.only().body).toEqual({ + api_key: "parity-key", + rule: "allow", + 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" })); + 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" })); + 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" }); + // 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({ + 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(); + // 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({ + 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("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, + 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(); + + 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"); + + // 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({ 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).toBe("f.csv"); + + 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"); + 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/ts-consumer.ts b/tests/ts-consumer.ts index 0056a52..fc1da64 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, @@ -21,6 +22,9 @@ import ZeroBounceSDK, { type ValidateBatchResponse, type ApiUsageResponse, type EmailActivityResponse, + type EmailScoringResult, + type EmailScoringError, + type ScoreEmailOptions, type SendFileResponse, type ValidationFileStatusResponse, type ScoringFileStatusResponse, @@ -46,6 +50,15 @@ const downloadCombined: string = ZeroBounceSDK.ZBDownloadType.COMBINED; const validateOpts: ValidateEmailOptions = { ip_address: "127.0.0.1", timeout: 10, + activityData: true, + verifyPlus: false, + method: "POST", +}; + +const batchOpts: ValidateBatchOptions = { + timeout: 30, + activityData: true, + verifyPlus: true, }; const batchItems: ValidateBatchEmailItem[] = [ @@ -82,11 +95,17 @@ 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"); +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 = @@ -198,6 +217,8 @@ function assertTypes(): void { void usagePromise; void batchPromise; void activityPromise; + void scorePromise; + void scorePostPromise; void findDomainPromise; void findCompanyPromise; void formatDomainPromise; 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/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index c041cb4..bfcf388 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' @@ -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 () => { @@ -225,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", @@ -290,18 +333,109 @@ 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 () => { + 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"); + }); + + 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", () => { + const email = "mark@gmail.com"; + + it("should throw an error if not initialized", async () => { + 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 expect(zeroBounceSDK.scoreEmail(null)).rejects.toThrow(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(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"); + 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); }); }); @@ -313,25 +447,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 () => { @@ -397,6 +538,31 @@ 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); + // 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); + }); + + it("should throw an error if batch timeout is out of range", async () => { + zeroBounceSDK.init("valid-api-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); + await expect(zeroBounceSDK.validateBatch(emailBatch, { timeout: 9 })).rejects.toThrow("timeout parameter is invalid") + }); }); @@ -404,16 +570,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 () => { @@ -453,8 +615,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 () => { @@ -463,10 +624,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 () => { @@ -475,21 +633,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" - ] - }); + // {"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 () => { @@ -536,8 +699,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 () => { @@ -546,10 +708,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 () => { @@ -558,21 +717,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" - ] - }); + // {"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 () => { @@ -599,16 +763,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 () => { @@ -649,16 +809,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 () => { @@ -702,16 +858,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 () => { @@ -806,16 +958,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 () => { @@ -846,16 +994,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 () => { @@ -891,16 +1035,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 () => { @@ -940,16 +1080,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 () => { @@ -991,16 +1127,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 () => { @@ -1033,4 +1165,128 @@ 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("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 3432372..acd6583 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). */ @@ -69,6 +70,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; @@ -83,6 +88,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. */ @@ -138,6 +149,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; @@ -231,11 +259,34 @@ 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; + /** 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"; } /** 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). */ @@ -310,6 +361,184 @@ 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; +} + +/** 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; + /** At least 1 when given. */ + maxSeeds?: number; +} + +/** Options for createInboxPlacementTest (POST /api/ipt/test). */ +export interface CreateInboxPlacementTestOptions { + /** Unique test name, at most 64 characters. */ + name: 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; +} + +/** 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; @@ -355,7 +584,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. @@ -365,7 +603,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 +622,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; /** @@ -396,6 +636,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. */ @@ -484,6 +734,149 @@ 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; + + listS3files( + options?: { bucket?: string | null; search?: string | null } | null + ): Promise; + + validateS3file( + options: ValidateS3FileOptions + ): Promise; + + scoreS3file( + options: ScoreS3FileOptions + ): Promise; + + exportS3file( + 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; + + /** 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; + + /** Inbox Placement Tester: delete a finished test. DELETE /api/ipt/test/{id} */ + deleteInboxPlacementTest(testId: string | number): Promise; + + /** Inbox Placement Tester: the webhook saved on this API key. GET /api/ipt/webhook */ + getInboxPlacementWebhook(): Promise; + + /** Inbox Placement Tester: save the result webhook. PUT /api/ipt/webhook */ + setInboxPlacementWebhook(webhookUrl: string): Promise; + /** * Email Finder: find email by domain and name. */ @@ -521,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;