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,