From 3c1de28874934bca602218c44d6f33a59b584e76 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:25:05 +0400 Subject: [PATCH] fix: S3 bulk jobs drifted from the shared required-field helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateS3File and scoreS3File each built their own form instead of going through bulkFileJobFields, and both had drifted from the four jobs that do. The live API requires return_url on every one of these endpoints — POST /validateS3file without it answers 400 {"error_message":"Provided return URL is not permitted: None"} — but scoreS3File had no returnUrl field at all, so a required parameter was unreachable from the public API and every call failed, and validateS3File sent it only when it happened to be non-empty. Both now build their shared fields through the helper and add only the extra fields they alone take, so the next change to what the API requires is made in one place. They also stop sending the undocumented singular remove_duplicate alongside remove_duplicates: that field belongs to the bulk /v2/sendfile upload, where SendFileOptions still carries it. exportS3File hard-rejected an empty export_bucket although the docs make it optional and exportSFTPFile already treats export_path that way; it now takes an optional bucket and omits the field when unset. sendEmailFinderFile validated only domain_column, so an upload with no name column reached the API and was refused there; one of first_name_column and full_name_column is now required before the request. bulkApiRootUrl, bulkApiBaseUrl and bulkApiScoringBaseUrl were const with no setter, so operations 11-27 and 30-40 could only ever reach production and none of them could be pointed at a test host. They now have setters in the same style as setListEvaluatorBaseUrl and setInboxPlacementBaseUrl. ZeroBounceWarmup::initialize rejected a bad key or a non-https base URL by leaving the client un-ready with nothing to read back, and warmupReady then guessed at the reason. initialize now records why it refused; isReady() and getInitializeError() expose it, and the error callback repeats that reason rather than a guess. The https requirement stands: the Warmup key travels in a request header, so a plain-http base URL would put it on the wire in clear. Also corrects a stale comment claiming the Inbox Placement Tester key goes on the query string; the code 30 lines below has used a Bearer header for a while. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 8 +- CHANGELOG.md | 10 +- README.md | 45 ++++- documentation.md | 51 ++++- include/ZeroBounce/Priority3Options.h | 45 +++-- include/ZeroBounce/ZeroBounce.h | 52 ++++- include/ZeroBounce/ZeroBounceWarmup.h | 14 ++ src/Priority3.cpp | 101 +++++----- src/ZeroBounceWarmup.cpp | 74 +++++--- test/OfflineApiTest.cpp | 261 +++++++++++++++++++++++++- test/OfflineWarmupTest.cpp | 35 ++++ test/ZeroBounceTest.cpp | 2 + 12 files changed, 580 insertions(+), 118 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 310fdc4..f990404 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Guidance for AI coding agents working in this repository. - `include/ZeroBounce/`: public headers. `ZeroBounce.h` is the client. - `src/ZeroBounce.cpp`: validation, bulk file, credits, activity, scoring. -- `src/Priority3.cpp`: bulk email finder, domain search, list evaluator, filters, the S3 / SFTP bulk jobs, and the Inbox Placement Tester. The six S3 / SFTP file jobs build their form through `bulkFileJobFields`; two shapes differ from the rest and are easy to get wrong — `validateSFTPFile`'s `fileName` may be unset, and `exportSFTPFile` sends `export_path`, not `export_bucket`. +- `src/Priority3.cpp`: bulk email finder, domain search, list evaluator, filters, the S3 / SFTP bulk jobs, and the Inbox Placement Tester. All six S3 / SFTP file jobs build their shared form fields through `bulkFileJobFields`; `validateS3File` and `scoreS3File` then append the extra fields only they take. Do not re-implement those shared fields per job: that duplication is how `return_url` came to be optional on two of them. `exportSFTPFile` sends `export_path`, not the `export_bucket` `exportS3File` sends, and both are optional. - `src/HttpError.cpp`: turns HTTP failures, HTML pages, and empty or non-JSON bodies into a short `ZBErrorResponse`. - `test/ZeroBounceTest.cpp`: existing GoogleTest cases with a scripted response. - `test/OfflineApiTest.cpp`: offline suite. A fake `BaseRequestHandler` records every request and answers without the network. @@ -44,15 +44,15 @@ log, a fixture or a message. ## 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. -- **The list evaluator and the Inbox Placement Tester have their own base URLs** (`listEvaluatorBaseUrl`, `inboxPlacementBaseUrl`, settable via `setListEvaluatorBaseUrl` / `setInboxPlacementBaseUrl`). They are not built from the bulk base, and `sendEvaluatedFile` stays on it. -- **Where the live API and the docs disagree, follow the API and say so in a comment at the call site.** The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer`, not `api_key`; its listing is `GET /tests`, not `/test`; `send-evaluated-file` takes `file_id` as a form field, not a file upload; the S3 and SFTP jobs require `return_url` and `file_name`, `validateSFTPFile` included; the Warmup `check-domain-dmarc` takes an email address, not a domain; and a failed SMTP login in `test-smtp-configuration` comes back as an HTTP 500. +- **The list evaluator and the Inbox Placement Tester have their own base URLs** (`listEvaluatorBaseUrl`, `inboxPlacementBaseUrl`, settable via `setListEvaluatorBaseUrl` / `setInboxPlacementBaseUrl`). They are not built from the bulk base, and `sendEvaluatedFile` stays on it. The three bulk hosts have setters too — `setBulkApiRootUrl`, `setBulkApiBaseUrl`, `setBulkApiScoringBaseUrl` — so every bulk endpoint can be pointed at a test host. Keep every base URL settable; a hardcoded `const` one makes its endpoints untestable. +- **Where the live API and the docs disagree, follow the API and say so in a comment at the call site.** The ones found so far: `validatebatch` takes `timeout` as a JSON *string*; the Inbox Placement Tester authenticates with `Authorization: Bearer`, not `api_key`; its listing is `GET /tests`, not `/test`; `send-evaluated-file` takes `file_id` as a form field, not a file upload; all six S3 and SFTP jobs require `return_url` and `file_name`, `validateS3File`, `scoreS3File` and `validateSFTPFile` included (`POST /validateS3file` without one answers 400 `Provided return URL is not permitted: None`); the Warmup `check-domain-dmarc` takes an email address, not a domain; and a failed SMTP login in `test-smtp-configuration` comes back as an HTTP 500. - **The Inbox Placement Tester takes the API key as an `Authorization: Bearer` header and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`. It and the Warmup client are the only callers of `BaseRequestHandler::Put` and `Delete`, which have default implementations so existing subclasses keep compiling. The Warmup API's `DELETE /custom-engagement` carries a JSON body, which is why there is a `Delete` overload that takes one. - **`{"success": false}` on an HTTP 200 is a failure.** The API answers an unknown bulk file id that way. `zbReadJsonResponse` turns it into an error callback. - **`src/ZeroBounceWarmup.cpp` is a separate client for the Warmup API**, a different product with its own host and its own team-scoped key. It is not part of `ZeroBounce`, and it owns a `unique_ptr`, so like `ZeroBounce` it is not copyable. - **One `ZeroBounce` per API key.** `getInstance()` is a process-wide singleton. Do not reuse it across users or requests. Construct a separate object per key. The offline concurrency test does this. - **Column indexes are 1-based.** `SendFileOptions` name, gender, and IP columns default to `0`, and `0` means the field is left out. `ValidateS3FileOptions::emailAddressColumn` is `std::optional`: when it is set, `0` is sent. `sendFile`'s email column is required and is sent as given, including `0`. -- **Optional parameters are omitted when unset.** `timeout`, `activityData`, `verifyPlus`, `allowPhase2`, `downloadType`, and `hasHeaderRow` (`std::optional`) are not sent until the caller sets them. Explicit `false` is sent as `false`. +- **Optional parameters are omitted when unset.** `timeout`, `activityData`, `verifyPlus`, `allowPhase2`, `downloadType`, and `hasHeaderRow` (`std::optional`) are not sent until the caller sets them. Explicit `false` is sent as `false`. The S3 / SFTP jobs are the exception: their `hasHeaderRow` is a plain `bool` defaulting to `true` and is always sent, so all six agree on the wire. - **Booleans on multipart must be the strings `true` and `false`.** `cpr::Part` has no bool constructor; a bool becomes `1` or `0` and the API will not treat that as a boolean. Pass `std::string("true")` or `std::string("false")`. - **`allow_phase_2` is validation `sendFile` only**, and only when `allowPhase2IsSet` is true. Scoring `sendFile` ignores it. `activity_data` on get-file is validation only. - **`validateBatch` uses `api.zerobounce.net`**, not `bulkapi.zerobounce.net`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b9d86..14d2936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,18 +12,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `validate` and `validateBatch` take `ZBValidateOptions`: `timeout`, `activityData`, `verifyPlus`, and `usePost` (form-encoded `POST /validate`). - `ZBValidateResponse` reads `active_in_days` and `active_first_seen`. - `scoreEmail` (`GET /v2/scoring`) and `scoreEmailPost` (form-encoded `POST /v2/scoring`). -- Bulk email finder (`sendEmailFinderFile`, `emailFinderFileStatus`, `emailFinderGetFile`, `emailFinderDeleteFile`). +- Bulk email finder (`sendEmailFinderFile`, `emailFinderFileStatus`, `emailFinderGetFile`, `emailFinderDeleteFile`). The upload requires `domain_column` and one of `first_name_column` and `full_name_column`; neither being set is rejected before a request is sent. - Bulk domain search (`sendDomainSearchFile`, `domainSearchFileStatus`, `domainSearchGetFile`, `domainSearchDeleteFile`). - List evaluator (`sendListEvaluatorFile`, `getListEvaluatorResult`, `sendEvaluatedFile`). - Filters (`addFilter`, `listFilters`, `deleteFilter`). -- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3files`, `emailFinderS3File`, `domainSearchS3File`). +- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3files`, `emailFinderS3File`, `domainSearchS3File`). All six S3 and SFTP/FTP jobs build their form from the same fields, so `validateS3File` and `scoreS3File` require `fileName` and `returnUrl` like the rest; `ValidateS3FileOptions` and `ScoreS3FileOptions` carry `returnUrl` and a plain `bool hasHeaderRow`. `exportS3File` takes an `std::optional` bucket: leave it unset to use the export bucket from your ZeroBounce settings. - SFTP/FTP bulk: `validateSFTPFile`, `scoreSFTPFile`, `emailFinderSFTPFile`, `domainSearchSFTPFile`, and `exportSFTPFile`, all taking `BulkFileJobOptions`. The server, path and protocol come from your ZeroBounce settings. `validateSFTPFile` is the one job whose `fileName` may be left unset; `exportSFTPFile` takes `export_path` rather than the `export_bucket` `exportS3File` sends. - `ZBValidateOptions` accepts `domainInfo`, `greylistProcessing`, and `callbackUrl`. `callbackUrl` needs `greylistProcessing = true` and is rejected without it, and when blank, before a request is sent. - `ZBValidateResponse` carries `domainWebsiteExists`, `domainRegistrantCompanyName`, and `message`. `message` is read from the API's `Message` key, returned while greylist processing is still running. - Inbox Placement Tester on `https://bulkapi.zerobounce.net/api/ipt`: `getInboxPlacementProviders`, `getInboxPlacementTestTypes`, `createInboxPlacementTest`, `getInboxPlacementTest`, `listInboxPlacementTests`, `deleteInboxPlacementTest`, `getInboxPlacementWebhook`, `setInboxPlacementWebhook`. -- `setListEvaluatorBaseUrl` and `setInboxPlacementBaseUrl` override those base URLs. +- `setListEvaluatorBaseUrl`, `setInboxPlacementBaseUrl`, `setBulkApiRootUrl`, `setBulkApiBaseUrl`, and `setBulkApiScoringBaseUrl` override those base URLs. The three bulk hosts were fixed to production and had no setter, so no bulk endpoint could be pointed at a test host. - `BaseRequestHandler` gains `Put` and `Delete`, used by the Inbox Placement Tester and the Warmup API, plus a `Delete` overload that carries a body, which the Warmup `DELETE /custom-engagement` needs. All have default implementations, so existing subclasses keep compiling. -- `ZeroBounceWarmup`, a client for the Warmup API. It is a separate product with its own host (`https://warmup-api.zerobounce.net`), its own team-scoped key sent in an `API-Key` header, and all 26 documented endpoints, including the seed-list and statistics CSV downloads. +- `ZeroBounceWarmup`, a client for the Warmup API. It is a separate product with its own host (`https://warmup-api.zerobounce.net`), its own team-scoped key sent in an `API-Key` header, and all 26 documented endpoints, including the seed-list and statistics CSV downloads. `isReady()` and `getInitializeError()` report whether `initialize` accepted the key and base URL, and why it did not, instead of leaving the client quietly unusable until the first call. - `ZBErrorResponse::statusCode` carries the HTTP status of a failed response, so callers can tell a Cloudflare 403 from an API 400/404 and a Warmup 429. - An opt-in live API suite, built only with `-DZEROBOUNCE_BUILD_LIVE_TESTS=ON`. A normal build and CI never compile it, and `ctest` never runs it; it needs keys in the environment, and only spends credits or changes the account behind `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`. @@ -35,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `validateBatch` sends `timeout` as a JSON string. Sent as a number, the live API rejects the whole batch with an account-level error. - The Inbox Placement Tester authenticates with an `Authorization: Bearer` header instead of an `api_key` parameter, so the key no longer reaches a query string that gets logged, and `listInboxPlacementTests` calls `GET /tests`. `GET /test` answers `Method "GET" not allowed`. - `sendEvaluatedFile(fileId)` posts the list evaluator's `file_id` as a form field. The endpoint rejects a file upload with "file_id: This field is required", so it no longer takes a file path. -- The S3 and SFTP/FTP jobs require `file_name` and `return_url`, `validateSFTPFile` included, although the docs list `file_name` as optional for that one. The API answers "This field is required" otherwise. +- All six S3 and SFTP/FTP jobs require `file_name` and `return_url`, `validateS3File`, `scoreS3File` and `validateSFTPFile` included, although the docs list them as optional. Without a return URL the API answers `Provided return URL is not permitted: None`, and without a file name, "This field is required". `validateS3File` and `scoreS3File` no longer send the undocumented singular `remove_duplicate` alongside `remove_duplicates`; `remove_duplicate` belongs to the bulk `/v2/sendfile` upload, where `SendFileOptions` still carries it. - Query values, including the API key, email, and file id, are percent-encoded. ### Fixed diff --git a/README.md b/README.md index c53ea81..78ab7eb 100644 --- a/README.md +++ b/README.md @@ -447,8 +447,30 @@ and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs. auto ok = [](ZBSendFileResponse response) { std::cout << response.toString() << std::endl; }; auto failed = [](ZBErrorResponse error) { std::cout << error.toString() << std::endl; }; +// fileName and returnUrl are required on every one of these six jobs. The docs +// mark them optional; the live API rejects a job without a return URL with +// "Provided return URL is not permitted: None". + // Amazon S3. fileName is the full path, as returned by listS3files. -// fileName and returnUrl are required on every one of these jobs. +zb->listS3files(std::string("my-bucket"), std::string("leads"), + [](std::vector files) { + for (const auto& file : files) { std::cout << file.name << std::endl; } + }, + failed +); + +ValidateS3FileOptions validate; +validate.fileName = "lists/leads.csv"; +validate.returnUrl = "https://example.com/zerobounce-done"; +validate.hasHeaderRow = true; +validate.emailAddressColumn = 1; // 1-based; set it to send it, 0 included +zb->validateS3File(validate, ok, failed); + +ScoreS3FileOptions score; +score.fileName = "lists/leads.csv"; +score.returnUrl = "https://example.com/zerobounce-done"; +zb->scoreS3File(score, ok, failed); + BulkFileJobOptions finder; finder.fileName = "lists/leads.csv"; finder.hasHeaderRow = true; @@ -459,6 +481,11 @@ BulkFileJobOptions domains = finder; domains.removeDuplicates = true; zb->domainSearchS3File(domains, ok, failed); +// export_bucket is optional: std::nullopt uses the export bucket from your +// ZeroBounce settings. +zb->exportS3File("", std::nullopt, ok, failed); +zb->exportS3File("", std::string("my-export-bucket"), ok, failed); + // SFTP/FTP. The server, path and protocol come from your ZeroBounce settings. // The docs list fileName as optional for validateSFTPFile, but the live API // requires it there too. @@ -485,6 +512,12 @@ The Warmup API is a separate product. It has its own host, its own team-scoped k ZeroBounceWarmup warmup; warmup.initialize(""); +// initialize rejects a padded key or a base URL that is not https, and says so +// rather than leaving the client quietly unusable. +if (!warmup.isReady()) { + std::cout << warmup.getInitializeError() << std::endl; +} + warmup.listSendingAccounts(onSuccess, onError); warmup.getProgressToday(onSuccess, onError); warmup.downloadSeeds( @@ -541,10 +574,16 @@ Run it by hand before every release: the offline suite proves the SDK sends what ## Per-API base URLs The list evaluator and the Inbox Placement Tester have their own base URLs, -separate from the main and bulk hosts. +separate from the main and bulk hosts. The three bulk hosts have setters of +their own, so every bulk endpoint can be pointed at a test host. ```cpp zb->setListEvaluatorBaseUrl("https://bulkapi.zerobounce.net/v2/listevaluator"); zb->setInboxPlacementBaseUrl("https://bulkapi.zerobounce.net/api/ipt"); -``` +// The bulk root carries the S3 and SFTP jobs, the bulk email finder and the +// bulk domain search; the other two carry bulk validation and bulk AI scoring. +zb->setBulkApiRootUrl("https://bulkapi.zerobounce.net"); +zb->setBulkApiBaseUrl("https://bulkapi.zerobounce.net/v2"); +zb->setBulkApiScoringBaseUrl("https://bulkapi.zerobounce.net/v2/scoring"); +``` diff --git a/documentation.md b/documentation.md index eb076ba..70b8372 100644 --- a/documentation.md +++ b/documentation.md @@ -428,25 +428,52 @@ and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs. auto ok = [](ZBSendFileResponse response) { std::cout << response.toString() << std::endl; }; auto failed = [](ZBErrorResponse error) { std::cout << error.toString() << std::endl; }; +// fileName and returnUrl are required on every one of these six jobs. The docs +// mark them optional; the live API rejects a job without a return URL with +// "Provided return URL is not permitted: None". + // Amazon S3. fileName is the full path, as returned by listS3files. +zb->listS3files(std::string("my-bucket"), std::string("leads"), + [](std::vector files) { + for (const auto& file : files) { std::cout << file.name << std::endl; } + }, + failed +); + +ValidateS3FileOptions validate; +validate.fileName = "lists/leads.csv"; +validate.returnUrl = "https://example.com/zerobounce-done"; +validate.hasHeaderRow = true; +validate.emailAddressColumn = 1; // 1-based; set it to send it, 0 included +zb->validateS3File(validate, ok, failed); + +ScoreS3FileOptions score; +score.fileName = "lists/leads.csv"; +score.returnUrl = "https://example.com/zerobounce-done"; +zb->scoreS3File(score, ok, failed); + BulkFileJobOptions finder; finder.fileName = "lists/leads.csv"; finder.hasHeaderRow = true; +finder.returnUrl = "https://example.com/zerobounce-done"; zb->emailFinderS3File(finder, ok, failed); -BulkFileJobOptions domains; -domains.fileName = "lists/leads.csv"; +BulkFileJobOptions domains = finder; domains.removeDuplicates = true; -domains.returnUrl = "https://example.com/zerobounce-done"; zb->domainSearchS3File(domains, ok, failed); +// export_bucket is optional: std::nullopt uses the export bucket from your +// ZeroBounce settings. +zb->exportS3File("", std::nullopt, ok, failed); +zb->exportS3File("", std::string("my-export-bucket"), ok, failed); + // SFTP/FTP. The server, path and protocol come from your ZeroBounce settings. -// fileName may be left unset for validateSFTPFile alone, which then uses the -// input bucket from those settings. +// The docs list fileName as optional for validateSFTPFile, but the live API +// requires it there too. BulkFileJobOptions sftp; -zb->validateSFTPFile(sftp, ok, failed); - sftp.fileName = "my_file.txt"; +sftp.returnUrl = "https://example.com/zerobounce-done"; +zb->validateSFTPFile(sftp, ok, failed); zb->scoreSFTPFile(sftp, ok, failed); zb->emailFinderSFTPFile(sftp, ok, failed); zb->domainSearchSFTPFile(sftp, ok, failed); @@ -459,10 +486,16 @@ zb->exportSFTPFile("", std::string("/exports"), ok, failed); ## Per-API base URLs The list evaluator and the Inbox Placement Tester have their own base URLs, -separate from the main and bulk hosts. +separate from the main and bulk hosts. The three bulk hosts have setters of +their own, so every bulk endpoint can be pointed at a test host. ```cpp zb->setListEvaluatorBaseUrl("https://bulkapi.zerobounce.net/v2/listevaluator"); zb->setInboxPlacementBaseUrl("https://bulkapi.zerobounce.net/api/ipt"); -``` +// The bulk root carries the S3 and SFTP jobs, the bulk email finder and the +// bulk domain search; the other two carry bulk validation and bulk AI scoring. +zb->setBulkApiRootUrl("https://bulkapi.zerobounce.net"); +zb->setBulkApiBaseUrl("https://bulkapi.zerobounce.net/v2"); +zb->setBulkApiScoringBaseUrl("https://bulkapi.zerobounce.net/v2/scoring"); +``` diff --git a/include/ZeroBounce/Priority3Options.h b/include/ZeroBounce/Priority3Options.h index 55668b0..5630b01 100644 --- a/include/ZeroBounce/Priority3Options.h +++ b/include/ZeroBounce/Priority3Options.h @@ -16,11 +16,33 @@ struct DomainSearchSendFileOptions { std::optional hasHeaderRow; }; +/** + * The documented fields shared by the six S3 and SFTP/FTP file jobs. + * + * `fileName` is required by every job except `validateSFTPFile`, where the docs + * list it as optional: leaving it unset there uses the input bucket configured + * in your ZeroBounce settings. + */ +struct BulkFileJobOptions { + std::optional fileName; + bool hasHeaderRow = true; + std::optional removeDuplicates; + std::optional returnUrl; +}; + +/** + * `validateS3File` takes the shared job fields plus the column indexes and + * `allow_phase_2` that only this endpoint accepts. + * + * `fileName` and `returnUrl` are both required: the live API rejects a job + * without a return URL ("Provided return URL is not permitted: None") even + * though the docs mark it optional. `hasHeaderRow` is always sent, like the + * other S3 and SFTP jobs. + */ struct ValidateS3FileOptions { std::string fileName; - std::optional hasHeaderRow; + bool hasHeaderRow = true; std::optional removeDuplicates; - std::optional removeDuplicate; std::optional emailAddressColumn; std::string returnUrl; int firstNameColumn = 0; @@ -31,25 +53,18 @@ struct ValidateS3FileOptions { }; /** - * The documented fields shared by the six S3 and SFTP/FTP file jobs. + * `scoreS3File` takes the shared job fields plus `email_address_column`. + * `allow_phase_2` and the name columns are not among them. * - * `fileName` is required by every job except `validateSFTPFile`, where the docs - * list it as optional: leaving it unset there uses the input bucket configured - * in your ZeroBounce settings. + * `fileName` and `returnUrl` are both required, for the same reason as on + * `ValidateS3FileOptions`. */ -struct BulkFileJobOptions { - std::optional fileName; - bool hasHeaderRow = true; - std::optional removeDuplicates; - std::optional returnUrl; -}; - struct ScoreS3FileOptions { std::string fileName; - std::optional hasHeaderRow; + bool hasHeaderRow = true; std::optional removeDuplicates; - std::optional removeDuplicate; std::optional emailAddressColumn; + std::string returnUrl; }; #endif diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index 7cf94d2..43f1b83 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -198,12 +198,12 @@ class ZeroBounce { private: std::string apiKey; std::string apiBaseUrl = "https://api.zerobounce.net/v2"; - const std::string bulkApiRootUrl = "https://bulkapi.zerobounce.net"; + std::string bulkApiRootUrl = "https://bulkapi.zerobounce.net"; // The list evaluator and the Inbox Placement Tester are not under the bulk /v2 path. std::string listEvaluatorBaseUrl = "https://bulkapi.zerobounce.net/v2/listevaluator"; std::string inboxPlacementBaseUrl = "https://bulkapi.zerobounce.net/api/ipt"; - const std::string bulkApiBaseUrl = "https://bulkapi.zerobounce.net/v2"; - const std::string bulkApiScoringBaseUrl = "https://bulkapi.zerobounce.net/v2/scoring"; + std::string bulkApiBaseUrl = "https://bulkapi.zerobounce.net/v2"; + std::string bulkApiScoringBaseUrl = "https://bulkapi.zerobounce.net/v2/scoring"; /** * Checks if the [apiKey] is invalid or not and if it is, then it throws an error through the provided @@ -620,6 +620,13 @@ class ZeroBounce { // Priority 3 — bulk email finder, domain search, list evaluator, filters, S3 + /** + * Upload a file for the bulk email finder (POST /email-finder/sendfile). + * + * `domainColumn` is required, and so is one of `options->firstNameColumn` + * and `options->fullNameColumn`: the finder needs a name to work from, + * so `options` may not be null and may not leave both unset. + */ void sendEmailFinderFile( std::string filePath, int domainColumn, @@ -721,21 +728,39 @@ class ZeroBounce { OnErrorCallback errorCallback ); + /** + * Validate a file in your Amazon S3 bucket (POST /validateS3file). + * + * Shares the required fields with the other five S3 / SFTP jobs: + * `fileName` and `returnUrl` are both required. + */ void validateS3File( const ValidateS3FileOptions& options, OnSuccessCallback successCallback, OnErrorCallback errorCallback ); + /** + * Score a file in your Amazon S3 bucket (POST /scoreS3file). + * + * `fileName` and `returnUrl` are both required here too. + */ void scoreS3File( const ScoreS3FileOptions& options, OnSuccessCallback successCallback, OnErrorCallback errorCallback ); + /** + * Export a validated or scored file back to Amazon S3 (POST /exportS3file). + * + * `exportBucket` is optional: pass an unset one to use the export + * bucket from your ZeroBounce settings, the way exportSFTPFile treats + * its `export_path`. + */ void exportS3File( std::string fileId, - std::string exportBucket, + std::optional exportBucket, OnSuccessCallback successCallback, OnErrorCallback errorCallback ); @@ -817,8 +842,23 @@ class ZeroBounce { /** Override the Inbox Placement Tester base URL. */ void setInboxPlacementBaseUrl(const std::string& url); - // Inbox Placement Tester (bulkapi.zerobounce.net/api/ipt). The API key goes - // on the query string and bodies are JSON with camelCase fields. + /** + * Override the bulk API root (`https://bulkapi.zerobounce.net`), which + * the S3 and SFTP/FTP jobs, the bulk email finder and the bulk domain + * search are built from. The list evaluator and the Inbox Placement + * Tester are not: they have their own setters. + */ + void setBulkApiRootUrl(const std::string& url); + + /** Override the bulk API base URL (`/v2`) used by bulk validation. */ + void setBulkApiBaseUrl(const std::string& url); + + /** Override the bulk AI scoring base URL (`/v2/scoring`). */ + void setBulkApiScoringBaseUrl(const std::string& url); + + // Inbox Placement Tester (bulkapi.zerobounce.net/api/ipt). LIVE: the API + // key goes in an `Authorization: Bearer` header, never on the query + // string, and bodies are JSON with camelCase fields. void getInboxPlacementProviders( OnRawJsonSuccessCallback successCallback, diff --git a/include/ZeroBounce/ZeroBounceWarmup.h b/include/ZeroBounce/ZeroBounceWarmup.h index da5c562..c987d22 100644 --- a/include/ZeroBounce/ZeroBounceWarmup.h +++ b/include/ZeroBounce/ZeroBounceWarmup.h @@ -66,6 +66,7 @@ class ZeroBounceWarmup { std::string apiKey; std::string root; bool ready = false; + std::string initializeError; public: static constexpr const char* DefaultBaseUrl = "https://warmup-api.zerobounce.net"; @@ -81,9 +82,22 @@ class ZeroBounceWarmup { * A key with surrounding whitespace or control characters, or a base * URL that is not https, is rejected here: every later call then fails * with that message rather than sending the key anywhere. + * + * The rejection is not silent. `getInitializeError()` returns the + * reason straight away, so a bad base URL can be caught at the point + * of the mistake instead of on the first request. */ void initialize(const std::string& apiKey, const std::string& baseUrl = DefaultBaseUrl); + /** True when `initialize` accepted the key and the base URL. */ + bool isReady() const { return ready; } + + /** + * Why the last `initialize` was rejected, or empty when it was accepted. + * Never contains the API key. + */ + const std::string& getInitializeError() const { return initializeError; } + void setRequestHandler(BaseRequestHandler* handler) { requestHandler = handler; } BaseRequestHandler* getRequestHandler() { return requestHandler; } const std::string& getApiKey() const { return apiKey; } diff --git a/src/Priority3.cpp b/src/Priority3.cpp index 0a46fe7..f8131f9 100644 --- a/src/Priority3.cpp +++ b/src/Priority3.cpp @@ -216,6 +216,12 @@ void ZeroBounce::sendEmailFinderFile( errorCallback(ZBErrorResponse::parseError("Empty parameter: domain_column")); return; } + // The upload needs a name to work from: one of first_name_column and + // full_name_column is required, so neither being set is rejected here. + if (options == nullptr || (options->firstNameColumn < 1 && options->fullNameColumn < 1)) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: first_name_column or full_name_column")); + return; + } bulkRootMultipart( this, bulkApiRootUrl + "/email-finder/sendfile", @@ -511,26 +517,21 @@ void ZeroBounce::validateS3File( OnSuccessCallback successCallback, OnErrorCallback errorCallback ) { - if (options.fileName.empty()) { - errorCallback(ZBErrorResponse::parseError("Empty parameter: file_name")); + // The shared fields go through the same helper as the other five jobs, so + // that what the API requires is stated once. Only the extra fields that + // validateS3file alone accepts are added here. + BulkFileJobOptions shared; + shared.fileName = options.fileName; + shared.hasHeaderRow = options.hasHeaderRow; + shared.removeDuplicates = options.removeDuplicates; + shared.returnUrl = options.returnUrl; + std::vector> fields; + if (!bulkFileJobFields(shared, apiKey, fields, errorCallback)) { return; } - std::vector> fields{{"file_name", options.fileName}}; - if (options.hasHeaderRow.has_value()) { - fields.emplace_back("has_header_row", *options.hasHeaderRow ? "true" : "false"); - } - if (options.removeDuplicates.has_value()) { - fields.emplace_back("remove_duplicates", *options.removeDuplicates ? "true" : "false"); - } - if (options.removeDuplicate.has_value()) { - fields.emplace_back("remove_duplicate", *options.removeDuplicate ? "true" : "false"); - } if (options.emailAddressColumn.has_value()) { fields.emplace_back("email_address_column", std::to_string(*options.emailAddressColumn)); } - if (!options.returnUrl.empty()) { - fields.emplace_back("return_url", options.returnUrl); - } if (options.firstNameColumn > 0) { fields.emplace_back("first_name_column", std::to_string(options.firstNameColumn)); } @@ -546,7 +547,6 @@ void ZeroBounce::validateS3File( if (options.allowPhase2.has_value()) { fields.emplace_back("allow_phase_2", *options.allowPhase2 ? "true" : "false"); } - fields.emplace_back("api_key", apiKey); postSendFileForm(this, bulkApiRootUrl + "/validateS3file", fields, successCallback, errorCallback); } @@ -555,20 +555,15 @@ void ZeroBounce::scoreS3File( OnSuccessCallback successCallback, OnErrorCallback errorCallback ) { - if (options.fileName.empty()) { - errorCallback(ZBErrorResponse::parseError("Empty parameter: file_name")); + BulkFileJobOptions shared; + shared.fileName = options.fileName; + shared.hasHeaderRow = options.hasHeaderRow; + shared.removeDuplicates = options.removeDuplicates; + shared.returnUrl = options.returnUrl; + std::vector> fields; + if (!bulkFileJobFields(shared, apiKey, fields, errorCallback)) { return; } - std::vector> fields{{"file_name", options.fileName}, {"api_key", apiKey}}; - if (options.hasHeaderRow.has_value()) { - fields.emplace_back("has_header_row", *options.hasHeaderRow ? "true" : "false"); - } - if (options.removeDuplicates.has_value()) { - fields.emplace_back("remove_duplicates", *options.removeDuplicates ? "true" : "false"); - } - if (options.removeDuplicate.has_value()) { - fields.emplace_back("remove_duplicate", *options.removeDuplicate ? "true" : "false"); - } if (options.emailAddressColumn.has_value()) { fields.emplace_back("email_address_column", std::to_string(*options.emailAddressColumn)); } @@ -612,25 +607,30 @@ void ZeroBounce::listS3files( void ZeroBounce::exportS3File( std::string fileId, - std::string exportBucket, + std::optional exportBucket, OnSuccessCallback successCallback, OnErrorCallback errorCallback ) { - if (fileId.empty()) { - errorCallback(ZBErrorResponse::parseError("Empty parameter: file_id")); + if (fileId.find_first_not_of(" \t\r\n") == std::string::npos) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: file_id")); + } return; } - if (exportBucket.empty()) { - errorCallback(ZBErrorResponse::parseError("Empty parameter: export_bucket")); - return; + std::vector> fields{{"file_id", fileId}}; + // export_bucket is optional: without it the export bucket from your + // ZeroBounce settings is used, exactly as exportSFTPFile treats export_path. + if (exportBucket.has_value()) { + if (exportBucket->find_first_not_of(" \t\r\n") == std::string::npos) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: export_bucket")); + } + return; + } + fields.emplace_back("export_bucket", *exportBucket); } - postSendFileForm( - this, - bulkApiRootUrl + "/exportS3file", - {{"file_id", fileId}, {"export_bucket", exportBucket}, {"api_key", apiKey}}, - successCallback, - errorCallback - ); + fields.emplace_back("api_key", apiKey); + postSendFileForm(this, bulkApiRootUrl + "/exportS3file", fields, successCallback, errorCallback); } void ZeroBounce::emailFinderS3File( @@ -734,9 +734,10 @@ void ZeroBounce::exportSFTPFile( postSendFileForm(this, bulkApiRootUrl + "/exportSFTPfile", fields, successCallback, errorCallback); } -// Inbox Placement Tester (bulkapi.zerobounce.net/api/ipt). The API key goes on -// the query string rather than in the body, and bodies are JSON with camelCase -// fields. Only fields the caller set are sent. +// Inbox Placement Tester (bulkapi.zerobounce.net/api/ipt). LIVE: the API key +// goes in an `Authorization: Bearer` header, never on the query string or in +// the body, and bodies are JSON with camelCase fields. Only fields the caller +// set are sent. namespace { @@ -824,6 +825,18 @@ void ZeroBounce::setInboxPlacementBaseUrl(const std::string& url) { inboxPlacementBaseUrl = url; } +void ZeroBounce::setBulkApiRootUrl(const std::string& url) { + bulkApiRootUrl = url; +} + +void ZeroBounce::setBulkApiBaseUrl(const std::string& url) { + bulkApiBaseUrl = url; +} + +void ZeroBounce::setBulkApiScoringBaseUrl(const std::string& url) { + bulkApiScoringBaseUrl = url; +} + void ZeroBounce::getInboxPlacementProviders( OnRawJsonSuccessCallback successCallback, OnErrorCallback errorCallback diff --git a/src/ZeroBounceWarmup.cpp b/src/ZeroBounceWarmup.cpp index 379bab0..bc365f7 100644 --- a/src/ZeroBounceWarmup.cpp +++ b/src/ZeroBounceWarmup.cpp @@ -74,13 +74,16 @@ ZeroBounceWarmup::ZeroBounceWarmup() void ZeroBounceWarmup::initialize(const std::string& key, const std::string& baseUrl) { ready = false; + initializeError.clear(); apiKey = key; if (trimmed(key).empty()) { + initializeError = "api_key is required"; return; } // A key with surrounding whitespace or control characters would be quoted, // unredacted, in the HTTP library's own error. if (key != trimmed(key) || hasControlCharacters(key)) { + initializeError = "api_key has surrounding whitespace or control characters"; return; } std::string base = baseUrl.empty() ? std::string(DefaultBaseUrl) : baseUrl; @@ -88,7 +91,12 @@ void ZeroBounceWarmup::initialize(const std::string& key, const std::string& bas std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + // The Warmup key travels in a request header, so a plain-http base URL + // would put it on the wire in the clear. Rejected, and the reason is kept + // rather than dropped: a caller that only ever looked at initialize() used + // to get no signal at all. if (lower.rfind("https://", 0) != 0) { + initializeError = "base_url must be an https:// URL"; return; } while (!base.empty() && base.back() == '/') { @@ -134,7 +142,12 @@ struct WarmupResult { */ namespace { -bool warmupReady(const std::string& apiKey, bool ready, const OnErrorCallback& errorCallback) { +bool warmupReady( + const std::string& apiKey, + bool ready, + const std::string& initializeError, + const OnErrorCallback& errorCallback +) { if (trimmed(apiKey).empty()) { reportError(errorCallback, "api_key is required"); return false; @@ -144,7 +157,12 @@ bool warmupReady(const std::string& apiKey, bool ready, const OnErrorCallback& e return false; } if (!ready) { - reportError(errorCallback, "base_url must be an https:// URL"); + // Repeat what initialize() rejected rather than guessing at a reason. + reportError( + errorCallback, + initializeError.empty() ? "ZeroBounceWarmup is not initialized. Call initialize(apiKey) first" + : initializeError + ); return false; } return true; @@ -318,14 +336,14 @@ bool engagementBody( // -- sending accounts -------------------------------------------------------- void ZeroBounceWarmup::listSendingAccounts(OnRawJsonSuccessCallback ok, OnErrorCallback err) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; warmupJson(this, "GET", root + "/sending-accounts", nullptr, {}, ok, err); } void ZeroBounceWarmup::getSendingAccount( const std::string& fromAddress, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(fromAddress, "from_address", err)) return; warmupJson(this, "GET", root + "/sending-account?from_address=" + urlEncode(fromAddress), nullptr, {}, ok, err); @@ -338,7 +356,7 @@ void ZeroBounceWarmup::addSendingAccount( OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(fromAddress, "from_address", err)) return; if (std::find(kOnboardingModels.begin(), kOnboardingModels.end(), onboardingModel) == kOnboardingModels.end()) { @@ -409,7 +427,7 @@ void ZeroBounceWarmup::updateSendingAccount( const std::string& fromAddress, int engagementRuleId, int statusId, bool autoEnable, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(fromAddress, "from_address", err)) return; if (!requireRange(engagementRuleId, 0, 4, "engagement_rule_id", err)) return; if (!requireRange(statusId, 0, 8, "status_id", err)) return; @@ -427,7 +445,7 @@ void ZeroBounceWarmup::updateSendingAccount( void ZeroBounceWarmup::deleteSendingAccount( const std::string& fromAddress, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(fromAddress, "from_address", err)) return; warmupJson(this, "DELETE", root + "/sending-accounts?from_address=" + urlEncode(fromAddress), nullptr, {}, ok, err); @@ -437,7 +455,7 @@ void ZeroBounceWarmup::changeFromAddress( const std::string& oldFromAddress, const std::string& newFromAddress, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(oldFromAddress, "old_from_address", err)) return; if (!requireValue(newFromAddress, "new_from_address", err)) return; @@ -452,7 +470,7 @@ void ZeroBounceWarmup::changeFromAddress( void ZeroBounceWarmup::listFromAddresses( const WarmupListFromAddressesOptions& options, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; std::string url = root + "/from-addresses"; std::string separator = "?"; @@ -490,14 +508,14 @@ void ZeroBounceWarmup::listFromAddresses( // -- engagement rules -------------------------------------------------------- void ZeroBounceWarmup::getEngagementRules(OnRawJsonSuccessCallback ok, OnErrorCallback err) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; warmupJson(this, "GET", root + "/engagement-rules", nullptr, {}, ok, err); } void ZeroBounceWarmup::setEngagementRule( const std::string& cFrom, int ruleId, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(cFrom, "c_from", err)) return; if (!requireRange(ruleId, 0, 4, "rule_id", err)) return; @@ -511,7 +529,7 @@ void ZeroBounceWarmup::setEngagementRule( void ZeroBounceWarmup::getCustomEngagement( const std::string& cFrom, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(cFrom, "c_from", err)) return; const std::string segment = trimmed(cFrom); if (segment == "." || segment == "..") { @@ -525,7 +543,7 @@ void ZeroBounceWarmup::createCustomEngagement( const std::string& cFrom, int replyPct, int importantPct, int clickPct, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; std::string payload; if (!engagementBody(cFrom, replyPct, importantPct, clickPct, payload, err)) return; warmupJson(this, "POST", root + "/custom-engagement", &payload, {}, ok, err); @@ -535,7 +553,7 @@ void ZeroBounceWarmup::updateCustomEngagement( const std::string& cFrom, int replyPct, int importantPct, int clickPct, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; std::string payload; if (!engagementBody(cFrom, replyPct, importantPct, clickPct, payload, err)) return; warmupJson(this, "PUT", root + "/custom-engagement", &payload, {}, ok, err); @@ -544,7 +562,7 @@ void ZeroBounceWarmup::updateCustomEngagement( void ZeroBounceWarmup::deleteCustomEngagement( const std::string& cFrom, int engagementRuleId, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(cFrom, "c_from", err)) return; if (!requireRange(engagementRuleId, 1, 4, "engagement_rule_id", err)) return; @@ -558,39 +576,39 @@ void ZeroBounceWarmup::deleteCustomEngagement( // -- statistics, seeds and utilities ----------------------------------------- void ZeroBounceWarmup::getSeeds(OnRawJsonSuccessCallback ok, OnErrorCallback err) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; warmupJson(this, "GET", root + "/seeds", nullptr, {}, ok, err); } void ZeroBounceWarmup::downloadSeeds(OnSuccessCallback ok, OnErrorCallback err) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; warmupCsv(this, root + "/seeds/download", ok, err); } void ZeroBounceWarmup::getSeedCount(OnRawJsonSuccessCallback ok, OnErrorCallback err) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; warmupJson(this, "GET", root + "/seed-count", nullptr, {}, ok, err); } void ZeroBounceWarmup::getProgressToday(OnRawJsonSuccessCallback ok, OnErrorCallback err) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; warmupJson(this, "GET", root + "/progress-today", nullptr, {}, ok, err); } void ZeroBounceWarmup::getSentToday(OnRawJsonSuccessCallback ok, OnErrorCallback err) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; warmupJson(this, "GET", root + "/sent-today", nullptr, {}, ok, err); } void ZeroBounceWarmup::getEmailsPerDay(OnRawJsonSuccessCallback ok, OnErrorCallback err) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; warmupJson(this, "GET", root + "/emails-per-day", nullptr, {}, ok, err); } void ZeroBounceWarmup::getStatistics( const std::string& fromAddress, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(fromAddress, "from_address", err)) return; warmupJson(this, "GET", root + "/statistics?from_address=" + urlEncode(fromAddress), nullptr, {}, ok, err); @@ -600,7 +618,7 @@ void ZeroBounceWarmup::getStatisticsPerDay( const std::string& fromAddress, const std::string& startDate, const std::string& endDate, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(fromAddress, "from_address", err)) return; if (!isIsoDate(startDate)) { reportError(err, "start_date must be YYYY-MM-DD"); @@ -623,7 +641,7 @@ void ZeroBounceWarmup::getStatisticsPerDay( void ZeroBounceWarmup::getStatisticsDetailed( const std::string& cFrom, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(cFrom, "c_from", err)) return; warmupJson(this, "GET", root + "/statistics-detailed?c_from=" + urlEncode(cFrom), nullptr, {}, ok, err); @@ -632,7 +650,7 @@ void ZeroBounceWarmup::getStatisticsDetailed( void ZeroBounceWarmup::downloadStatistics( const std::string& cFrom, OnSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(cFrom, "c_from", err)) return; warmupCsv(this, root + "/statistics/download?c_from=" + urlEncode(cFrom), ok, err); } @@ -640,7 +658,7 @@ void ZeroBounceWarmup::downloadStatistics( void ZeroBounceWarmup::getFromAddressStatistics( const std::string& fromAddress, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(fromAddress, "from_address", err)) return; warmupJson(this, "GET", root + "/from-address-statistics?from_address=" + urlEncode(fromAddress), nullptr, {}, ok, err); @@ -650,7 +668,7 @@ void ZeroBounceWarmup::checkDomainDmarc( const std::string& fromAddress, const std::optional& dkimSelector, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(fromAddress, "from_address", err)) return; if (fromAddress.find('@') == std::string::npos) { reportError(err, "from_address must be an email address, e.g. sender@example.com"); @@ -668,7 +686,7 @@ void ZeroBounceWarmup::testSmtpConfiguration( const std::string& fromAddress, const std::string& username, const std::string& password, const std::string& server, int port, OnRawJsonSuccessCallback ok, OnErrorCallback err ) { - if (!warmupReady(apiKey, ready, err)) return; + if (!warmupReady(apiKey, ready, initializeError, err)) return; if (!requireValue(fromAddress, "from_address", err)) return; if (!requireValue(username, "username", err)) return; if (!requireValue(password, "password", err)) return; diff --git a/test/OfflineApiTest.cpp b/test/OfflineApiTest.cpp index e3909b0..9064798 100644 --- a/test/OfflineApiTest.cpp +++ b/test/OfflineApiTest.cpp @@ -263,6 +263,14 @@ class FakeApi : public BaseRequestHandler { response.header["Content-Type"] = "application/json"; return response; } + if (key.rfind("return-url-400-", 0) == 0) { + // LIVE: the body the API answers a /validateS3file without a + // return_url with, verbatim. + response.status_code = 400; + response.text = R"({"error_message":"Provided return URL is not permitted: None"})"; + response.header["Content-Type"] = "application/json"; + return response; + } if (key.rfind("empty-", 0) == 0) { response.status_code = 200; response.text = ""; @@ -723,12 +731,15 @@ TEST(OfflineApi, Priority3EndpointsAndZeroColumn) { EmailFinderSendFileOptions finder; finder.hasHeaderRow = false; finder.firstNameColumn = 0; + // A zero column is still "not set", so full_name_column carries the name here. + finder.fullNameColumn = 3; client.sendEmailFinderFile(kCsv, 1, &finder, [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); auto sent = onlyRequest(api); EXPECT_EQ(sent.host, "bulkapi.zerobounce.net"); EXPECT_EQ(sent.path, "/email-finder/sendfile"); EXPECT_EQ(sent.fields["domain_column"], "1"); EXPECT_EQ(sent.fields["has_header_row"], "false"); + EXPECT_EQ(sent.fields["full_name_column"], "3"); EXPECT_EQ(sent.fields.count("first_name_column"), 0u); api.clear(); @@ -805,28 +816,36 @@ TEST(OfflineApi, Priority3EndpointsAndZeroColumn) { s3.fileName = "inbox.csv"; s3.emailAddressColumn = 0; s3.hasHeaderRow = false; - s3.removeDuplicate = true; + s3.removeDuplicates = true; + s3.returnUrl = "https://example.com/done"; client.validateS3File(s3, [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); auto validated = onlyRequest(api); EXPECT_EQ(validated.method, "POST"); + EXPECT_EQ(validated.host, "bulkapi.zerobounce.net"); EXPECT_EQ(validated.path, "/validateS3file"); EXPECT_EQ(validated.fields["file_name"], "inbox.csv"); EXPECT_EQ(validated.fields["email_address_column"], "0"); EXPECT_EQ(validated.fields["has_header_row"], "false"); - EXPECT_EQ(validated.fields["remove_duplicate"], "true"); - EXPECT_EQ(validated.fields.count("remove_duplicates"), 0u); + EXPECT_EQ(validated.fields["remove_duplicates"], "true"); + EXPECT_EQ(validated.fields["return_url"], "https://example.com/done"); + EXPECT_EQ(validated.fields["api_key"], "p3-key"); + // The singular remove_duplicate belongs to the bulk /v2/sendfile upload, + // not to the S3 jobs; sending both here was never documented. + EXPECT_EQ(validated.fields.count("remove_duplicate"), 0u); EXPECT_EQ(validated.fields.count("first_name_column"), 0u); api.clear(); ScoreS3FileOptions score; score.fileName = "inbox.csv"; + score.returnUrl = "https://example.com/done"; client.scoreS3File(score, [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); auto scored = onlyRequest(api); EXPECT_EQ(scored.path, "/scoreS3file"); + EXPECT_EQ(scored.fields["return_url"], "https://example.com/done"); EXPECT_EQ(scored.fields.count("email_address_column"), 0u); api.clear(); - client.exportS3File("fid", "bucket-out", [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + client.exportS3File("fid", std::string("bucket-out"), [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); auto exported = onlyRequest(api); EXPECT_EQ(exported.path, "/exportS3file"); EXPECT_EQ(exported.fields["file_id"], "fid"); @@ -965,6 +984,240 @@ TEST(OfflineApi, S3AndSftpFileJobsRejectInvalidArgumentsBeforeRequest) { EXPECT_EQ(api.size(), 0u); } +// LIVE: POST /validateS3file without return_url answers +// 400 {"error_message":"Provided return URL is not permitted: None"}, so the +// two S3 jobs go through the same bulkFileJobFields helper as the other four +// and require it up front. +TEST(OfflineApi, ValidateAndScoreS3FileShareTheBulkFileJobFields) { + FakeApi api; + OfflineClient client(api, "s3-shared-key"); + auto fail = [&](ZBErrorResponse e) { FAIL() << e.toString(); }; + + ValidateS3FileOptions validate; + validate.fileName = "uploads/list.csv"; + validate.hasHeaderRow = true; + validate.removeDuplicates = false; + validate.returnUrl = "https://example.com/done"; + validate.emailAddressColumn = 0; + validate.firstNameColumn = 2; + validate.lastNameColumn = 3; + validate.genderColumn = 4; + validate.ipAddressColumn = 5; + validate.allowPhase2 = true; + client.validateS3File(validate, [&](ZBSendFileResponse response) { + EXPECT_TRUE(response.success); + EXPECT_EQ(response.fileId, "s3-shared-key"); + }, fail); + auto validated = onlyRequest(api); + EXPECT_EQ(validated.method, "POST"); + EXPECT_EQ(validated.url, "https://bulkapi.zerobounce.net/validateS3file"); + // The shared fields, in the shape bulkFileJobFields puts them in. + EXPECT_EQ(validated.fields["file_name"], "uploads/list.csv"); + EXPECT_EQ(validated.fields["has_header_row"], "true"); + EXPECT_EQ(validated.fields["remove_duplicates"], "false"); + EXPECT_EQ(validated.fields["return_url"], "https://example.com/done"); + // The key goes in the form body, not on the query string. + EXPECT_EQ(validated.fields["api_key"], "s3-shared-key"); + EXPECT_EQ(validated.url.find("api_key"), std::string::npos); + // The fields only validateS3file takes. + EXPECT_EQ(validated.fields["email_address_column"], "0"); + EXPECT_EQ(validated.fields["first_name_column"], "2"); + EXPECT_EQ(validated.fields["last_name_column"], "3"); + EXPECT_EQ(validated.fields["gender_column"], "4"); + EXPECT_EQ(validated.fields["ip_address_column"], "5"); + EXPECT_EQ(validated.fields["allow_phase_2"], "true"); + EXPECT_EQ(validated.fields.count("remove_duplicate"), 0u); + + api.clear(); + ScoreS3FileOptions score; + score.fileName = "uploads/list.csv"; + score.hasHeaderRow = false; + score.removeDuplicates = true; + score.returnUrl = "https://example.com/scored"; + score.emailAddressColumn = 2; + client.scoreS3File(score, [&](ZBSendFileResponse) {}, fail); + auto scored = onlyRequest(api); + EXPECT_EQ(scored.method, "POST"); + EXPECT_EQ(scored.url, "https://bulkapi.zerobounce.net/scoreS3file"); + EXPECT_EQ(scored.fields["file_name"], "uploads/list.csv"); + EXPECT_EQ(scored.fields["has_header_row"], "false"); + EXPECT_EQ(scored.fields["remove_duplicates"], "true"); + EXPECT_EQ(scored.fields["return_url"], "https://example.com/scored"); + EXPECT_EQ(scored.fields["api_key"], "s3-shared-key"); + EXPECT_EQ(scored.fields["email_address_column"], "2"); + EXPECT_EQ(scored.fields.count("remove_duplicate"), 0u); + // allow_phase_2 and the name columns are not scoreS3file's. + EXPECT_EQ(scored.fields.count("allow_phase_2"), 0u); + EXPECT_EQ(scored.fields.count("first_name_column"), 0u); + + // An unset or blank return_url is rejected before anything is sent. + api.clear(); + auto succeeded = [&](ZBSendFileResponse) { FAIL() << "success"; }; + auto expectReturnUrl = [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("return_url"), std::string::npos); + }; + ValidateS3FileOptions noReturnUrl; + noReturnUrl.fileName = "uploads/list.csv"; + client.validateS3File(noReturnUrl, succeeded, expectReturnUrl); + noReturnUrl.returnUrl = " "; + client.validateS3File(noReturnUrl, succeeded, expectReturnUrl); + ScoreS3FileOptions scoreNoReturnUrl; + scoreNoReturnUrl.fileName = "uploads/list.csv"; + client.scoreS3File(scoreNoReturnUrl, succeeded, expectReturnUrl); + scoreNoReturnUrl.returnUrl = "\t"; + client.scoreS3File(scoreNoReturnUrl, succeeded, expectReturnUrl); + + auto expectFileName = [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("file_name"), std::string::npos); + }; + ValidateS3FileOptions blankName; + blankName.fileName = " "; + blankName.returnUrl = "https://example.com/done"; + client.validateS3File(blankName, succeeded, expectFileName); + ScoreS3FileOptions blankScoreName; + blankScoreName.returnUrl = "https://example.com/done"; + client.scoreS3File(blankScoreName, succeeded, expectFileName); + + EXPECT_EQ(api.size(), 0u); +} + +TEST(OfflineApi, AValidateS3FileReturnUrlRejectionIsReadBack) { + FakeApi api; + OfflineClient client(api, "return-url-400-key"); + ValidateS3FileOptions options; + options.fileName = "uploads/list.csv"; + // The SDK's own check is satisfied; the scripted API still refuses. + options.returnUrl = "https://example.com/done"; + bool errored = false; + client.validateS3File(options, [&](ZBSendFileResponse) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + errored = true; + auto captured = captureError(error); + EXPECT_EQ(captured.text, "Provided return URL is not permitted: None"); + EXPECT_EQ(error.statusCode, 400); + }); + EXPECT_TRUE(errored); + EXPECT_EQ(onlyRequest(api).path, "/validateS3file"); +} + +// The docs make export_bucket optional, and exportSFTPFile already treats +// export_path that way. +TEST(OfflineApi, ExportS3FileTreatsTheBucketAsOptional) { + FakeApi api; + OfflineClient client(api, "export-key"); + auto fail = [&](ZBErrorResponse e) { FAIL() << e.toString(); }; + + client.exportS3File("fid", std::nullopt, [&](ZBSendFileResponse response) { + EXPECT_TRUE(response.success); + }, fail); + auto withoutBucket = onlyRequest(api); + EXPECT_EQ(withoutBucket.method, "POST"); + EXPECT_EQ(withoutBucket.url, "https://bulkapi.zerobounce.net/exportS3file"); + EXPECT_EQ(withoutBucket.fields["file_id"], "fid"); + EXPECT_EQ(withoutBucket.fields["api_key"], "export-key"); + EXPECT_EQ(withoutBucket.fields.count("export_bucket"), 0u); + + api.clear(); + client.exportS3File("fid", std::string("bucket-out"), [&](ZBSendFileResponse) {}, fail); + auto withBucket = onlyRequest(api); + EXPECT_EQ(withBucket.fields["export_bucket"], "bucket-out"); + EXPECT_EQ(withBucket.fields.count("export_path"), 0u); + + // A bucket that was passed but is blank is a mistake, not an omission. + api.clear(); + auto succeeded = [&](ZBSendFileResponse) { FAIL() << "success"; }; + client.exportS3File("fid", std::string(" "), succeeded, [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("export_bucket"), std::string::npos); + }); + client.exportS3File(" ", std::nullopt, succeeded, [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("file_id"), std::string::npos); + }); + EXPECT_EQ(api.size(), 0u); +} + +// The bulk email finder upload needs a name to work from: one of +// first_name_column and full_name_column is required. +TEST(OfflineApi, EmailFinderSendFileRequiresAFirstOrFullNameColumn) { + FakeApi api; + OfflineClient client(api, "finder-file-key"); + auto fail = [&](ZBErrorResponse e) { FAIL() << e.toString(); }; + + EmailFinderSendFileOptions firstName; + firstName.firstNameColumn = 2; + firstName.lastNameColumn = 3; + client.sendEmailFinderFile(kCsv, 1, &firstName, [&](ZBSendFileResponse) {}, fail); + auto withFirstName = onlyRequest(api); + EXPECT_EQ(withFirstName.method, "POST"); + EXPECT_EQ(withFirstName.url, "https://bulkapi.zerobounce.net/email-finder/sendfile"); + EXPECT_EQ(withFirstName.fields["domain_column"], "1"); + EXPECT_EQ(withFirstName.fields["first_name_column"], "2"); + EXPECT_EQ(withFirstName.fields["api_key"], "finder-file-key"); + EXPECT_EQ(withFirstName.fields.count("full_name_column"), 0u); + + api.clear(); + EmailFinderSendFileOptions fullName; + fullName.fullNameColumn = 4; + client.sendEmailFinderFile(kCsv, 1, &fullName, [&](ZBSendFileResponse) {}, fail); + auto withFullName = onlyRequest(api); + EXPECT_EQ(withFullName.fields["full_name_column"], "4"); + EXPECT_EQ(withFullName.fields.count("first_name_column"), 0u); + + api.clear(); + auto succeeded = [&](ZBSendFileResponse) { FAIL() << "success"; }; + auto expectNameColumn = [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("first_name_column or full_name_column"), std::string::npos); + }; + client.sendEmailFinderFile(kCsv, 1, nullptr, succeeded, expectNameColumn); + EmailFinderSendFileOptions neither; + neither.lastNameColumn = 2; + neither.middleNameColumn = 3; + client.sendEmailFinderFile(kCsv, 1, &neither, succeeded, expectNameColumn); + EXPECT_EQ(api.size(), 0u); +} + +// The bulk hosts had no setters at all, so every bulk endpoint could only ever +// reach production and none of them could be pointed at a test host. +TEST(OfflineApi, TheBulkBaseUrlsCanBePointedAtATestHost) { + FakeApi api; + OfflineClient client(api, "base-url-key"); + auto fail = [&](ZBErrorResponse e) { FAIL() << e.toString(); }; + client.setBulkApiRootUrl("https://bulk.test.example"); + client.setBulkApiBaseUrl("https://bulk.test.example/v2"); + client.setBulkApiScoringBaseUrl("https://bulk.test.example/v2/scoring"); + + SendFileOptions sendOptions; + client.sendFile(kCsv, 1, sendOptions, [&](ZBSendFileResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).url, "https://bulk.test.example/v2/sendfile"); + + api.clear(); + client.scoringSendFile(kCsv, 1, sendOptions, [&](ZBSendFileResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).url, "https://bulk.test.example/v2/scoring/sendfile"); + + api.clear(); + client.fileStatus("fid", [&](ZBFileStatusResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).host, "bulk.test.example"); + + api.clear(); + client.scoringFileStatus("fid", [&](ZBFileStatusResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).path, "/v2/scoring/filestatus"); + + api.clear(); + BulkFileJobOptions job; + job.fileName = "uploads/list.csv"; + job.returnUrl = "https://example.com/done"; + client.validateSFTPFile(job, [&](ZBSendFileResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).url, "https://bulk.test.example/validateSFTPfile"); + + api.clear(); + client.emailFinderDeleteFile("fid", [&](ZBDeleteFileResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).host, "bulk.test.example"); + + // The list evaluator and the Inbox Placement Tester are not built from the + // bulk root, so their own setters still govern them. + api.clear(); + client.getListEvaluatorResult("fid", [&](std::string) {}, fail); + EXPECT_EQ(onlyRequest(api).host, "bulkapi.zerobounce.net"); +} + #endif TEST(OfflineApi, RejectsMissingKeyAndInvalidArgumentsBeforeRequest) { diff --git a/test/OfflineWarmupTest.cpp b/test/OfflineWarmupTest.cpp index 74a424b..f019a27 100644 --- a/test/OfflineWarmupTest.cpp +++ b/test/OfflineWarmupTest.cpp @@ -224,6 +224,41 @@ TEST(OfflineWarmup, APlainHttpBaseUrlIsRejected) { EXPECT_EQ(api.size(), 0u); } +// initialize() used to reject a base URL by quietly leaving the client +// un-ready, with nothing to read back at the point of the mistake. +TEST(OfflineWarmup, InitializeReportsWhyItRejectedTheArguments) { + FakeWarmupApi api; + + OfflineWarmupClient good(api, "warmup-key", "https://warmup.example.com"); + EXPECT_TRUE(good.isReady()); + EXPECT_EQ(good.getInitializeError(), ""); + + OfflineWarmupClient plainHttp(api, "warmup-key", "http://warmup.example.com"); + EXPECT_FALSE(plainHttp.isReady()); + EXPECT_EQ(plainHttp.getInitializeError(), "base_url must be an https:// URL"); + // And that same reason, not a guess, reaches the error callback. + std::string message = captureError([&](auto ok, auto err) { plainHttp.getSeeds(ok, err); }); + EXPECT_NE(message.find("base_url must be an https:// URL"), std::string::npos) << message; + + OfflineWarmupClient noKey(api, "", "https://warmup.example.com"); + EXPECT_FALSE(noKey.isReady()); + EXPECT_EQ(noKey.getInitializeError(), "api_key is required"); + + OfflineWarmupClient paddedKey(api, " warmup-key ", "https://warmup.example.com"); + EXPECT_FALSE(paddedKey.isReady()); + EXPECT_EQ(paddedKey.getInitializeError(), + "api_key has surrounding whitespace or control characters"); + // The reason never repeats the key back. + EXPECT_EQ(paddedKey.getInitializeError().find("warmup-key"), std::string::npos); + + // A later good initialize clears the earlier reason. + plainHttp.initialize("warmup-key", "https://warmup.example.com"); + EXPECT_TRUE(plainHttp.isReady()); + EXPECT_EQ(plainHttp.getInitializeError(), ""); + + EXPECT_EQ(api.size(), 0u); +} + TEST(OfflineWarmup, ACustomHttpsBaseUrlIsUsed) { FakeWarmupApi api; OfflineWarmupClient client(api, "warmup-key", "https://warmup.example.com/"); diff --git a/test/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index 2770907..9e90fd4 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -1476,6 +1476,8 @@ TEST_F(Tests, testValidateS3FileValid) { ValidateS3FileOptions options; options.fileName = "bucket/key.csv"; + // LIVE: the API rejects a job without a return URL. + options.returnUrl = "https://example.com/done"; ZeroBounceTest::getInstance()->validateS3File( options,