diff --git a/AGENTS.md b/AGENTS.md index c41d771..f8ca4ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,15 +30,16 @@ There is no separate lint command. CI (`.github/workflows/sdk_ci.yml`) runs `npm ## Conventions +- **Exactly the documented set of fields, no more and no fewer.** The eight S3 / SFTP file jobs go through `_bulkRootFileJob` and send `file_name`, `has_header_row`, `remove_duplicates` and `return_url` only. The live API requires `file_name` and `return_url` on every one of them, including `validateSFTPfile`, although the docs mark them optional. - **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. +- **`allow_phase_2` is validation bulk only** (`sendFile`, `sendFileStream`), and only when `allowPhase2` is set. Scoring `sendFile` ignores it. The S3 and SFTP file jobs send only the documented set and so never send it. `activity_data` on get-file is validation `getFile` only. +- **List evaluator, the Inbox Placement Tester, the bulk email finder and bulk domain search have their own base URLs**, set on the instance by `init`'s third argument: `https://bulkapi.zerobounce.net/v2/listevaluator`, `https://bulkapi.zerobounce.net/api/ipt`, `https://bulkapi.zerobounce.net/email-finder` and `https://bulkapi.zerobounce.net/domain-search`. They are not built from the bulk base, and each is passed to `createRequest` as `baseURL` with a path relative to it. `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`. +- **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`, and sends its body as `jsonBody` so `createRequest` sets `Content-Type: application/json`. 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"`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d0709..36aeb45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `init`'s `emailFinderBaseURL` and `domainSearchBaseURL` options are now honoured. They were validated and stored but never read, so the bulk email finder and bulk domain search always went to `https://bulkapi.zerobounce.net`. Both default to the same hosts as before, so a client that does not set them is unaffected. +- **BREAKING:** `sendEmailFinderFile` and `sendEmailFinderFileStream` require `first_name_column` or `full_name_column`, as the bulk email finder documents. As with every optional column, `0` and `false` mean "leave this column out" and so do not count as given. +- **BREAKING:** `validateS3file` and `scoreS3file` now send exactly the documented job fields — `file_name`, `has_header_row`, `remove_duplicates` and `return_url` — and go through the same path as their six siblings. They no longer send the undocumented `email_address_column`, `first_name_column`, `last_name_column`, `gender_column`, `ip_address_column`, `allow_phase_2` or singular `remove_duplicate`. `file_name`, `has_header_row` and `return_url` are now required on both: the live API rejects a job without `return_url` ("Provided return URL is not permitted: None"). `allowPhase2` remains available on `sendFile` and `sendFileStream`. +- `findEmailByDomain` and `findEmailByCompanyName` accept a missing `first_name` instead of rejecting it, as the Email Finder documents it as optional. Without it the API answers with the domain's or company's email format. `domain` or `company_name` is still required. +- `exportS3file` accepts a missing `export_bucket` instead of rejecting it, matching `exportSFTPfile`'s `export_path`. Without it the output bucket from your ZeroBounce settings is used. + +### Fixed + +- `validateBatch` sends its JSON body with `Content-Type: application/json`. It was handed to `fetch` as a plain string, which labelled the JSON `text/plain;charset=UTF-8`. +- Warmup `addSendingAccount` rejects `engagementRuleId` for a `zb_managed` account rather than silently dropping it. The field belongs to `self_managed` only. +- `validateSFTPfile` no longer passes a third argument that the shared job helper never had, and its documentation no longer says `file_name` is optional — the live API requires it. + ## [3.0.0] - 2026-10-25 This release is a major version because the error contract changed: every public diff --git a/README.md b/README.md index eb3d327..45339a4 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,27 @@ try { - ##### S3 and SFTP/FTP bulk jobs ```javascript +// All eight jobs take the same four fields and nothing else: file_name, +// has_header_row, remove_duplicates and return_url. The live API requires +// file_name and return_url on every one of them, including validateSFTPfile, +// although the docs mark them optional. + // 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.validateS3file({ + file_name: "lists/leads.csv", + has_header_row: true, + return_url: "https://example.com/zerobounce-done", +}); +await zeroBounce.scoreS3file({ + file_name: "lists/leads.csv", + has_header_row: true, + return_url: "https://example.com/zerobounce-done", +}); +await zeroBounce.emailFinderS3file({ + file_name: "lists/leads.csv", + has_header_row: true, + return_url: "https://example.com/zerobounce-done", +}); await zeroBounce.domainSearchS3file({ file_name: "lists/leads.csv", has_header_row: true, @@ -150,18 +169,46 @@ await zeroBounce.domainSearchS3file({ return_url: "https://example.com/zerobounce-done", }); +// Without export_bucket the output bucket from your settings is used. +await zeroBounce.exportS3file({ file_id: "" }); +await zeroBounce.exportS3file({ file_id: "", export_bucket: "my-bucket" }); + // 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 }); +const sftpJob = { + file_name: "my_file.txt", + has_header_row: true, + return_url: "https://example.com/zerobounce-done", +}; +await zeroBounce.validateSFTPfile(sftpJob); +await zeroBounce.scoreSFTPfile(sftpJob); +await zeroBounce.emailFinderSFTPfile(sftpJob); +await zeroBounce.domainSearchSFTPfile(sftpJob); // Without export_path the path from your settings is used. await zeroBounce.exportSFTPfile({ file_id: "", export_path: "/exports" }); ``` +- ##### Bulk email finder + +```javascript +// domain_column is required, and so is a name to work from: send either +// first_name_column or full_name_column. As with every optional column, 0 and +// false mean "leave this column out", so neither counts as a name column. +await zeroBounce.sendEmailFinderFile({ + file: "", + domain_column: 1, + first_name_column: 2, + last_name_column: 3, + has_header_row: true, +}); +await zeroBounce.sendEmailFinderFile({ + file: "", + domain_column: 1, + full_name_column: 2, +}); +``` + - ##### Inbox Placement Tester ```javascript @@ -199,11 +246,14 @@ try { - ##### 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. +// The list evaluator, the Inbox Placement Tester, the bulk email finder and +// bulk domain search each have their own base URL, separate from the main and +// bulk hosts. Every one of them must be https://. zeroBounce.init("", ZeroBounceSDK.ApiURL.DEFAULT_API_URL, { listEvaluatorBaseURL: "https://bulkapi.zerobounce.net/v2/listevaluator", inboxPlacementBaseURL: "https://bulkapi.zerobounce.net/api/ipt", + emailFinderBaseURL: "https://bulkapi.zerobounce.net/email-finder", + domainSearchBaseURL: "https://bulkapi.zerobounce.net/domain-search", }); ``` @@ -422,8 +472,9 @@ try { // The email domain for which to find the email format. // company_name: String // The company name for which to find the email format. -// first_name: String +// first_name: String or null (Optional) // The first name of the person whose email format is being searched. +// Left out, the API answers with the domain's or company's email format. // middle_name: String or null (Optional) // The middle name of the person whose email format is being searched. // last_name: String or null (Optional) @@ -446,6 +497,8 @@ const companyNamePayload = { try { const domainResponse = await zeroBounce.findEmailByDomain(domainPayload); const companyNameResponse = await zeroBounce.findEmailByCompanyName(companyNamePayload); + // first_name is optional: this asks for the domain's format on its own. + const formatOnly = await zeroBounce.findEmailByDomain({ domain: "" }); } catch (error) { console.error(error); } diff --git a/documentation.md b/documentation.md index e3232ef..0691ec2 100644 --- a/documentation.md +++ b/documentation.md @@ -95,8 +95,27 @@ try { - ##### S3 and SFTP/FTP bulk jobs ```javascript +// All eight jobs take the same four fields and nothing else: file_name, +// has_header_row, remove_duplicates and return_url. The live API requires +// file_name and return_url on every one of them, including validateSFTPfile, +// although the docs mark them optional. + // 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.validateS3file({ + file_name: "lists/leads.csv", + has_header_row: true, + return_url: "https://example.com/zerobounce-done", +}); +await zeroBounce.scoreS3file({ + file_name: "lists/leads.csv", + has_header_row: true, + return_url: "https://example.com/zerobounce-done", +}); +await zeroBounce.emailFinderS3file({ + file_name: "lists/leads.csv", + has_header_row: true, + return_url: "https://example.com/zerobounce-done", +}); await zeroBounce.domainSearchS3file({ file_name: "lists/leads.csv", has_header_row: true, @@ -104,18 +123,39 @@ await zeroBounce.domainSearchS3file({ return_url: "https://example.com/zerobounce-done", }); +// Without export_bucket the output bucket from your settings is used. +await zeroBounce.exportS3file({ file_id: "" }); + // 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 }); +const sftpJob = { + file_name: "my_file.txt", + has_header_row: true, + return_url: "https://example.com/zerobounce-done", +}; +await zeroBounce.validateSFTPfile(sftpJob); +await zeroBounce.scoreSFTPfile(sftpJob); +await zeroBounce.emailFinderSFTPfile(sftpJob); +await zeroBounce.domainSearchSFTPfile(sftpJob); // Without export_path the path from your settings is used. await zeroBounce.exportSFTPfile({ file_id: "", export_path: "/exports" }); ``` +- ##### Bulk email finder + +```javascript +// domain_column is required, and so is a name to work from: send either +// first_name_column or full_name_column. As with every optional column, 0 and +// false mean "leave this column out", so neither counts as a name column. +await zeroBounce.sendEmailFinderFile({ + file: "", + domain_column: 1, + first_name_column: 2, + has_header_row: true, +}); +``` + - ##### Inbox Placement Tester ```javascript @@ -376,8 +416,9 @@ try { // The email domain for which to find the email format. // company_name: String // The company name for which to find the email format. -// first_name: String +// first_name: String or null (Optional) // The first name of the person whose email format is being searched. +// Left out, the API answers with the domain's or company's email format. // middle_name: String or null (Optional) // The middle name of the person whose email format is being searched. // last_name: String or null (Optional) diff --git a/src/warmup.js b/src/warmup.js index 65a3e05..ce8485f 100644 --- a/src/warmup.js +++ b/src/warmup.js @@ -180,8 +180,8 @@ export class ZeroBounceWarmup { * * `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. + * `managedSending` instead, and rejects `engagementRuleId`; a 200 means + * accepted, and the mailbox is provisioned afterwards. */ addSendingAccount({ fromAddress, @@ -204,6 +204,9 @@ export class ZeroBounceWarmup { if (emailContent != null || managedSending != null) { throw clientError("email_content and managed_sending are for zb_managed."); } + } else if (engagementRuleId != null) { + // zb_managed has no engagement_rule_id field: say so rather than drop it. + throw clientError("engagement_rule_id is for self_managed."); } if (ignoreConflict != null) body.ignore_conflict = Boolean(ignoreConflict); if (emailContent != null) { diff --git a/src/zero-bounce.js b/src/zero-bounce.js index ab1d2fb..345683c 100644 --- a/src/zero-bounce.js +++ b/src/zero-bounce.js @@ -341,7 +341,7 @@ export class ZeroBounceSDK { return createRequest({ requestType: "POST", path: "/validatebatch", - body: JSON.stringify(body), + jsonBody: body, apiBaseURL: this._api_base_url }); } @@ -623,7 +623,7 @@ export class ZeroBounceSDK { }); } - async _getStatusUtil(fileId, path, { bulkRoot = false } = {}) { + async _getStatusUtil(fileId, path, { bulkRoot = false, baseURL = null } = {}) { if (!this._initialized) { notInitialized(); return; @@ -642,7 +642,8 @@ export class ZeroBounceSDK { path, batch: true, bulkBase: bulkRoot ? "root" : "v2", - apiBaseURL: this._api_base_url + apiBaseURL: this._api_base_url, + baseURL }); } @@ -676,7 +677,7 @@ export class ZeroBounceSDK { return this._getStatusUtil(fileId, "/scoring/filestatus"); } - async _getFileUtil(fileId, path, scoring = false, getFileOptions = null, { bulkRoot = false } = {}) { + async _getFileUtil(fileId, path, scoring = false, getFileOptions = null, { bulkRoot = false, baseURL = null } = {}) { if (!this._initialized) { notInitialized(); return; @@ -704,7 +705,8 @@ export class ZeroBounceSDK { bulkBase: bulkRoot ? "root" : "v2", returnText: true, scoring, - apiBaseURL: this._api_base_url + apiBaseURL: this._api_base_url, + baseURL }); } @@ -724,7 +726,7 @@ export class ZeroBounceSDK { return this._getFileUtil(fileId, "/scoring/getfile", true, getFileOptions); } - async _deleteFileUtil(fileId, path, scoring = false, { bulkRoot = false } = {}) { + async _deleteFileUtil(fileId, path, scoring = false, { bulkRoot = false, baseURL = null } = {}) { if (!this._initialized) { notInitialized(); return; @@ -743,11 +745,12 @@ export class ZeroBounceSDK { batch: true, bulkBase: bulkRoot ? "root" : "v2", scoring, - apiBaseURL: this._api_base_url + apiBaseURL: this._api_base_url, + baseURL }); } - async _sendBulkRootMultipartFile(path, { file, fileName = null, fields = {} }) { + async _sendBulkRootMultipartFile(path, { file, fileName = null, fields = {}, baseURL = null }) { const body = new FormData(); for (const [key, value] of Object.entries(fields)) { if (value == null) continue; @@ -766,6 +769,7 @@ export class ZeroBounceSDK { batch: true, bulkBase: "root", apiBaseURL: this._api_base_url, + baseURL, }); } @@ -783,8 +787,16 @@ export class ZeroBounceSDK { return this._deleteFileUtil(fileId, "/scoring/deletefile", true); } - // BULK EMAIL FINDER (bulkapi.zerobounce.net/email-finder/*) + // BULK EMAIL FINDER (emailFinderBaseURL, by default + // bulkapi.zerobounce.net/email-finder) + /** + * Submit a file to the bulk email finder. POST /email-finder/sendfile + * + * The API needs a name to work from: either {@code first_name_column} or + * {@code full_name_column}. As with every optional column, {@code 0} and + * {@code false} mean "leave this field out", so neither counts as given. + * */ async sendEmailFinderFile({ file, domain_column, @@ -803,9 +815,13 @@ export class ZeroBounceSDK { } else if (isMissing(domain_column)) { parameterIsMissing("domain_column"); return; + } else if (!first_name_column && !full_name_column) { + parameterIsMissing("first_name_column", "Send first_name_column or full_name_column."); + return; } - return this._sendBulkRootMultipartFile("/email-finder/sendfile", { + return this._sendBulkRootMultipartFile("/sendfile", { file, + baseURL: this._email_finder_base_url, fields: { domain_column, first_name_column: optionalColumn(first_name_column), @@ -817,6 +833,12 @@ export class ZeroBounceSDK { }); } + /** + * Submit a stream or Blob to the bulk email finder. POST /email-finder/sendfile + * + * Takes {@code first_name_column} or {@code full_name_column}, as + * {@link sendEmailFinderFile} does. + * */ async sendEmailFinderFileStream(fileStreamOrBlob, fileName, { domain_column, first_name_column = false, @@ -837,10 +859,14 @@ export class ZeroBounceSDK { } else if (isMissing(domain_column)) { parameterIsMissing("domain_column"); return; + } else if (!first_name_column && !full_name_column) { + parameterIsMissing("first_name_column", "Send first_name_column or full_name_column."); + return; } - return this._sendBulkRootMultipartFile("/email-finder/sendfile", { + return this._sendBulkRootMultipartFile("/sendfile", { file: fileStreamOrBlob, fileName, + baseURL: this._email_finder_base_url, fields: { domain_column, first_name_column: optionalColumn(first_name_column), @@ -853,18 +879,28 @@ export class ZeroBounceSDK { } async getEmailFinderFileStatus(fileId) { - return this._getStatusUtil(fileId, "/email-finder/filestatus", { bulkRoot: true }); + return this._getStatusUtil(fileId, "/filestatus", { + bulkRoot: true, + baseURL: this._email_finder_base_url, + }); } async getEmailFinderFile(fileId) { - return this._getFileUtil(fileId, "/email-finder/getfile", false, null, { bulkRoot: true }); + return this._getFileUtil(fileId, "/getfile", false, null, { + bulkRoot: true, + baseURL: this._email_finder_base_url, + }); } async deleteEmailFinderFile(fileId) { - return this._deleteFileUtil(fileId, "/email-finder/deletefile", false, { bulkRoot: true }); + return this._deleteFileUtil(fileId, "/deletefile", false, { + bulkRoot: true, + baseURL: this._email_finder_base_url, + }); } - // BULK DOMAIN SEARCH (bulkapi.zerobounce.net/domain-search/*) + // BULK DOMAIN SEARCH (domainSearchBaseURL, by default + // bulkapi.zerobounce.net/domain-search) async sendDomainSearchFile({ file, @@ -881,8 +917,9 @@ export class ZeroBounceSDK { parameterIsMissing("domain_column"); return; } - return this._sendBulkRootMultipartFile("/domain-search/sendfile", { + return this._sendBulkRootMultipartFile("/sendfile", { file, + baseURL: this._domain_search_base_url, fields: { domain_column, has_header_row: Boolean(has_header_row) }, }); } @@ -904,23 +941,33 @@ export class ZeroBounceSDK { parameterIsMissing("domain_column"); return; } - return this._sendBulkRootMultipartFile("/domain-search/sendfile", { + return this._sendBulkRootMultipartFile("/sendfile", { file: fileStreamOrBlob, fileName, + baseURL: this._domain_search_base_url, fields: { domain_column, has_header_row: Boolean(has_header_row) }, }); } async getDomainSearchFileStatus(fileId) { - return this._getStatusUtil(fileId, "/domain-search/filestatus", { bulkRoot: true }); + return this._getStatusUtil(fileId, "/filestatus", { + bulkRoot: true, + baseURL: this._domain_search_base_url, + }); } async getDomainSearchFile(fileId) { - return this._getFileUtil(fileId, "/domain-search/getfile", false, null, { bulkRoot: true }); + return this._getFileUtil(fileId, "/getfile", false, null, { + bulkRoot: true, + baseURL: this._domain_search_base_url, + }); } async deleteDomainSearchFile(fileId) { - return this._deleteFileUtil(fileId, "/domain-search/deletefile", false, { bulkRoot: true }); + return this._deleteFileUtil(fileId, "/deletefile", false, { + bulkRoot: true, + baseURL: this._domain_search_base_url, + }); } // LIST EVALUATOR (bulkapi.zerobounce.net/v2/listevaluator/) @@ -1144,88 +1191,43 @@ export class ZeroBounceSDK { }); } - 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); + /** Validate a file in your Amazon S3 bucket. POST /validateS3file */ + async validateS3file(options) { + return this._bulkRootFileJob("/validateS3file", options || {}); } - async exportS3file({ file_id, export_bucket }) { + /** Score a file in your Amazon S3 bucket. POST /scoreS3file */ + async scoreS3file(options) { + return this._bulkRootFileJob("/scoreS3file", options || {}); + } + + /** + * Export a validated or scored file back to Amazon S3. POST /exportS3file + * + * Without export_bucket the output bucket from your ZeroBounce settings is + * used, as exportSFTPfile does with export_path. + * */ + async exportS3file({ file_id, export_bucket = null } = {}) { if (!this._initialized) { notInitialized(); return; - } else if (isMissing(file_id)) { + } + if (isMissing(file_id)) { parameterIsMissing("file_id"); return; - } else if (isMissing(export_bucket)) { + } + if (export_bucket != null && String(export_bucket).trim() === "") { parameterIsMissing("export_bucket"); return; } - return this._sendBulkRootFormPost("/exportS3file", { - file_id, - export_bucket, - }); + const fields = { file_id }; + if (export_bucket != null) fields.export_bucket = export_bucket; + return this._sendBulkRootFormPost("/exportS3file", fields); } /** - * 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. + * One of the eight 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". @@ -1233,6 +1235,9 @@ export class ZeroBounceSDK { * 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 set of fields is file_name, has_header_row, + * remove_duplicates and return_url, and nothing else goes out. + * * The documented success is {success, message} with no file ID. */ async _bulkRootFileJob(path, { file_name, has_header_row, remove_duplicates, return_url }) { @@ -1278,11 +1283,10 @@ export class ZeroBounceSDK { /** * 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. + * LIVE: file_name is required here too, although the docs mark it optional. * */ async validateSFTPfile(options) { - return this._bulkRootFileJob("/validateSFTPfile", options || {}, false); + return this._bulkRootFileJob("/validateSFTPfile", options || {}); } /** Score a file from your SFTP/FTP server. POST /scoreSFTPfile */ @@ -1514,7 +1518,7 @@ export class ZeroBounceSDK { /** * @param domain str - domain of the email address - * @param first_name str - first name + * @param first_name str or null - first name (optional) * @param middle_name str or null - middle name * @param last_name str or null - last name * */ @@ -1534,7 +1538,7 @@ export class ZeroBounceSDK { /** * @param company_name str - company name of the email address - * @param first_name str - first name + * @param first_name str or null - first name (optional) * @param middle_name str or null - middle name * @param last_name str or null - last name * */ @@ -1555,7 +1559,7 @@ export class ZeroBounceSDK { async _findEmail({ domain = null, company_name = null, - first_name, + first_name = null, middle_name = null, last_name = null }) { @@ -1566,15 +1570,14 @@ export class ZeroBounceSDK { parameterIsMissing("domain"); parameterIsMissing("company_name"); return; - } else if (isMissing(first_name)) { - parameterIsMissing("first_name"); - return; } + // first_name is optional: /guessformat answers a domain-only request with + // the domain's format, so an unset name is left out rather than rejected. const params = { api_key: this._api_key, - first_name: first_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; diff --git a/tests/offline-api.test.js b/tests/offline-api.test.js index 34e8d54..fabb386 100644 --- a/tests/offline-api.test.js +++ b/tests/offline-api.test.js @@ -312,8 +312,8 @@ describe("offline API", () => { ["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"], + ["sendEmailFinderFile", () => sdk.sendEmailFinderFile({ file, domain_column: 1, first_name_column: 2 }), "POST", "bulkapi.zerobounce.net", "/email-finder/sendfile"], + ["sendEmailFinderFileStream", () => sdk.sendEmailFinderFileStream(file, "emails.csv", { domain_column: 1, full_name_column: 2 }), "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"], @@ -514,11 +514,19 @@ describe("offline API", () => { 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 }); + await sdk.sendEmailFinderFile({ + file, + domain_column: 2, + has_header_row: false, + full_name_column: 3, + middle_name_column: 0, + }); request = fake.only(); expect(request.fields.has_header_row).toBe("false"); expect(request.fields.domain_column).toBe("2"); + expect(request.fields.full_name_column).toBe("3"); expect(request.fields.first_name_column).toBeUndefined(); + expect(request.fields.middle_name_column).toBeUndefined(); fake.records.length = 0; await sdk.sendDomainSearchFile({ file, domain_column: 1, has_header_row: false }); @@ -539,27 +547,12 @@ describe("offline API", () => { expect(request.fields.last_name_column).toBe("3"); fake.records.length = 0; - await sdk.sendEmailFinderFile({ file, domain_column: 0 }); + await sdk.sendEmailFinderFile({ file, domain_column: 0, first_name_column: 1 }); 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 () => { @@ -660,11 +653,23 @@ describe("offline API", () => { 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.findEmailFormatByDomain({ domain: "" })); await expectRejected(sdk.addFilter({ rule: "", target: "domain", value: "spam.com" })); await expectRejected(sdk.getFile("")); - await expectRejected(sdk.exportS3file({ file_id: "id" })); + await expectRejected(sdk.exportS3file({})); + await expectRejected(sdk.exportS3file({ file_id: "fid", export_bucket: " " })); await expectRejected(sdk.emailFinderS3file({ has_header_row: true })); + await expectRejected(sdk.validateS3file({ file_name: "f.csv", has_header_row: true })); + await expectRejected(sdk.scoreS3file({ file_name: "f.csv", has_header_row: true })); + await expectRejected( + sdk.sendEmailFinderFile({ file, domain_column: 1 }) + ); + await expectRejected( + sdk.sendEmailFinderFile({ file, domain_column: 1, first_name_column: 0 }) + ); + await expectRejected( + sdk.sendEmailFinderFileStream(file, "emails.csv", { domain_column: 1 }) + ); 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. @@ -917,6 +922,254 @@ describe("offline API", () => { await expect(csvSdk.getFile("file-1")).resolves.toBe("result.csv"); }); + it("sends validateBatch as JSON with the JSON content type", async () => { + const sdk = client("parity-key"); + const batch = [{ email_address: "a@example.com", ip_address: "1.1.1.1" }]; + const result = await sdk.validateBatch(batch, { timeout: 30, verifyPlus: true }); + + const request = fake.only(); + expect(request.method).toBe("POST"); + expect(request.url).toBe("https://api.zerobounce.net/v2/validatebatch"); + // The key travels in the JSON body, not the query string. + expect(request.query).toEqual({}); + expect(request.headers["Content-Type"]).toBe("application/json"); + expect(request.json).toEqual({ + api_key: "parity-key", + email_batch: batch, + // LIVE: timeout is a JSON string; the flags stay JSON booleans. + timeout: "30", + verify_plus: true, + }); + expect(result.email_batch[0].status).toBe("valid"); + }); + + it("finds an email without first_name and still sends it when given", async () => { + const sdk = client("parity-key"); + await sdk.findEmailByDomain({ domain: "example.com" }); + let request = fake.only(); + expect(request.method).toBe("GET"); + expect(request.url).toBe( + "https://api.zerobounce.net/v2/guessformat?api_key=parity-key&domain=example.com" + ); + expect(request.query.first_name).toBeUndefined(); + + fake.records.length = 0; + await sdk.findEmailByCompanyName({ company_name: "Ada Inc" }); + request = fake.only(); + expect(request.url).toBe( + "https://api.zerobounce.net/v2/guessformat?api_key=parity-key&company_name=Ada+Inc" + ); + expect(request.query.first_name).toBeUndefined(); + + fake.records.length = 0; + const result = await sdk.findEmailByDomain({ + domain: "example.com", + first_name: "Ada", + last_name: "Lovelace", + }); + request = fake.only(); + expect(request.query).toEqual({ + api_key: "parity-key", + first_name: "Ada", + last_name: "Lovelace", + domain: "example.com", + }); + expect(result.email).toBe("ada@example.com"); + + // The domain is still required, and the domain-search family is untouched. + await expectRejected(sdk.findEmailByDomain({ first_name: "Ada" })); + await expectRejected(sdk.findEmailByCompanyName({ first_name: "Ada" })); + }); + + it("requires a first_name_column or full_name_column for the bulk email finder", async () => { + const sdk = client("parity-key"); + const missing = await expectRejected(sdk.sendEmailFinderFile({ file, domain_column: 1 })); + expect(missing.message).toBe( + "ZeroBounce: first_name_column parameter is missing. Send first_name_column or full_name_column." + ); + // 0 and false mean "leave this column out", so neither counts as given. + await expectRejected( + sdk.sendEmailFinderFile({ file, domain_column: 1, first_name_column: 0 }) + ); + await expectRejected( + sdk.sendEmailFinderFile({ file, domain_column: 1, full_name_column: false }) + ); + await expectRejected( + sdk.sendEmailFinderFileStream(file, "emails.csv", { domain_column: 1 }) + ); + expect(fake.records).toHaveLength(0); + + await sdk.sendEmailFinderFile({ file, domain_column: 1, first_name_column: 2 }); + let request = fake.only(); + expect(request.method).toBe("POST"); + expect(request.url).toBe("https://bulkapi.zerobounce.net/email-finder/sendfile"); + expect(request.fields.first_name_column).toBe("2"); + expect(request.fields.api_key).toBe("parity-key"); + + fake.records.length = 0; + await sdk.sendEmailFinderFileStream(file, "emails.csv", { + domain_column: 1, + full_name_column: 3, + }); + request = fake.only(); + expect(request.url).toBe("https://bulkapi.zerobounce.net/email-finder/sendfile"); + expect(request.fields.full_name_column).toBe("3"); + expect(request.fields.first_name_column).toBeUndefined(); + }); + + it("honours the emailFinderBaseURL and domainSearchBaseURL overrides", async () => { + const sdk = new ZeroBounceSDK(); + sdk.init("parity-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL, { + emailFinderBaseURL: "https://finder.example.com/email-finder", + domainSearchBaseURL: "https://search.example.com/domain-search", + }); + + const calls = [ + [() => sdk.sendEmailFinderFile({ file, domain_column: 1, first_name_column: 2 }), "POST", "https://finder.example.com/email-finder/sendfile"], + [() => sdk.sendEmailFinderFileStream(file, "emails.csv", { domain_column: 1, first_name_column: 2 }), "POST", "https://finder.example.com/email-finder/sendfile"], + [() => sdk.getEmailFinderFileStatus("file-1"), "GET", "https://finder.example.com/email-finder/filestatus?api_key=parity-key&file_id=file-1"], + [() => sdk.getEmailFinderFile("file-1"), "GET", "https://finder.example.com/email-finder/getfile?api_key=parity-key&file_id=file-1"], + [() => sdk.deleteEmailFinderFile("file-1"), "GET", "https://finder.example.com/email-finder/deletefile?api_key=parity-key&file_id=file-1"], + [() => sdk.sendDomainSearchFile({ file, domain_column: 1 }), "POST", "https://search.example.com/domain-search/sendfile"], + [() => sdk.sendDomainSearchFileStream(file, "emails.csv", { domain_column: 1 }), "POST", "https://search.example.com/domain-search/sendfile"], + [() => sdk.getDomainSearchFileStatus("file-1"), "GET", "https://search.example.com/domain-search/filestatus?api_key=parity-key&file_id=file-1"], + [() => sdk.getDomainSearchFile("file-1"), "GET", "https://search.example.com/domain-search/getfile?api_key=parity-key&file_id=file-1"], + [() => sdk.deleteDomainSearchFile("file-1"), "GET", "https://search.example.com/domain-search/deletefile?api_key=parity-key&file_id=file-1"], + ]; + for (const [run, method, url] of calls) { + fake.records.length = 0; + await run(); + expect({ method: fake.only().method, url: fake.only().url }).toEqual({ method, url }); + expect(fake.only().apiKey).toBe("parity-key"); + } + + // An override is still checked, and a default client keeps the bulk host. + const bare = new ZeroBounceSDK(); + expect(() => + bare.init("parity-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL, { + emailFinderBaseURL: "http://finder.example.com", + }) + ).toThrow(/emailFinderBaseURL must be an https/); + expect(() => + bare.init("parity-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL, { + domainSearchBaseURL: "http://search.example.com", + }) + ).toThrow(/domainSearchBaseURL must be an https/); + + fake.records.length = 0; + await client("parity-key").getEmailFinderFileStatus("file-1"); + expect(fake.only().url).toBe( + "https://bulkapi.zerobounce.net/email-finder/filestatus?api_key=parity-key&file_id=file-1" + ); + }); + + it("sends exactly the documented fields for the S3 and SFTP file jobs", async () => { + const sdk = client("parity-key"); + const jobs = [ + ["validateS3file", (options) => sdk.validateS3file(options), "/validateS3file"], + ["scoreS3file", (options) => sdk.scoreS3file(options), "/scoreS3file"], + ["emailFinderS3file", (options) => sdk.emailFinderS3file(options), "/emailFinderS3file"], + ["domainSearchS3file", (options) => sdk.domainSearchS3file(options), "/domainSearchS3file"], + ["validateSFTPfile", (options) => sdk.validateSFTPfile(options), "/validateSFTPfile"], + ["scoreSFTPfile", (options) => sdk.scoreSFTPfile(options), "/scoreSFTPfile"], + ["emailFinderSFTPfile", (options) => sdk.emailFinderSFTPfile(options), "/emailFinderSFTPfile"], + ["domainSearchSFTPfile", (options) => sdk.domainSearchSFTPfile(options), "/domainSearchSFTPfile"], + ]; + + for (const [name, run, path] of jobs) { + fake.records.length = 0; + const result = await run({ + file_name: "emails/list.csv", + has_header_row: true, + remove_duplicates: false, + return_url: "https://example.com/done", + // Undocumented extras a caller may still pass: none may go out. + email_address_column: 1, + first_name_column: 2, + last_name_column: 3, + gender_column: 4, + ip_address_column: 5, + remove_duplicate: true, + allowPhase2: true, + }); + const request = fake.only(); + expect({ name, method: request.method, url: request.url }).toEqual({ + name, + method: "POST", + url: `https://bulkapi.zerobounce.net${path}`, + }); + expect(request.query).toEqual({}); + expect(request.body).toEqual({ + api_key: "parity-key", + file_name: "emails/list.csv", + has_header_row: "true", + remove_duplicates: "false", + return_url: "https://example.com/done", + }); + expect(result.success).toBe(true); + } + + // LIVE: file_name, has_header_row and return_url are all required. + fake.records.length = 0; + for (const [, run] of jobs) { + await expectRejected(run({ has_header_row: true, return_url: "https://example.com/done" })); + await expectRejected(run({ file_name: "f.csv", return_url: "https://example.com/done" })); + const noUrl = await expectRejected(run({ file_name: "f.csv", has_header_row: true })); + expect(noUrl.message).toBe( + "ZeroBounce: return_url parameter is missing. The API rejects a job without it." + ); + } + expect(fake.records).toHaveLength(0); + }); + + it("makes export_bucket optional on exportS3file, as export_path is on exportSFTPfile", async () => { + const sdk = client("parity-key"); + await sdk.exportS3file({ file_id: "job-1" }); + let request = fake.only(); + expect(request.method).toBe("POST"); + expect(request.url).toBe("https://bulkapi.zerobounce.net/exportS3file"); + expect(request.body).toEqual({ api_key: "parity-key", file_id: "job-1" }); + + fake.records.length = 0; + const result = await sdk.exportS3file({ file_id: "job-1", export_bucket: "my-bucket" }); + request = fake.only(); + expect(request.body).toEqual({ + api_key: "parity-key", + file_id: "job-1", + export_bucket: "my-bucket", + }); + expect(result.success).toBe(true); + + fake.records.length = 0; + await expectRejected(sdk.exportS3file({})); + await expectRejected(sdk.exportS3file({ file_id: "job-1", export_bucket: " " })); + expect(fake.records).toHaveLength(0); + }); + + it("reports the API's own error body for a rejected S3 job", async () => { + const sdk = client("parity-key"); + jest.spyOn(global, "fetch").mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 400, + headers: { get: () => "application/json" }, + text: () => + Promise.resolve( + JSON.stringify({ error_message: "Provided return URL is not permitted: None" }) + ), + }) + ); + const error = await expectRejected( + sdk.validateS3file({ + file_name: "emails/list.csv", + has_header_row: true, + return_url: "https://example.com/done", + }) + ); + expect(error.message).toBe("Provided return URL is not permitted: None"); + expect(error.statusCode).toBe(400); + }); + 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`; diff --git a/tests/ts-consumer.ts b/tests/ts-consumer.ts index fc1da64..26c1f3c 100644 --- a/tests/ts-consumer.ts +++ b/tests/ts-consumer.ts @@ -33,6 +33,13 @@ import ZeroBounceSDK, { type EmailFinderResponse, type DomainFormatResponse, type SendScoringFileResponse, + type SendEmailFinderFileOptions, + type SendEmailFinderFileStreamOptions, + type BulkRootFileJobOptions, + type ValidateS3FileOptions, + type ScoreS3FileOptions, + type ExportS3FileOptions, + type S3BulkJobResponse, } from "@zerobounce/zero-bounce-sdk"; // --- Class and static members --- @@ -77,10 +84,42 @@ const findByCompanyOpts: FindEmailByCompanyNameOptions = { first_name: "Jane", }; +// first_name is optional on both: /guessformat answers with the format. +const findByDomainNoName: FindEmailByDomainOptions = { domain: "example.com" }; +const findByCompanyNoName: FindEmailByCompanyNameOptions = { company_name: "Acme Inc" }; + +// A bulk email finder file takes first_name_column or full_name_column. +const finderFileOpts: SendEmailFinderFileStreamOptions = { + domain_column: 1, + first_name_column: 2, +}; +const finderFullNameOpts: SendEmailFinderFileStreamOptions = { + domain_column: 1, + full_name_column: 2, +}; + +// The S3 / SFTP jobs all take the same documented set of fields. +const s3JobOpts: BulkRootFileJobOptions = { + file_name: "emails/list.csv", + has_header_row: true, + remove_duplicates: false, + return_url: "https://example.com/done", +}; +const validateS3Opts: ValidateS3FileOptions = s3JobOpts; +const scoreS3Opts: ScoreS3FileOptions = s3JobOpts; +const exportS3Opts: ExportS3FileOptions = { file_id: "job-1" }; + // --- Method signatures (types only; we don't call the API) --- const zb: ZeroBounceSDK = new ZeroBounceSDK(); zb.init("fake-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL); +// Every overridable base URL, including the finder and domain-search hosts. +zb.init("fake-key", ZeroBounceSDK.ApiURL.DEFAULT_API_URL, { + listEvaluatorBaseURL: "https://bulkapi.zerobounce.net/v2/listevaluator", + inboxPlacementBaseURL: "https://bulkapi.zerobounce.net/api/ipt", + emailFinderBaseURL: "https://bulkapi.zerobounce.net/email-finder", + domainSearchBaseURL: "https://bulkapi.zerobounce.net/domain-search", +}); // Return types are Promise<...>; we only check they're assignable const creditsPromise: Promise = zb.getCredits(); @@ -208,6 +247,23 @@ zb.sendFileStream(blob, "emails.csv", sendFileStreamOpts); zb.sendScoringFileStream(blob, "scoring.csv"); zb.sendScoringFileStream(blob, "scoring.csv", sendScoringStreamOpts); +const finderFileSendOpts: SendEmailFinderFileOptions = { + file: blob as unknown as File, + domain_column: 1, + full_name_column: 2, +}; +zb.sendEmailFinderFile(finderFileSendOpts); +zb.sendEmailFinderFileStream(blob, "finder.csv", finderFileOpts); + +const validateS3Promise: Promise = + zb.validateS3file(validateS3Opts); +const scoreS3Promise: Promise = zb.scoreS3file(scoreS3Opts); +const exportS3Promise: Promise = zb.exportS3file(exportS3Opts); +const validateSFTPPromise: Promise = + zb.validateSFTPfile(s3JobOpts); +zb.findEmailByDomain(findByDomainNoName); +zb.findEmailByCompanyName(findByCompanyNoName); + // Ensure types are used (avoid unused variable warnings in strict mode) function assertTypes(): void { void creditsPromise; @@ -250,6 +306,12 @@ function assertTypes(): void { void statusValid; void statusInvalid; void subStatus; + void finderFullNameOpts; + void finderFileSendOpts; + void validateS3Promise; + void scoreS3Promise; + void exportS3Promise; + void validateSFTPPromise; } assertTypes(); diff --git a/tests/warmup-offline.test.js b/tests/warmup-offline.test.js index 04ae1f9..ac3b71b 100644 --- a/tests/warmup-offline.test.js +++ b/tests/warmup-offline.test.js @@ -199,6 +199,17 @@ describe("Warmup API client", () => { managed_sending: { timezone_offset: 2, warmup_start_date: "2026-02-01" }, }); + // A zb_managed account takes no engagement rule: say so rather than drop it. + fake.records.length = 0; + expect(() => + c.addSendingAccount({ + fromAddress: "sender@example.com", + onboardingModel: "zb_managed", + engagementRuleId: 2, + }) + ).toThrow("ZeroBounce: engagement_rule_id is for self_managed."); + expect(fake.records).toHaveLength(0); + fake.records.length = 0; await c.updateSendingAccount({ fromAddress: "sender@example.com", @@ -224,6 +235,8 @@ describe("Warmup API client", () => { () => 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 }), + // zb_managed has no engagement_rule_id: it is rejected, not dropped. + () => c.addSendingAccount({ fromAddress: "a@example.com", onboardingModel: "zb_managed", engagementRuleId: 1 }), () => 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 }), diff --git a/tests/zero-bounce.test.js b/tests/zero-bounce.test.js index bfcf388..c18ebe6 100644 --- a/tests/zero-bounce.test.js +++ b/tests/zero-bounce.test.js @@ -1186,7 +1186,7 @@ describe("ZeroBounceSDK", () => { const fetch = jest.spyOn(global, "fetch").mockImplementationOnce(() => jsonOk({ success: true, file_id: "fid" }) ); - await zeroBounceSDK.sendEmailFinderFile({ file, domain_column: 1 }); + await zeroBounceSDK.sendEmailFinderFile({ file, domain_column: 1, first_name_column: 2 }); expect(String(fetch.mock.calls[0][0])).toContain( "https://bulkapi.zerobounce.net/email-finder/sendfile" ); @@ -1270,10 +1270,19 @@ describe("ZeroBounceSDK", () => { file_name: "emails/list.csv", has_header_row: true, remove_duplicates: true, + return_url: "https://example.com/done", }); expect(String(fetch.mock.calls[0][0])).toBe( "https://bulkapi.zerobounce.net/validateS3file" ); + const body = fetch.mock.calls[0][1].body; + expect(Object.fromEntries(body.entries())).toEqual({ + api_key: "valid-api-key", + file_name: "emails/list.csv", + has_header_row: "true", + remove_duplicates: "true", + return_url: "https://example.com/done", + }); }); it("exportS3file posts file_id and export_bucket", async () => { diff --git a/zeroBounceSDK.d.ts b/zeroBounceSDK.d.ts index acd6583..e77dade 100644 --- a/zeroBounceSDK.d.ts +++ b/zeroBounceSDK.d.ts @@ -361,7 +361,12 @@ export interface SendScoringFileStreamOptions { remove_duplicate?: boolean; } -/** Options for bulk email finder sendfile. */ +/** + * Options for bulk email finder sendfile. + * + * Send `first_name_column` or `full_name_column`; `0` and `false` mean + * "leave this column out" and so do not count as given. + */ export interface SendEmailFinderFileOptions { file: File; domain_column: number; @@ -372,6 +377,7 @@ export interface SendEmailFinderFileOptions { has_header_row?: boolean; } +/** As SendEmailFinderFileOptions, for a stream or Blob. */ export interface SendEmailFinderFileStreamOptions { domain_column: number; first_name_column?: number | false; @@ -446,33 +452,16 @@ export interface ListFiltersOptions { method?: "GET" | "POST"; } -export interface ValidateS3FileOptions { - file_name: string; - has_header_row?: boolean; - /** Postman field name for duplicate removal. */ - remove_duplicates?: boolean; - /** Alternate field name used by some bulk endpoints. */ - remove_duplicate?: boolean; - email_address_column?: number; - return_url?: string | false; - first_name_column?: number | false; - last_name_column?: number | false; - gender_column?: number | false; - ip_address_column?: number | false; - allowPhase2?: boolean | null; -} +/** Options for validateS3file: the shared S3 / SFTP job fields. */ +export interface ValidateS3FileOptions extends BulkRootFileJobOptions {} -export interface ScoreS3FileOptions { - file_name: string; - has_header_row?: boolean; - remove_duplicates?: boolean; - remove_duplicate?: boolean; - email_address_column?: number; -} +/** Options for scoreS3file: the shared S3 / SFTP job fields. */ +export interface ScoreS3FileOptions extends BulkRootFileJobOptions {} export interface ExportS3FileOptions { file_id: string; - export_bucket: string; + /** Defaults to the export bucket in your ZeroBounce settings. */ + export_bucket?: string; } /** Single entry from GET /listS3files. */ @@ -523,13 +512,19 @@ export interface ListInboxPlacementTestsOptions { pageSize?: number; } -/** Shared options for the documented S3 and SFTP file jobs. */ +/** + * Shared options for the documented S3 and SFTP file jobs. + * + * The live API requires `file_name` and `return_url` on every one of them, + * including validateSFTPfile, although the docs mark them optional. + */ export interface BulkRootFileJobOptions { - /** Full path as returned by listS3files. Optional only for validateSFTPfile. */ - file_name?: string; + /** Full path as returned by listS3files. */ + file_name: string; has_header_row: boolean; remove_duplicates?: boolean; - return_url?: string; + /** The API rejects a job without it. */ + return_url: string; } /** Options for exportSFTPfile. */ @@ -542,7 +537,8 @@ export interface ExportSFTPFileOptions { /** Options for findEmailByDomain / Email Finder. */ export interface FindEmailByDomainOptions { domain: string; - first_name: string; + /** Optional: without it the API answers with the domain's format. */ + first_name?: string | null; middle_name?: string | null; last_name?: string | null; } @@ -550,7 +546,8 @@ export interface FindEmailByDomainOptions { /** Options for findEmailByCompanyName / Email Finder. */ export interface FindEmailByCompanyNameOptions { company_name: string; - first_name: string; + /** Optional: without it the API answers with the company's format. */ + first_name?: string | null; middle_name?: string | null; last_name?: string | null; } @@ -592,6 +589,10 @@ export class ZeroBounceSDK { listEvaluatorBaseURL?: string; /** Defaults to https://bulkapi.zerobounce.net/api/ipt */ inboxPlacementBaseURL?: string; + /** Defaults to https://bulkapi.zerobounce.net/email-finder */ + emailFinderBaseURL?: string; + /** Defaults to https://bulkapi.zerobounce.net/domain-search */ + domainSearchBaseURL?: string; } | null ): void; @@ -832,9 +833,9 @@ export class ZeroBounceSDK { options: BulkRootFileJobOptions ): Promise; - /** file_name is optional here; omit it to use the configured input bucket. */ + /** LIVE: file_name is required here too, although the docs mark it optional. */ validateSFTPfile( - options: BulkRootFileJobOptions | { has_header_row: boolean; remove_duplicates?: boolean; return_url?: string } + options: BulkRootFileJobOptions ): Promise; scoreSFTPfile( @@ -957,7 +958,7 @@ export class ZeroBounceWarmup { addSendingAccount(options: { fromAddress: string; onboardingModel: WarmupOnboardingModel; - /** 0 to 4. Required for self_managed. */ + /** 0 to 4. Required for self_managed, and rejected for zb_managed. */ engagementRuleId?: number | null; ignoreConflict?: boolean | null; emailContent?: WarmupEmailContent | null;