diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..310fdc4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project + +`zerobounce-cpp-api-wrapper` is the ZeroBounce C++ SDK. Callers construct a `ZeroBounce`, call `initialize` with an API key, and receive results through success and error callbacks. HTTP goes through libcpr behind `BaseRequestHandler`. There is no package registry: releases are GitHub tags. + +## Layout + +- `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/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. +- `CMakeLists.txt`: the only version field, `project(ZeroBounce VERSION x.y.z)`. + +## Commands + +```bash +cmake -S . -B build -G "Unix Makefiles" +cmake --build build # this is the typecheck; there is no separate lint step +ctest --test-dir build --output-on-failure +``` + +CI (`.github/workflows/sdk_ci.yml`) configures, builds, and runs `ctest`. That build is the typecheck. + +The live API suite is its own executable and off by default, so neither a +normal build nor CI compiles it: + +```bash +cmake -S . -B build -DZEROBOUNCE_BUILD_LIVE_TESTS=ON +cmake --build build --target ZeroBounceLiveApi +export ZEROBOUNCE_API_KEY=... ZEROBOUNCE_WARMUP_API_KEY=... +./build/bin/ZeroBounceLiveApi # free calls only +ZEROBOUNCE_LIVE_SPEND=1 ./build/bin/ZeroBounceLiveApi +ZEROBOUNCE_LIVE_CHANGES=1 ./build/bin/ZeroBounceLiveApi +``` + +Keys come from the environment only. Never write one into a file, a commit, a +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 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`. +- **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`. +- **Failures use the error callback**, not exceptions. The message must be short: no raw HTML, no megabyte body, no `[json.exception…]` text, and no `Error: Error:` prefix. An account-level `validateBatch` error (`email_address` `all`) is an error callback. A per-address error stays on the success result. +- **Tests replace `requestHandler`.** It is `protected`. Subclass `ZeroBounce` in the test and assign a fake. Do not call the live API from the default test run; `live/live_api.cpp` is the separate, opt-in suite. +- **Errors carry the HTTP status.** `ZBErrorResponse::statusCode` is set on every failed response, so callers can tell a Cloudflare 403 from an API 400/404 and a Warmup 429. A failure that never reached the API leaves it 0. + +## Releasing + +**Run the live suite by hand before every release.** The offline suite proves the SDK sends what we think it sends; only the live suite proves the API accepts it. Run it with real keys, including `ZEROBOUNCE_LIVE_SPEND=1` and `ZEROBOUNCE_LIVE_CHANGES=1`, and do not release on a failure. +- Match the surrounding style. Do not reformat unrelated code. + +## Changelog + +`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). + +- Add every user-visible change to `## [Unreleased]` in the same commit as the change. +- Group entries under these headings, in this order, omitting empty ones: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. +- Write for SDK users: name the method, option, or behaviour, and say what changed. Leave out CI-only changes, internal refactors, and fixes to code that was never released. +- At 2.y.z, a breaking change bumps the major version, a new method or option bumps the minor version, and fixes alone bump the patch version. + +## Releasing + +Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. + +1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links at the bottom of `CHANGELOG.md`. +2. Set the same version in `project(ZeroBounce VERSION …)` in `CMakeLists.txt`. The publish workflow fails if it differs from the tag. That is the only version field. There is no lockfile and no runtime version constant. +3. `cmake --build build` and `ctest --test-dir build --output-on-failure`. +4. Tag `vx.y.z` on that commit and run the Publish workflow with that tag. It creates the GitHub release. There is no package registry. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..65b9d86 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,127 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `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 domain search (`sendDomainSearchFile`, `domainSearchFileStatus`, `domainSearchGetFile`, `domainSearchDeleteFile`). +- List evaluator (`sendListEvaluatorFile`, `getListEvaluatorResult`, `sendEvaluatedFile`). +- Filters (`addFilter`, `listFilters`, `deleteFilter`). +- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3files`, `emailFinderS3File`, `domainSearchS3File`). +- 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. +- `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. +- `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`. + +### Changed + +- List evaluator requests use their own base URL (`https://bulkapi.zerobounce.net/v2/listevaluator`) instead of building the path from the bulk base, and the file id is URL-encoded as one path segment. `sendEvaluatedFile` stays on the bulk base. + +- API requests time out after 120 seconds and do not follow redirects. +- `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. +- Query values, including the API key, email, and file id, are percent-encoded. + +### Fixed + +- The api-usage response now reads `sub_status_leading_period_removed` and `sub_status_alias_address`, two counters the API returns that were silently discarded. + +- `{"success": false}` on an HTTP 200 reaches the error callback instead of the success one. The API answers an unknown bulk file id that way. +- An error body whose JSON carries `error`, `message`, `error_message`, or `detail` reports that text instead of the raw JSON. `detail` is what the Inbox Placement Tester (Django REST framework) returns. + +- A 403 HTML "Access denied" page (about 1.4 MB from the live API on `getFile`) is returned as a short error. An HTML page with status 200 is an error as well, including `getFile`, `emailFinderGetFile`, and `domainSearchGetFile`, instead of a saved CSV or a JSON parse. +- Empty and non-JSON bodies are a short error. The message is no longer the nlohmann `[json.exception.parse_error…]` text, and a repeated `Error:` / `[Error]:` prefix is removed. +- `validateBatch` reports an account-level failure (HTTP 200, error for address `all`, such as an invalid API key or no credits) through the error callback. An error for one address stays on the successful result. +- `sendFile` / `scoringSendFile` send `has_header_row`, `remove_duplicate`, and `allow_phase_2` as `true` or `false`. Bulk email finder and domain search send `has_header_row` when it is set to false. +- `findEmail`, `searchDomain`, and the bulk finder, domain search, list evaluator, filter, and S3 calls reject an empty API key before a request is sent. +- `ZBValidateResponse::operator==` compares every field. It previously stopped after `address` and `status`. + +### Security + +- Query parameters are percent-encoded, and redirects are disabled, so a value cannot rewrite the request URL or send the client to another host. +- Error callbacks no longer include the raw HTML access-denied page. +- A Warmup API key with surrounding whitespace or control characters is rejected, because the transport would otherwise quote it unredacted in its own error, and a Warmup base URL must be `https://`. +- The Warmup SMTP test masks the username and password out of every error it raises. + +## [2.1.3] - 2026-06-18 + +### Fixed + +- `validateBatch` posts to `api.zerobounce.net/v2/validatebatch` instead of `bulkapi.zerobounce.net`. + +## [2.1.2] - 2026-06-10 + +### Added + +- `ZBValidateResponse.catchallDomain`, read from `catchall_domain`. + +## [2.1.0] - 2026-04-05 + +### Added + +- `getFileWithOptions` and `scoringGetFileWithOptions`, with `GetFileOptions.downloadType` (`phase_1`, `phase_2`, `combined`) and `activityData` (validation `getFile` only). +- `SendFileOptions.allowPhase2` / `allowPhase2IsSet`, sent as `allow_phase_2` on validation `sendFile` only. +- `ZBFileStatusResponse.filePhase2Status`. + +### Fixed + +- `getFile` calls the error callback when the body is a JSON error, including HTTP 200 with `"success": false`, instead of saving that body as the results file. + +## [2.0.10] - 2026-03-03 + +### Changed + +- License the project under MIT. + +## [2.0.0] - 2026-03-03 + +### Added + +- `findEmailByDomain`, `findEmailByCompanyName`, `searchDomainByDomain`, and `searchDomainByCompanyName`. +- `initialize(apiKey, ZBApiURL)` to select the API base URL. +- `ZBValidateSubStatus::AcceptAll`. + +### Changed + +- The library builds as C++20 and uses libcpr for HTTP. + +### Deprecated + +- `findEmail`. Use the email-finder or domain-search methods instead. + +## [1.1.1] - 2023-09-18 + +### Added + +- `ZBFindEmailResponse::toString`. + +## [1.1.0] - 2023-09-12 + +### Added + +- Initial release: `getCredits`, `getApiUsage`, `validate`, `validateBatch`, bulk and AI-scoring file methods, `getActivityData`, and `findEmail`. + +[Unreleased]: https://github.com/zerobounce/zerobounce-cpp-api-wrapper/compare/v2.1.4...HEAD +[2.1.3]: https://github.com/zerobounce/zerobounce-cpp-api-wrapper/compare/v2.1.2...v2.1.3 +[2.1.2]: https://github.com/zerobounce/zerobounce-cpp-api-wrapper/compare/v2.1.0...v2.1.2 +[2.1.0]: https://github.com/zerobounce/zerobounce-cpp-api-wrapper/compare/v2.0.10...v2.1.0 +[2.0.10]: https://github.com/zerobounce/zerobounce-cpp-api-wrapper/compare/v2.0.0...v2.0.10 +[2.0.0]: https://github.com/zerobounce/zerobounce-cpp-api-wrapper/compare/v1.1.1...v2.0.0 +[1.1.1]: https://github.com/zerobounce/zerobounce-cpp-api-wrapper/compare/v1.1.0...v1.1.1 +[1.1.0]: https://github.com/zerobounce/zerobounce-cpp-api-wrapper/releases/tag/v1.1.0 diff --git a/CMakeLists.txt b/CMakeLists.txt index 5bd558f..aef1170 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,7 +51,18 @@ target_include_directories(${PROJECT_NAME} PUBLIC include) target_link_libraries(${PROJECT_NAME} PUBLIC cpr::cpr nlohmann_json::nlohmann_json) enable_testing() -add_executable(ZeroBounceTests test/ZeroBounceTest.cpp) +add_executable(ZeroBounceTests test/ZeroBounceTest.cpp test/OfflineApiTest.cpp test/OfflineWarmupTest.cpp) +target_compile_definitions(ZeroBounceTests PRIVATE ZB_OFFLINE_CURRENT=1) target_link_libraries(ZeroBounceTests PRIVATE ${PROJECT_NAME} gtest gtest_main) + +# The live API suite is its own executable and is off by default, so a normal +# build and CI never compile it and ctest never runs it. Build it with +# -DZEROBOUNCE_BUILD_LIVE_TESTS=ON and run it by hand with keys in the +# environment. +option(ZEROBOUNCE_BUILD_LIVE_TESTS "Build the opt-in live API suite" OFF) +if(ZEROBOUNCE_BUILD_LIVE_TESTS) + add_executable(ZeroBounceLiveApi live/live_api.cpp) + target_link_libraries(ZeroBounceLiveApi PRIVATE ${PROJECT_NAME}) +endif() include(GoogleTest) gtest_add_tests(TARGET ZeroBounceTests) diff --git a/README.md b/README.md index 6a25e1f..c53ea81 100644 --- a/README.md +++ b/README.md @@ -380,3 +380,171 @@ You should see an output like this 2. **Actions → Publish → Run workflow** with that tag. No package registry — GitHub release only. Pin the tag in FetchContent or as a submodule. + +## Validate options + +```cpp +ZBValidateOptions options; +// domainInfo adds domainWebsiteExists and domainRegistrantCompanyName. +options.domainInfo = true; +// greylistProcessing re-runs a greylisted address instead of returning unknown. +options.greylistProcessing = true; +// callbackUrl is only allowed together with greylistProcessing = true. +options.callbackUrl = std::string("https://example.com/zerobounce-callback"); + +zb->validate("someone@example.com", "", options, + [](ZBValidateResponse response) { + // While the greylist retry is still running the API returns a message + // instead of a result; validate again in about 30 minutes. + std::cout << response.message << " " << response.domainWebsiteExists << std::endl; + }, + [](ZBErrorResponse error) { std::cout << error.toString() << std::endl; } +); +``` + +## Inbox Placement Tester + +Lives on `https://bulkapi.zerobounce.net/api/ipt`, authenticates with an +`Authorization: Bearer` header, and spends inbox test credits. + +```cpp +zb->getInboxPlacementProviders( + [](std::string json) { std::cout << json << std::endl; }, + [](ZBErrorResponse error) { std::cout << error.toString() << std::endl; } +); + +// testType 1 (Email) needs senderEmail; 2 is Unique ID and 3 is Auto Alias. +CreateInboxPlacementTestOptions options; +options.testType = 1; +options.senderEmail = std::string("news@example.com"); +options.providers = std::vector{{1, 5}}; +options.webhookUrl = std::string("https://example.com/ipt-webhook"); + +zb->createInboxPlacementTest("October campaign", options, // name: at most 64 characters + [](std::string json) { std::cout << json << std::endl; }, + [](ZBErrorResponse error) { std::cout << error.toString() << std::endl; } +); + +ListInboxPlacementTestsOptions filters; +filters.status = std::string("finished"); +filters.page = 1; +filters.pageSize = 25; +zb->listInboxPlacementTests(filters, onSuccess, onError); + +zb->getInboxPlacementTest("123", onSuccess, onError); +zb->setInboxPlacementWebhook("https://example.com/ipt-webhook", onSuccess, onError); +zb->getInboxPlacementWebhook(onSuccess, onError); +zb->deleteInboxPlacementTest("123", onSuccess, onError); +``` + +## S3 and SFTP/FTP bulk + +Queue a job on a file that already sits in your Amazon S3 bucket, or on your +SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api) +and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs. + +```cpp +auto ok = [](ZBSendFileResponse response) { std::cout << response.toString() << std::endl; }; +auto failed = [](ZBErrorResponse error) { std::cout << error.toString() << std::endl; }; + +// Amazon S3. fileName is the full path, as returned by listS3files. +// fileName and returnUrl are required on every one of these jobs. +BulkFileJobOptions finder; +finder.fileName = "lists/leads.csv"; +finder.hasHeaderRow = true; +finder.returnUrl = "https://example.com/zerobounce-done"; +zb->emailFinderS3File(finder, ok, failed); + +BulkFileJobOptions domains = finder; +domains.removeDuplicates = true; +zb->domainSearchS3File(domains, 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. +BulkFileJobOptions sftp; +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); + +// Note the field: this one takes export_path, not export_bucket. +// std::nullopt uses the export path from your settings. +zb->exportSFTPFile("", std::string("/exports"), ok, failed); +``` + +## Warmup API + +The Warmup API is a separate product. It has its own host, its own team-scoped key, and its own client; your validation key will not work with it. + +```cpp +#include "ZeroBounce/ZeroBounceWarmup.h" + +ZeroBounceWarmup warmup; +warmup.initialize(""); + +warmup.listSendingAccounts(onSuccess, onError); +warmup.getProgressToday(onSuccess, onError); +warmup.downloadSeeds( + [](std::string csv) { std::cout << csv << std::endl; }, // columns seed,provider + onError); +``` + +Add a from-address you run yourself, with one of the five standard engagement rules (0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect): + +```cpp +WarmupAddSendingAccountOptions options; +options.engagementRuleId = 0; +warmup.addSendingAccount("sender@example.com", "self_managed", options, onSuccess, onError); +``` + +A custom engagement rule can be changed only once every seven days; inside that window the API answers 429. + +```cpp +warmup.createCustomEngagement("sender@example.com", 20, 20, 35, onSuccess, onError); // reply, important, click % +``` + +`checkDomainDmarc` takes an **email address**, not a domain — the live API rejects a bare domain even though the docs describe one: + +```cpp +warmup.checkDomainDmarc("sender@example.com", std::nullopt, onSuccess, onError); +``` + +Every documented empty success (204, or an empty 200) arrives as the JSON text `null`. Failures carry the HTTP status on `ZBErrorResponse::statusCode`. Nothing is stored by `testSmtpConfiguration`, and the credentials you pass it are masked out of any error it raises. + +## Live API suite + +A normal build never compiles it and `ctest` never runs it: the live suite is its own executable, built only when you ask for it. It needs real keys, which come from the environment and are never written anywhere: + +```bash +cmake -S . -B build -DZEROBOUNCE_BUILD_LIVE_TESTS=ON +cmake --build build --target ZeroBounceLiveApi + +export ZEROBOUNCE_API_KEY=... # validation API +export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API +./build/bin/ZeroBounceLiveApi +``` + +By default it only makes free calls: sandbox addresses, reads, and requests that are expected to fail but still prove the API accepts the payload. It checks that the credit balance did not move. Two flags opt in to more: + +| Flag | What it adds | +| --- | --- | +| `ZEROBOUNCE_LIVE_SPEND=1` | billable calls (scoring, activity, email finder) and a bulk round trip that deletes its own job | +| `ZEROBOUNCE_LIVE_CHANGES=1` | reversible account changes — a test filter and a self-managed Warmup sending account — each undone in cleanup even when a check fails | + +It never creates an Inbox Placement test, sets the Inbox Placement webhook, or starts ZB-managed Warmup onboarding, because the API cannot undo any of those. + +Run it by hand before every release: the offline suite proves the SDK sends what we think it sends, and only the live suite proves the API accepts it. + +## Per-API base URLs + +The list evaluator and the Inbox Placement Tester have their own base URLs, +separate from the main and bulk hosts. + +```cpp +zb->setListEvaluatorBaseUrl("https://bulkapi.zerobounce.net/v2/listevaluator"); +zb->setInboxPlacementBaseUrl("https://bulkapi.zerobounce.net/api/ipt"); +``` + diff --git a/documentation.md b/documentation.md index e3789ab..eb076ba 100644 --- a/documentation.md +++ b/documentation.md @@ -362,3 +362,107 @@ You should see an output like this [==========] 26 tests from 1 test suite ran. (7 ms total) [ PASSED ] 26 tests. ``` + +## Validate options + +```cpp +ZBValidateOptions options; +// domainInfo adds domainWebsiteExists and domainRegistrantCompanyName. +options.domainInfo = true; +// greylistProcessing re-runs a greylisted address instead of returning unknown. +options.greylistProcessing = true; +// callbackUrl is only allowed together with greylistProcessing = true. +options.callbackUrl = std::string("https://example.com/zerobounce-callback"); + +zb->validate("someone@example.com", "", options, + [](ZBValidateResponse response) { + // While the greylist retry is still running the API returns a message + // instead of a result; validate again in about 30 minutes. + std::cout << response.message << " " << response.domainWebsiteExists << std::endl; + }, + [](ZBErrorResponse error) { std::cout << error.toString() << std::endl; } +); +``` + +## Inbox Placement Tester + +Lives on `https://bulkapi.zerobounce.net/api/ipt` and spends inbox test credits. + +```cpp +zb->getInboxPlacementProviders( + [](std::string json) { std::cout << json << std::endl; }, + [](ZBErrorResponse error) { std::cout << error.toString() << std::endl; } +); + +// testType 1 (Email) needs senderEmail; 2 is Unique ID and 3 is Auto Alias. +CreateInboxPlacementTestOptions options; +options.testType = 1; +options.senderEmail = std::string("news@example.com"); +options.providers = std::vector{{1, 5}}; +options.webhookUrl = std::string("https://example.com/ipt-webhook"); + +zb->createInboxPlacementTest("October campaign", options, // name: at most 64 characters + [](std::string json) { std::cout << json << std::endl; }, + [](ZBErrorResponse error) { std::cout << error.toString() << std::endl; } +); + +ListInboxPlacementTestsOptions filters; +filters.status = std::string("finished"); +filters.page = 1; +filters.pageSize = 25; +zb->listInboxPlacementTests(filters, onSuccess, onError); + +zb->getInboxPlacementTest("123", onSuccess, onError); +zb->setInboxPlacementWebhook("https://example.com/ipt-webhook", onSuccess, onError); +zb->getInboxPlacementWebhook(onSuccess, onError); +zb->deleteInboxPlacementTest("123", onSuccess, onError); +``` + +## S3 and SFTP/FTP bulk + +Queue a job on a file that already sits in your Amazon S3 bucket, or on your +SFTP/FTP server. See the [S3](https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api) +and [SFTP](https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api) docs. + +```cpp +auto ok = [](ZBSendFileResponse response) { std::cout << response.toString() << std::endl; }; +auto failed = [](ZBErrorResponse error) { std::cout << error.toString() << std::endl; }; + +// Amazon S3. fileName is the full path, as returned by listS3files. +BulkFileJobOptions finder; +finder.fileName = "lists/leads.csv"; +finder.hasHeaderRow = true; +zb->emailFinderS3File(finder, ok, failed); + +BulkFileJobOptions domains; +domains.fileName = "lists/leads.csv"; +domains.removeDuplicates = true; +domains.returnUrl = "https://example.com/zerobounce-done"; +zb->domainSearchS3File(domains, 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. +BulkFileJobOptions sftp; +zb->validateSFTPFile(sftp, ok, failed); + +sftp.fileName = "my_file.txt"; +zb->scoreSFTPFile(sftp, ok, failed); +zb->emailFinderSFTPFile(sftp, ok, failed); +zb->domainSearchSFTPFile(sftp, ok, failed); + +// Note the field: this one takes export_path, not export_bucket. +// std::nullopt uses the export path from your settings. +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. + +```cpp +zb->setListEvaluatorBaseUrl("https://bulkapi.zerobounce.net/v2/listevaluator"); +zb->setInboxPlacementBaseUrl("https://bulkapi.zerobounce.net/api/ipt"); +``` + diff --git a/include/ZeroBounce/Priority3Options.h b/include/ZeroBounce/Priority3Options.h new file mode 100644 index 0000000..55668b0 --- /dev/null +++ b/include/ZeroBounce/Priority3Options.h @@ -0,0 +1,55 @@ +#ifndef ZB_PRIORITY3_OPTIONS_H +#define ZB_PRIORITY3_OPTIONS_H + +#include +#include + +struct EmailFinderSendFileOptions { + int firstNameColumn = 0; + int lastNameColumn = 0; + int middleNameColumn = 0; + int fullNameColumn = 0; + std::optional hasHeaderRow; +}; + +struct DomainSearchSendFileOptions { + std::optional hasHeaderRow; +}; + +struct ValidateS3FileOptions { + std::string fileName; + std::optional hasHeaderRow; + std::optional removeDuplicates; + std::optional removeDuplicate; + std::optional emailAddressColumn; + std::string returnUrl; + int firstNameColumn = 0; + int lastNameColumn = 0; + int genderColumn = 0; + int ipAddressColumn = 0; + std::optional allowPhase2; +}; + +/** + * 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; +}; + +struct ScoreS3FileOptions { + std::string fileName; + std::optional hasHeaderRow; + std::optional removeDuplicates; + std::optional removeDuplicate; + std::optional emailAddressColumn; +}; + +#endif diff --git a/include/ZeroBounce/ZBErrorResponse.h b/include/ZeroBounce/ZBErrorResponse.h index dd48a4c..411b54e 100644 --- a/include/ZeroBounce/ZBErrorResponse.h +++ b/include/ZeroBounce/ZBErrorResponse.h @@ -22,12 +22,21 @@ class ZBErrorResponse { bool success = false; std::vector errors; + /** + * The HTTP status of the failed response, or 0 when the failure never + * reached the API. It lets callers tell a 403 from Cloudflare apart + * from a 400/404 from the API and a 429 from the Warmup rate limit. + */ + long statusCode = 0; + ZBErrorResponse(); ZBErrorResponse(bool success, std::vector errors); + ZBErrorResponse(bool success, std::vector errors, long statusCode); std::string toString(); static ZBErrorResponse parseError(std::string error); + static ZBErrorResponse parseError(std::string error, long statusCode); bool operator==(const ZBErrorResponse& other) const; }; diff --git a/include/ZeroBounce/ZBGetApiUsageResponse.h b/include/ZeroBounce/ZBGetApiUsageResponse.h index dbc721d..7ddc16e 100644 --- a/include/ZeroBounce/ZBGetApiUsageResponse.h +++ b/include/ZeroBounce/ZBGetApiUsageResponse.h @@ -47,6 +47,10 @@ class ZBGetApiUsageResponse { int subStatusAllowed = 0; int subStatusGold = 0; int subStatusRoleBasedAcceptAll = 0; + /// Total emails with a leading period that was removed + int subStatusLeadingPeriodRemoved = 0; + /// Total number of times the API has a sub status of "alias_address" + int subStatusAliasAddress = 0; std::string startDate; std::string endDate; std::string error; diff --git a/include/ZeroBounce/ZBS3ListFileEntry.h b/include/ZeroBounce/ZBS3ListFileEntry.h new file mode 100644 index 0000000..068de29 --- /dev/null +++ b/include/ZeroBounce/ZBS3ListFileEntry.h @@ -0,0 +1,23 @@ +#ifndef ZBS3LISTFILEENTRY_H +#define ZBS3LISTFILEENTRY_H + +#include +#include + +#include + +using json = nlohmann::json; + +class ZBS3ListFileEntry { + public: + std::string name; + int size = 0; + std::string lastModified; + + static ZBS3ListFileEntry from_json(const json& j); + static std::vector listFromJson(const json& j); + + bool operator==(const ZBS3ListFileEntry& other) const; +}; + +#endif diff --git a/include/ZeroBounce/ZBScoreEmailResponse.h b/include/ZeroBounce/ZBScoreEmailResponse.h new file mode 100644 index 0000000..e8450fa --- /dev/null +++ b/include/ZeroBounce/ZBScoreEmailResponse.h @@ -0,0 +1,26 @@ +#ifndef ZBSCOREEMAILRESPONSE_H +#define ZBSCOREEMAILRESPONSE_H + +#include +#include + +#include + +using json = nlohmann::json; + +/** + * Response for GET/POST /scoring (real-time AI scoring). + */ +class ZBScoreEmailResponse { + public: + std::optional email; + std::optional score; + + std::string toString(); + + static ZBScoreEmailResponse from_json(const json& j); + + bool operator==(const ZBScoreEmailResponse& other) const; +}; + +#endif diff --git a/include/ZeroBounce/ZBValidateResponse.h b/include/ZeroBounce/ZBValidateResponse.h index 2d77d4b..dae7230 100644 --- a/include/ZeroBounce/ZBValidateResponse.h +++ b/include/ZeroBounce/ZBValidateResponse.h @@ -23,6 +23,8 @@ class ZBValidateResponse { std::string domain; std::string didYouMean; std::string domainAgeDays; + std::string activeInDays; + std::string activeFirstSeen; bool freeEmail = false; bool mxFound = false; std::string mxRecord; @@ -36,6 +38,12 @@ class ZBValidateResponse { std::string zipCode; std::string country; std::string processedAt; + /** With domainInfo: whether the domain has a website ("True", "False" or "unchecked"). */ + std::string domainWebsiteExists; + /** With domainInfo: the WHOIS registrant company, "Registrant info not found" or "unchecked". */ + std::string domainRegistrantCompanyName; + /** Set instead of a result while greylist processing runs. The API spells this key "Message". */ + std::string message; std::string error; std::string toString(bool isBatch = false); diff --git a/include/ZeroBounce/ZBValidateSubStatus.h b/include/ZeroBounce/ZBValidateSubStatus.h index ddd1640..eec2296 100644 --- a/include/ZeroBounce/ZBValidateSubStatus.h +++ b/include/ZeroBounce/ZBValidateSubStatus.h @@ -31,7 +31,8 @@ enum ZBValidateSubStatus { RoleBasedCatchAll, Disposable, Toxic, - AcceptAll + AcceptAll, + AiAgentMailbox }; NLOHMANN_JSON_SERIALIZE_ENUM( @@ -61,7 +62,8 @@ NLOHMANN_JSON_SERIALIZE_ENUM( {ZBValidateSubStatus::RoleBasedCatchAll, "role_based_catch_all"}, {ZBValidateSubStatus::Disposable, "disposable"}, {ZBValidateSubStatus::Toxic, "toxic"}, - {ZBValidateSubStatus::AcceptAll, "accept_all"} + {ZBValidateSubStatus::AcceptAll, "accept_all"}, + {ZBValidateSubStatus::AiAgentMailbox, "ai_agent_mailbox"} } ) diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index 430c9d9..7cf94d2 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -17,9 +17,12 @@ #include "ZeroBounce/ZBGetFileResponse.h" #include "ZeroBounce/ZBDeleteFileResponse.h" #include "ZeroBounce/ZBActivityDataResponse.h" +#include "ZeroBounce/ZBScoreEmailResponse.h" #include "ZeroBounce/ZBFindEmailResponse.h" #include "ZeroBounce/ZBDomainSearchResponse.h" #include "ZeroBounce/ZBApiURL.h" +#include "ZeroBounce/Priority3Options.h" +#include "ZeroBounce/ZBS3ListFileEntry.h" #include @@ -27,6 +30,7 @@ template using OnSuccessCallback = std::function; using OnErrorCallback = std::function; +using OnRawJsonSuccessCallback = std::function; /** * Struct representing the options for sending files in requests. @@ -60,6 +64,55 @@ struct GetFileOptions { std::optional activityData; }; +/** + * Optional query/body parameters for GET /validate and POST /validatebatch. + */ +struct ZBValidateOptions { + std::optional timeout; + std::optional activityData; + std::optional verifyPlus; + /** Adds domainWebsiteExists and domainRegistrantCompanyName to the response. */ + std::optional domainInfo; + /** Processes a greylisted address again instead of returning it as unknown. */ + std::optional greylistProcessing; + /** Where the result is sent once ready. Only valid with greylistProcessing = true. */ + std::optional callbackUrl; + bool usePost = false; +}; + +/** A per-provider seed cap for createInboxPlacementTest. */ +struct InboxPlacementProviderSelection { + long long id = 0; + /** At least 1 when given. */ + std::optional maxSeeds; +}; + +/** Options for createInboxPlacementTest. Unset fields are not sent. */ +struct CreateInboxPlacementTestOptions { + /** 1 (Email, needs senderEmail), 2 (Unique ID) or 3 (Auto Alias). */ + std::optional testType; + /** The From address to match; required when testType is 1. */ + std::optional senderEmail; + std::optional> providerIds; + std::optional> seedIds; + std::optional> providers; + std::optional webhookUrl; +}; + +/** Filters for listInboxPlacementTests. Unset fields are not sent. */ +struct ListInboxPlacementTestsOptions { + /** "running" or "finished". */ + std::optional status; + /** ISO 8601 date or datetime. */ + std::optional createdAfter; + /** ISO 8601 date or datetime. */ + std::optional createdBefore; + /** From 1. */ + std::optional page; + /** 1 to 100. */ + std::optional pageSize; +}; + /** * Base class for handling requests. */ @@ -81,12 +134,36 @@ class BaseRequestHandler { return doPost(std::forward(ts)...); } + template + cpr::Response Put(Ts&&... ts) { + return doPut(std::forward(ts)...); + } + + template + cpr::Response Delete(Ts&&... ts) { + return doDelete(std::forward(ts)...); + } + protected: virtual cpr::Response doGet(const cpr::Url& url) = 0; virtual cpr::Response doGet(const cpr::Url& url, const cpr::Header& header) = 0; virtual cpr::Response doGetWithParameters(const cpr::Url& url, const cpr::Parameters& parameters) = 0; virtual cpr::Response doPost(const cpr::Url& url, const cpr::Header& header, const cpr::Body& body) = 0; virtual cpr::Response doPost(const cpr::Url& url, const cpr::Header& header, const cpr::Multipart& multipart) = 0; + + // PUT and DELETE are used by the Inbox Placement Tester and the Warmup + // API. They have defaults rather than being pure so existing + // subclasses keep compiling. + virtual cpr::Response doPut(const cpr::Url& url, const cpr::Header& header, const cpr::Body& body) { + return cpr::Put(url, header, body, cpr::Timeout{120000}, cpr::Redirect{false}); + } + virtual cpr::Response doDelete(const cpr::Url& url, const cpr::Header& header) { + return cpr::Delete(url, header, cpr::Timeout{120000}, cpr::Redirect{false}); + } + // The Warmup API's DELETE /custom-engagement takes a JSON body. + virtual cpr::Response doDelete(const cpr::Url& url, const cpr::Header& header, const cpr::Body& body) { + return cpr::Delete(url, header, body, cpr::Timeout{120000}, cpr::Redirect{false}); + } }; /** @@ -121,6 +198,10 @@ class ZeroBounce { private: std::string apiKey; std::string apiBaseUrl = "https://api.zerobounce.net/v2"; + const 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"; @@ -256,6 +337,9 @@ class ZeroBounce { ); public: + BaseRequestHandler* getRequestHandler() { return requestHandler; } + const std::string& getApiKey() const { return apiKey; } + ZeroBounce(); ZeroBounce(const ZeroBounce& obj) = delete; @@ -325,6 +409,14 @@ class ZeroBounce { OnErrorCallback errorCallback ); + void validate( + std::string email, + std::string ipAddress, + const ZBValidateOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + /** * Validates a batch of emails. * @@ -338,6 +430,13 @@ class ZeroBounce { OnErrorCallback errorCallback ); + void validateBatch( + std::vector emailBatch, + const ZBValidateOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + /** * The sendFile API allows user to send a file for bulk email validation. * @@ -501,6 +600,272 @@ class ZeroBounce { OnErrorCallback errorCallback ); + /** + * Real-time AI scoring for a single email (GET /scoring). + */ + void scoreEmail( + std::string email, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + /** + * Real-time AI scoring for a single email (POST /scoring, form-encoded). + */ + void scoreEmailPost( + std::string email, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + // Priority 3 — bulk email finder, domain search, list evaluator, filters, S3 + + void sendEmailFinderFile( + std::string filePath, + int domainColumn, + const EmailFinderSendFileOptions* options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void emailFinderFileStatus( + std::string fileId, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void emailFinderGetFile( + std::string fileId, + std::string localDownloadPath, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void emailFinderDeleteFile( + std::string fileId, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void sendDomainSearchFile( + std::string filePath, + int domainColumn, + const DomainSearchSendFileOptions* options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void domainSearchFileStatus( + std::string fileId, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void domainSearchGetFile( + std::string fileId, + std::string localDownloadPath, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void domainSearchDeleteFile( + std::string fileId, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void sendListEvaluatorFile( + std::string filePath, + int emailAddressColumn, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void getListEvaluatorResult( + std::string fileId, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + /** + * Submit a finished evaluation for validation. + * + * LIVE: this takes the list evaluator's file_id as a form field, not a + * file upload as the docs describe. + */ + void sendEvaluatedFile( + std::string fileId, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void addFilter( + std::string rule, + std::string target, + std::string value, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void listFilters( + bool usePost, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void deleteFilter( + std::string rule, + std::string target, + std::string value, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void validateS3File( + const ValidateS3FileOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void scoreS3File( + const ScoreS3FileOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void exportS3File( + std::string fileId, + std::string exportBucket, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void listS3files( + std::optional bucket, + std::optional search, + OnSuccessCallback> successCallback, + OnErrorCallback errorCallback + ); + + /** Run the email finder on a file in your Amazon S3 bucket (POST /emailFinderS3file). */ + void emailFinderS3File( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + /** Run domain search on a file in your Amazon S3 bucket (POST /domainSearchS3file). */ + void domainSearchS3File( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + // SFTP/FTP bulk. The server, path and protocol are never sent by the SDK; + // they come from your ZeroBounce settings. + + /** + * Validate a file from your SFTP/FTP server (POST /validateSFTPfile). + * + * This is the one job whose `file_name` the docs list as optional: leave + * `options.fileName` unset to use the input bucket from your settings. + */ + void validateSFTPFile( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + /** Score a file from your SFTP/FTP server (POST /scoreSFTPfile). */ + void scoreSFTPFile( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + /** Run the email finder on a file from your SFTP/FTP server (POST /emailFinderSFTPfile). */ + void emailFinderSFTPFile( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + /** Run domain search on a file from your SFTP/FTP server (POST /domainSearchSFTPfile). */ + void domainSearchSFTPFile( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + /** + * Export a validated or scored file back to your SFTP/FTP server (POST /exportSFTPfile). + * + * Note the field name: this endpoint takes `export_path`, not the + * `export_bucket` that exportS3File uses. Pass an unset `exportPath` to + * use the export path from your ZeroBounce settings. + */ + void exportSFTPFile( + std::string fileId, + std::optional exportPath, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + /** Override the list evaluator base URL. */ + void setListEvaluatorBaseUrl(const std::string& url); + + /** 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. + + void getInboxPlacementProviders( + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void getInboxPlacementTestTypes( + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void createInboxPlacementTest( + const std::string& name, + const CreateInboxPlacementTestOptions& options, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void getInboxPlacementTest( + const std::string& testId, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void listInboxPlacementTests( + const ListInboxPlacementTestsOptions& options, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void deleteInboxPlacementTest( + const std::string& testId, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void getInboxPlacementWebhook( + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + + void setInboxPlacementWebhook( + const std::string& webhookUrl, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback + ); + /** * @brief Email Address Search - Identifies and validates a person’s primary email address * diff --git a/include/ZeroBounce/ZeroBounceWarmup.h b/include/ZeroBounce/ZeroBounceWarmup.h new file mode 100644 index 0000000..da5c562 --- /dev/null +++ b/include/ZeroBounce/ZeroBounceWarmup.h @@ -0,0 +1,255 @@ +#ifndef ZEROBOUNCEWARMUP_H +#define ZEROBOUNCEWARMUP_H + +#include +#include +#include +#include +#include + +#include "ZeroBounce/ZeroBounce.h" + +/** What ZeroBounce should send on your behalf, for zb_managed onboarding. */ +struct WarmupEmailContent { + /** Any of "transactional", "newsletter", "marketing". */ + std::vector typesOfEmails; + std::optional industry; + std::optional companyName; + std::optional website; +}; + +/** When ZeroBounce should send, for zb_managed onboarding. */ +struct WarmupManagedSending { + /** -11 to 14. */ + std::optional timezoneOffset; + /** YYYY-MM-DD. */ + std::optional warmupStartDate; + std::optional dailyLimit; +}; + +/** Options for addSendingAccount. */ +struct WarmupAddSendingAccountOptions { + /** Required for "self_managed": 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. */ + std::optional engagementRuleId; + std::optional ignoreConflict; + /** zb_managed only. */ + std::optional emailContent; + /** zb_managed only. */ + std::optional managedSending; +}; + +/** Paging and filtering for listFromAddresses. */ +struct WarmupListFromAddressesOptions { + /** Letters, digits and . - _ @ + only. */ + std::optional searchFrom; + std::optional offset; + std::optional limit; +}; + +/** + * Client for the ZeroBounce Warmup API (https://www.zerobounce.net/docs/warmup-api). + * + * The Warmup API is separate from the email validation API: its own host, its + * own team-scoped key, and an "API-Key" header instead of an api_key + * parameter. Use ZeroBounceWarmup with a Warmup API key; ZeroBounce stays for + * the validation API key. + * + * Every method answers through a callback, like ZeroBounce. Failures carry the + * HTTP status on ZBErrorResponse::statusCode. + */ +class ZeroBounceWarmup { + protected: + std::unique_ptr defaultRequestHandler; + BaseRequestHandler* requestHandler = nullptr; + + private: + std::string apiKey; + std::string root; + bool ready = false; + + public: + static constexpr const char* DefaultBaseUrl = "https://warmup-api.zerobounce.net"; + static constexpr const char* BasePath = "/api/v1/warmup"; + /** Required by the schema but always replaced with the key's own team. */ + static constexpr int AnyTeamId = 1; + + ZeroBounceWarmup(); + virtual ~ZeroBounceWarmup() = default; + + /** + * Store the Warmup API key, and optionally a custom https base URL. + * 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. + */ + void initialize(const std::string& apiKey, const std::string& baseUrl = DefaultBaseUrl); + + void setRequestHandler(BaseRequestHandler* handler) { requestHandler = handler; } + BaseRequestHandler* getRequestHandler() { return requestHandler; } + const std::string& getApiKey() const { return apiKey; } + + // -- sending accounts ------------------------------------------------ + + /** Every sending account of the key's team. GET /sending-accounts */ + void listSendingAccounts(OnRawJsonSuccessCallback, OnErrorCallback); + + /** One sending account. GET /sending-account */ + void getSendingAccount(const std::string& fromAddress, OnRawJsonSuccessCallback, OnErrorCallback); + + /** + * Add a from-address to warmup. POST /sending-accounts + * + * "self_managed" runs your own mailbox and needs engagementRuleId 0-4. + * "zb_managed" has ZeroBounce provision it and takes emailContent and + * managedSending; a 200 means accepted, and the mailbox is provisioned + * afterwards. + */ + void addSendingAccount( + const std::string& fromAddress, + const std::string& onboardingModel, + const WarmupAddSendingAccountOptions& options, + OnRawJsonSuccessCallback, + OnErrorCallback + ); + + /** + * Change an account's engagement rule, status and auto-enable flag. + * PUT /sending-accounts. statusId is 0 to 8 (4 Paused, 5 Disabled). + */ + void updateSendingAccount( + const std::string& fromAddress, + int engagementRuleId, + int statusId, + bool autoEnable, + OnRawJsonSuccessCallback, + OnErrorCallback + ); + + /** Remove a sending account. DELETE /sending-accounts. Destructive and final. */ + void deleteSendingAccount(const std::string& fromAddress, OnRawJsonSuccessCallback, OnErrorCallback); + + /** + * Move an account and its history to a new from-address. + * PUT /smtp-credentials - despite the path, no SMTP credentials are stored. + */ + void changeFromAddress( + const std::string& oldFromAddress, + const std::string& newFromAddress, + OnRawJsonSuccessCallback, + OnErrorCallback + ); + + /** Registered from-addresses. GET /from-addresses */ + void listFromAddresses( + const WarmupListFromAddressesOptions& options, + OnRawJsonSuccessCallback, + OnErrorCallback + ); + + // -- engagement rules ------------------------------------------------ + + /** -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect. */ + void getEngagementRules(OnRawJsonSuccessCallback, OnErrorCallback); + + /** Set a standard rule (0 to 4) on an address. PUT /engagement-rule */ + void setEngagementRule(const std::string& cFrom, int ruleId, OnRawJsonSuccessCallback, OnErrorCallback); + + /** An address's custom rule, with can_update and remaining_seconds. */ + void getCustomEngagement(const std::string& cFrom, OnRawJsonSuccessCallback, OnErrorCallback); + + /** Create a custom rule. Starts a 7 day cooldown on updates. */ + void createCustomEngagement( + const std::string& cFrom, int replyPct, int importantPct, int clickPct, + OnRawJsonSuccessCallback, OnErrorCallback + ); + + /** Update a custom rule. During the 7 day cooldown the API answers 429. */ + void updateCustomEngagement( + const std::string& cFrom, int replyPct, int importantPct, int clickPct, + OnRawJsonSuccessCallback, OnErrorCallback + ); + + /** Delete a custom rule and fall back to standard rule 1-4. */ + void deleteCustomEngagement( + const std::string& cFrom, int engagementRuleId, + OnRawJsonSuccessCallback, OnErrorCallback + ); + + // -- statistics, seeds and utilities --------------------------------- + + /** The seed list. GET /seeds */ + void getSeeds(OnRawJsonSuccessCallback, OnErrorCallback); + + /** The seed list as CSV (columns seed,provider). GET /seeds/download */ + void downloadSeeds(OnSuccessCallback, OnErrorCallback); + + /** Number of seeds. GET /seed-count */ + void getSeedCount(OnRawJsonSuccessCallback, OnErrorCallback); + + /** Today's warmup progress. GET /progress-today */ + void getProgressToday(OnRawJsonSuccessCallback, OnErrorCallback); + + /** Messages sent today. GET /sent-today */ + void getSentToday(OnRawJsonSuccessCallback, OnErrorCallback); + + /** Current daily volume. GET /emails-per-day */ + void getEmailsPerDay(OnRawJsonSuccessCallback, OnErrorCallback); + + /** Statistics for an address. An address with no history returns zeros. */ + void getStatistics(const std::string& fromAddress, OnRawJsonSuccessCallback, OnErrorCallback); + + /** Daily statistics between two dates, inclusive. GET /statistics-per-day */ + void getStatisticsPerDay( + const std::string& fromAddress, + const std::string& startDate, + const std::string& endDate, + OnRawJsonSuccessCallback, + OnErrorCallback + ); + + /** Detailed statistics for an address. GET /statistics-detailed */ + void getStatisticsDetailed(const std::string& cFrom, OnRawJsonSuccessCallback, OnErrorCallback); + + /** An address's statistics as CSV. GET /statistics/download */ + void downloadStatistics(const std::string& cFrom, OnSuccessCallback, OnErrorCallback); + + /** Statistics for one from-address. GET /from-address-statistics */ + void getFromAddressStatistics(const std::string& fromAddress, OnRawJsonSuccessCallback, OnErrorCallback); + + /** + * SPF, DKIM and DMARC status of a from-address's domain. + * GET /check-domain-dmarc + * + * LIVE: pass an EMAIL ADDRESS. The live API answers "Please provide a + * valid email address" for a bare domain, although the docs describe + * a domain. + */ + void checkDomainDmarc( + const std::string& fromAddress, + const std::optional& dkimSelector, + OnRawJsonSuccessCallback, + OnErrorCallback + ); + + /** + * Check that SMTP credentials connect and hand off a message. + * POST /test-smtp-configuration + * + * Nothing is stored, and the test message goes to a ZeroBounce test + * recipient, not fromAddress. + * + * LIVE: the API answers any failed SMTP login with HTTP 500. That is an + * API bug. The username and password are masked in every error here. + */ + void testSmtpConfiguration( + const std::string& fromAddress, + const std::string& username, + const std::string& password, + const std::string& server, + int port, + OnRawJsonSuccessCallback, + OnErrorCallback + ); +}; + +#endif diff --git a/live/live_api.cpp b/live/live_api.cpp new file mode 100644 index 0000000..033207a --- /dev/null +++ b/live/live_api.cpp @@ -0,0 +1,709 @@ +// Opt-in live API suite: calls every SDK method against the real API to check +// payloads and response shapes. +// +// It is its own executable, built only when ZEROBOUNCE_BUILD_LIVE_TESTS=ON, so +// `cmake --build build` and CI never compile it and `ctest` never runs it: +// +// cmake -S . -B build-live -DZEROBOUNCE_BUILD_LIVE_TESTS=ON +// cmake --build build-live --target ZeroBounceLiveApi +// export ZEROBOUNCE_API_KEY=... # validation API (skipped without it) +// export ZEROBOUNCE_WARMUP_API_KEY=... # Warmup API (skipped without it) +// ./build-live/ZeroBounceLiveApi +// +// By default only free calls run: sandbox addresses (no credits), reads, and +// requests expected to fail (unknown file IDs, S3/SFTP without an integration) +// that still prove the API accepts the payload. The free tier checks that the +// credit balance did not change. Two flags opt in to more: +// +// ZEROBOUNCE_LIVE_SPEND=1 spends credits: scoring, activity, email +// finder, and a bulk round trip whose job is +// deleted again +// ZEROBOUNCE_LIVE_CHANGES=1 reversible account changes, each undone in +// cleanup even when a test fails: a test filter, +// and a self-managed Warmup sending account +// +// Never called, because the API cannot undo them: creating an Inbox Placement +// test (it cannot be deleted while running), setting the Inbox Placement +// webhook (it cannot be cleared), and ZB-managed Warmup onboarding (it starts +// mailbox provisioning). +// +// No key is ever written to a file or printed. Keys come from the environment. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ZeroBounce/ZeroBounce.h" +#include "ZeroBounce/ZeroBounceWarmup.h" + +using json = nlohmann::json; + +namespace { + +/// Well-formed but matching no file: a malformed id can be Cloudflare-blocked +/// instead of answered. +const std::string kUnknownId = "0d1b7a4e-1111-2222-3333-444455556666"; +const std::string kCallback = "https://example.com/zerobounce-sdk-live-test"; +const std::string kSandbox = "valid@example.com"; + +int passed = 0; +int failed = 0; +int skipped = 0; +std::vector failures; + +std::string env(const char* name) { + const char* value = std::getenv(name); + if (value == nullptr) { + return ""; + } + std::string text = value; + const auto first = text.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) { + return ""; + } + const auto last = text.find_last_not_of(" \t\r\n"); + return text.substr(first, last - first + 1); +} + +std::string apiKey() { return env("ZEROBOUNCE_API_KEY"); } +std::string warmupKey() { return env("ZEROBOUNCE_WARMUP_API_KEY"); } +bool flag(const char* name) { return env(name) == "1"; } + +std::string redact(const std::string& text) { + std::string value = text; + for (const auto& secret : {apiKey(), warmupKey()}) { + if (secret.empty()) { + continue; + } + size_t at = 0; + while ((at = value.find(secret, at)) != std::string::npos) { + value.replace(at, secret.size(), "***"); + at += 3; + } + } + return value; +} + +/// A check either passes, or records why it did not. +void check(const std::string& name, const std::function& body) { + std::string problem; + try { + problem = body(); + } catch (const std::exception& error) { + problem = redact(error.what()); + } + if (problem.empty()) { + passed++; + std::cout << " + " << name << std::endl; + } else { + failed++; + failures.push_back(name + ": " + redact(problem)); + std::cout << " X " << name << "\n " << redact(problem) << std::endl; + } +} + +void skip(const std::string& name, const std::string& reason) { + skipped++; + std::cout << " - SKIP " << name << " (" << reason << ")" << std::endl; +} + +/// One client per key, built once. Neither client is copyable. +ZeroBounce& validationClient() { + static ZeroBounce zb; + static bool ready = false; + if (!ready) { + zb.initialize(apiKey()); + ready = true; + } + return zb; +} + +std::tm daysAgo(int days) { + std::time_t now = std::time(nullptr); + now -= static_cast(days) * 24 * 3600; + std::tm value{}; +#if defined(_WIN32) + localtime_s(&value, &now); +#else + localtime_r(&now, &value); +#endif + return value; +} + +/// Run a call that must fail, and hand back the error's text. +std::string expectFailure( + const std::string& what, + const std::function, OnErrorCallback)>& call +) { + std::string message; + bool rejected = false; + call([&]() { rejected = false; }, + [&](ZBErrorResponse error) { + rejected = true; + message = redact(error.toString()); + }); + if (!rejected) { + return ""; + } + return message.empty() ? what + " failed without a message" : message; +} + +// -- validation API, free ---------------------------------------------------- + +void validationFree() { + std::cout << "Validation API (free calls)" << std::endl; + ZeroBounce& zb = validationClient(); + + long long before = -2; + check("getCredits returns a balance", [&]() -> std::string { + std::string problem = "no response"; + zb.getCredits([&](ZBCreditsResponse response) { + before = response.credits; + problem.clear(); + }, [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("getApiUsage parses the documented date formats", [&]() -> std::string { + std::string problem = "no response"; + zb.getApiUsage(daysAgo(7), daysAgo(0), + [&](ZBGetApiUsageResponse) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("validate on a sandbox address spends nothing", [&]() -> std::string { + std::string problem = "no response"; + zb.validate(kSandbox, "", [&](ZBValidateResponse response) { + problem = response.address == kSandbox ? "" : "address did not echo"; + }, [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("validate works over POST as well as GET", [&]() -> std::string { + // No billable add-on here: activity_data, verify_plus and domain_info + // are charged even on a sandbox address, so they live in the SPEND tier. + ZBValidateOptions options; + options.usePost = true; + options.timeout = 30; + std::string problem = "no response"; + zb.validate(kSandbox, "", options, [&](ZBValidateResponse) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("LIVE: validateBatch accepts timeout as a string", [&]() -> std::string { + // Sent as a JSON number the API rejects the whole batch with the + // account-level "Invalid API Key or your account ran out of credits". + ZBValidateOptions options; + options.timeout = 30; + std::string problem = "no response"; + zb.validateBatch({ZBEmailToValidate(kSandbox, "")}, options, + [&](ZBValidateBatchResponse response) { + problem.clear(); + for (const auto& error : response.errors) { + std::string address = error.emailAddress; + for (auto& c : address) c = static_cast(std::tolower(c)); + if (address == "all") { + problem = "account-level error: " + error.error; + } + } + }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("an unknown file id is a short, clean error", [&]() -> std::string { + std::string message = expectFailure("fileStatus", [&](auto ok, auto err) { + zb.fileStatus(kUnknownId, [&](ZBFileStatusResponse) { ok(); }, err); + }); + if (message.empty()) { + return "fileStatus unexpectedly succeeded"; + } + if (message.size() >= 500 || message.find(" std::string { + std::string problem; + zb.listS3files(std::nullopt, std::nullopt, + [&](std::vector) {}, + [&](ZBErrorResponse error) { + // No S3 integration configured is an expected, documented failure. + if (error.toString().size() >= 500) { + problem = "message too long"; + } + }); + return problem; + }); + + check("LIVE: S3/SFTP jobs reject a payload without return_url before sending", + [&]() -> std::string { + BulkFileJobOptions options; + options.fileName = "f.csv"; + options.hasHeaderRow = true; + std::string message = expectFailure("emailFinderS3File", [&](auto ok, auto err) { + zb.emailFinderS3File(options, [&](ZBSendFileResponse) { ok(); }, err); + }); + if (message.find("return_url") == std::string::npos) { + return "unexpected message: " + message; + } + return ""; + }); + + check("LIVE: a complete S3 job payload is accepted by the API", [&]() -> std::string { + // Without an S3 integration this fails on the integration, not the payload. + BulkFileJobOptions options; + options.fileName = "f.csv"; + options.hasHeaderRow = true; + options.returnUrl = kCallback; + std::string problem; + zb.emailFinderS3File(options, [&](ZBSendFileResponse) {}, [&](ZBErrorResponse error) { + const std::string message = redact(error.toString()); + if (message.find("return URL is not permitted") != std::string::npos || + message.find("This field is required") != std::string::npos) { + problem = "payload rejected: " + message; + } + }); + return problem; + }); + + check("filters list", [&]() -> std::string { + std::string problem = "no response"; + zb.listFilters(false, [&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("LIVE: the Inbox Placement Tester accepts a Bearer key", [&]() -> std::string { + std::string problem = "no response"; + zb.getInboxPlacementProviders([&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("inbox placement test types", [&]() -> std::string { + std::string problem = "no response"; + zb.getInboxPlacementTestTypes([&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("LIVE: inbox placement tests are listed from /tests, and the webhook reads back", + [&]() -> std::string { + // The listing is GET /tests (plural); /test is the single-test path + // and answers "Method \"GET\" not allowed". + ListInboxPlacementTestsOptions options; + options.page = 1; + options.pageSize = 10; + std::string problem = "no response"; + zb.listInboxPlacementTests(options, [&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + if (!problem.empty()) { + return problem; + } + problem = "no webhook response"; + zb.getInboxPlacementWebhook([&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("list evaluator result for an unknown id fails cleanly", [&]() -> std::string { + std::string message = expectFailure("getListEvaluatorResult", [&](auto ok, auto err) { + zb.getListEvaluatorResult(kUnknownId, [&](std::string) { ok(); }, err); + }); + if (message.empty()) { + return "getListEvaluatorResult unexpectedly succeeded"; + } + return message.size() < 500 ? "" : "message too long"; + }); + + check("LIVE: sendEvaluatedFile posts the file id as a form field", [&]() -> std::string { + std::string message = expectFailure("sendEvaluatedFile", [&](auto ok, auto err) { + zb.sendEvaluatedFile(kUnknownId, [&](ZBSendFileResponse) { ok(); }, err); + }); + if (message.empty()) { + return "sendEvaluatedFile unexpectedly succeeded"; + } + if (message.find("not allowed") != std::string::npos) { + return "wrong method or path: " + message; + } + return ""; + }); + + check("the free calls did not change the credit balance", [&]() -> std::string { + long long after = -2; + std::string problem = "no response"; + validationClient().getCredits([&](ZBCreditsResponse response) { + after = response.credits; + problem.clear(); + }, [&](ZBErrorResponse error) { problem = error.toString(); }); + if (!problem.empty()) { + return problem; + } + return after == before + ? "" + : "credits went from " + std::to_string(before) + " to " + std::to_string(after); + }); +} + +// -- validation API, spends credits ------------------------------------------ + +void validationSpend() { + std::cout << "\nValidation API (ZEROBOUNCE_LIVE_SPEND=1)\n"; + ZeroBounce& zb = validationClient(); + + check("validate accepts every documented option", [&]() -> std::string { + // LIVE: activity_data, verify_plus and domain_info are billable add-ons + // and are charged even for a sandbox address, so this is not free. + ZBValidateOptions options; + options.timeout = 30; + options.activityData = true; + options.verifyPlus = true; + options.domainInfo = true; + options.greylistProcessing = true; + options.callbackUrl = kCallback; + std::string problem = "no response"; + zb.validate(kSandbox, "", options, [&](ZBValidateResponse) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("scoreEmail", [&]() -> std::string { + std::string problem = "no response"; + zb.scoreEmail(kSandbox, [&](ZBScoreEmailResponse) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("scoreEmail over POST", [&]() -> std::string { + std::string problem = "no response"; + zb.scoreEmailPost(kSandbox, [&](ZBScoreEmailResponse) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("getActivityData", [&]() -> std::string { + std::string problem = "no response"; + zb.getActivityData(kSandbox, [&](ZBActivityDataResponse) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("email finder", [&]() -> std::string { + std::string problem = "no response"; + zb.findEmailByDomain("example.com", "John", "Doe", + [&](ZBFindEmailResponse) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("domain search", [&]() -> std::string { + std::string problem = "no response"; + zb.searchDomainByDomain("example.com", [&](ZBDomainSearchResponse) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + std::string fileId; + check("bulk validation round trip: upload, status, delete", [&]() -> std::string { + const std::string path = "zb-sdk-live.csv"; + { + std::ofstream out(path); + out << "email\nvalid@example.com\ninvalid@example.com\n"; + } + SendFileOptions options; + options.hasHeaderRow = true; + std::string problem = "no response"; + zb.sendFile(path, 1, options, [&](ZBSendFileResponse response) { + if (response.fileId.empty()) { + problem = "no file_id in the upload response"; + return; + } + fileId = response.fileId; + problem.clear(); + }, [&](ZBErrorResponse error) { problem = error.toString(); }); + std::remove(path.c_str()); + if (!problem.empty()) { + return problem; + } + problem = "no status response"; + zb.fileStatus(fileId, [&](ZBFileStatusResponse status) { + problem = status.fileId == fileId ? "" : "status did not echo the file id"; + }, [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + if (fileId.empty()) { + skip("the uploaded validation job is deleted again", "upload did not produce a file id"); + } else { + check("the uploaded validation job is deleted again", [&]() -> std::string { + // LIVE: a just-accepted job answers "File cannot be found" for a + // short while, so the delete is retried rather than leaving it behind. + std::string last; + for (int attempt = 0; attempt < 12; ++attempt) { + bool done = false; + zb.deleteFile(fileId, [&](ZBDeleteFileResponse) { done = true; }, + [&](ZBErrorResponse error) { last = redact(error.toString()); }); + if (done) { + return ""; + } + std::this_thread::sleep_for(std::chrono::seconds(10)); + } + return "could not delete " + fileId + " after retrying: " + last; + }); + } +} + +// -- validation API, reversible account changes ------------------------------ + +void validationChanges() { + std::cout << "\nValidation API (ZEROBOUNCE_LIVE_CHANGES=1)\n"; + ZeroBounce& zb = validationClient(); + const std::string value = + "zb-sdk-live-" + std::to_string(std::time(nullptr)) + ".example.com"; + + bool added = false; + check("addFilter creates a reversible allow rule", [&]() -> std::string { + std::string problem = "no response"; + zb.addFilter("allow", "domain", value, [&](std::string) { + added = true; + problem.clear(); + }, [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + if (!added) { + skip("the test filter is removed again", "filter was not created"); + return; + } + check("the test filter is removed again", [&]() -> std::string { + std::string problem = "no response"; + zb.deleteFilter("allow", "domain", value, [&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); +} + +// -- Warmup API -------------------------------------------------------------- + +ZeroBounceWarmup& warmupClient() { + static ZeroBounceWarmup zb; + static bool ready = false; + if (!ready) { + zb.initialize(warmupKey()); + ready = true; + } + return zb; +} + +void warmupFree() { + std::cout << "\nWarmup API (reads)\n"; + ZeroBounceWarmup& zb = warmupClient(); + + auto read = [&](const std::string& name, + const std::function& call) { + check(name, [&]() -> std::string { + std::string problem = "no response"; + call([&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + }; + + read("listSendingAccounts", [&](auto ok, auto err) { zb.listSendingAccounts(ok, err); }); + read("listFromAddresses", [&](auto ok, auto err) { zb.listFromAddresses({}, ok, err); }); + read("getEngagementRules", [&](auto ok, auto err) { zb.getEngagementRules(ok, err); }); + read("getSeeds", [&](auto ok, auto err) { zb.getSeeds(ok, err); }); + read("getSeedCount", [&](auto ok, auto err) { zb.getSeedCount(ok, err); }); + read("getProgressToday", [&](auto ok, auto err) { zb.getProgressToday(ok, err); }); + read("getSentToday", [&](auto ok, auto err) { zb.getSentToday(ok, err); }); + read("getEmailsPerDay", [&](auto ok, auto err) { zb.getEmailsPerDay(ok, err); }); + + check("downloadSeeds returns CSV text", [&]() -> std::string { + std::string problem = "no response"; + zb.downloadSeeds([&](std::string csv) { + problem = csv.empty() ? "empty CSV body" : ""; + if (!csv.empty() && csv.front() == '{') { + problem = "got JSON instead of CSV"; + } + }, [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + check("LIVE: checkDomainDmarc rejects a bare domain before sending", [&]() -> std::string { + std::string message = expectFailure("checkDomainDmarc", [&](auto ok, auto err) { + zb.checkDomainDmarc("example.com", std::nullopt, [&](std::string) { ok(); }, err); + }); + if (message.empty()) { + return "checkDomainDmarc with a domain unexpectedly succeeded"; + } + return message.find("email address") != std::string::npos + ? "" + : "unexpected message: " + message; + }); + + check("LIVE: checkDomainDmarc accepts an email address", [&]() -> std::string { + std::string problem = "no response"; + zb.checkDomainDmarc("sender@example.com", std::nullopt, + [&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + // The statistics endpoints need a from-address this team actually has; the + // API answers "Sending account ... was not found" for anything else. + std::string known; + zb.listSendingAccounts([&](std::string body) { + try { + json parsed = json::parse(body); + const json* list = &parsed; + if (parsed.is_object() && parsed.contains("data")) { + list = &parsed["data"]; + } + if (list->is_array() && !list->empty()) { + for (const char* key : {"from_address", "c_from", "fromAddress"}) { + if ((*list)[0].contains(key) && (*list)[0][key].is_string()) { + known = (*list)[0][key].get(); + break; + } + } + } + } catch (const std::exception&) { + } + }, [](ZBErrorResponse) {}); + + if (known.empty()) { + for (const char* name : {"statistics for a registered from-address", + "statistics per day over a short range", + "detailed statistics and the CSV download for that address", + "from-address statistics"}) { + skip(name, "this team has no sending account"); + } + } else { + read("statistics for a registered from-address", + [&](auto ok, auto err) { zb.getStatistics(known, ok, err); }); + check("statistics per day over a short range", [&]() -> std::string { + char start[11]; + char end[11]; + std::tm from = daysAgo(6); + std::tm to = daysAgo(0); + std::strftime(start, sizeof(start), "%Y-%m-%d", &from); + std::strftime(end, sizeof(end), "%Y-%m-%d", &to); + std::string problem = "no response"; + zb.getStatisticsPerDay(known, start, end, [&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + check("detailed statistics and the CSV download for that address", [&]() -> std::string { + std::string problem = "no response"; + zb.getStatisticsDetailed(known, [&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + if (!problem.empty()) { + return problem; + } + problem = "no CSV body"; + zb.downloadStatistics(known, [&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + read("from-address statistics", + [&](auto ok, auto err) { zb.getFromAddressStatistics(known, ok, err); }); + } + + check("an unknown Warmup resource fails with a status code", [&]() -> std::string { + std::string problem; + zb.getCustomEngagement("no-such-address@example.com", [](std::string) {}, + [&](ZBErrorResponse error) { + if (error.statusCode == 0) { + problem = "no status code on the error"; + } else if (error.toString().find(warmupKey()) != std::string::npos) { + problem = "the key leaked into the message"; + } + }); + return problem; + }); +} + +void warmupChanges() { + std::cout << "\nWarmup API (ZEROBOUNCE_LIVE_CHANGES=1)\n"; + ZeroBounceWarmup& zb = warmupClient(); + const std::string address = + "zb-sdk-live-" + std::to_string(std::time(nullptr)) + "@example.com"; + + bool added = false; + check("a self-managed sending account can be added", [&]() -> std::string { + WarmupAddSendingAccountOptions options; + options.engagementRuleId = 0; + options.ignoreConflict = true; + std::string problem = "no response"; + zb.addSendingAccount(address, "self_managed", options, [&](std::string) { + added = true; + problem.clear(); + }, [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); + + if (!added) { + skip("the test sending account is removed again", "account was not created"); + return; + } + check("the test sending account is removed again", [&]() -> std::string { + std::string problem = "no response"; + zb.deleteSendingAccount(address, [&](std::string) { problem.clear(); }, + [&](ZBErrorResponse error) { problem = error.toString(); }); + return problem; + }); +} + +} // namespace + +int main() { + const std::string key = apiKey(); + const std::string warmup = warmupKey(); + const bool spend = flag("ZEROBOUNCE_LIVE_SPEND"); + const bool changes = flag("ZEROBOUNCE_LIVE_CHANGES"); + + std::cout << "ZeroBounce live API suite\n"; + std::cout << " validation key: " << (key.empty() ? "MISSING" : "set") << "\n"; + std::cout << " warmup key: " << (warmup.empty() ? "MISSING" : "set") << "\n"; + std::cout << " flags: SPEND=" << (spend ? 1 : 0) + << " CHANGES=" << (changes ? 1 : 0) << "\n\n"; + + if (!key.empty()) { + validationFree(); + if (spend) { + validationSpend(); + } + if (changes) { + validationChanges(); + } + } + if (!warmup.empty()) { + warmupFree(); + if (changes) { + warmupChanges(); + } + } + + std::cout << "\n" << passed << " passed, " << failed << " failed, " << skipped + << " skipped\n"; + if (!failures.empty()) { + std::cout << "\nFailures:\n"; + for (const auto& line : failures) { + std::cout << " - " << line << "\n"; + } + } + return failed == 0 ? 0 : 1; +} diff --git a/src/HttpError.cpp b/src/HttpError.cpp new file mode 100644 index 0000000..cc998af --- /dev/null +++ b/src/HttpError.cpp @@ -0,0 +1,192 @@ +#include "HttpError.h" + +#include +#include + +namespace { + +std::string trimCopy(const std::string& value) { + size_t begin = 0; + while (begin < value.size() && std::isspace(static_cast(value[begin]))) { + begin++; + } + size_t end = value.size(); + while (end > begin && std::isspace(static_cast(value[end - 1]))) { + end--; + } + return value.substr(begin, end - begin); +} + +std::string lowerCopy(const std::string& value) { + std::string lower; + lower.reserve(value.size()); + for (unsigned char c : value) { + lower.push_back(static_cast(std::tolower(c))); + } + return lower; +} + +std::string htmlTitle(const std::string& body) { + std::string lower = lowerCopy(body); + auto start = lower.find("', start); + if (openEnd == std::string::npos) { + return ""; + } + auto close = lower.find("", openEnd); + if (close == std::string::npos) { + return ""; + } + return trimCopy(body.substr(openEnd + 1, close - openEnd - 1)); +} + +std::string stripErrorPrefix(std::string message) { + for (;;) { + if (message.rfind("Error:", 0) == 0) { + message = trimCopy(message.substr(6)); + continue; + } + if (message.rfind("[Error]:", 0) == 0) { + message = trimCopy(message.substr(8)); + continue; + } + return message; + } +} + +// Pulls the readable message out of an API error body. "detail" is how the +// Inbox Placement Tester (Django REST framework) reports errors. +std::string jsonErrorText(const json& object) { + if (!object.is_object()) { + return ""; + } + for (const char* key : {"error", "message", "error_message", "detail"}) { + if (object.contains(key) && object[key].is_string()) { + std::string value = trimCopy(object[key].get()); + if (!value.empty()) { + return value; + } + } + } + return ""; +} + +bool jsonHasErrorOrMessageKey(const json& object) { + for (auto it = object.begin(); it != object.end(); ++it) { + if (it.key().find("error") != std::string::npos || it.key().find("message") != std::string::npos) { + return true; + } + } + return false; +} + +} // namespace + +bool zbBodyLooksLikeHtml(const std::string& body) { + std::string lower = lowerCopy(body.substr(0, std::min(body.size(), 2000))); + return lower.find("(); + bool accessDenied = title.find("Access denied") != std::string::npos || title.find("1020") != std::string::npos; + bool cloudflare = parsed.contains("cloudflare_error") && parsed["cloudflare_error"] == true; + if (accessDenied && (cloudflare || !jsonHasErrorOrMessageKey(parsed))) { + return title; + } + } + std::string fromField = jsonErrorText(parsed); + if (!fromField.empty()) { + return stripErrorPrefix(fromField); + } + return trimmed; + } catch (const std::exception&) { + return "ZeroBounce returned a response that is not valid JSON."; + } + } + if (trimmed.size() > 1000) { + return "ZeroBounce returned a response that is not valid JSON."; + } + std::string message = stripErrorPrefix(trimmed); + if (message.empty()) { + return "Empty response from ZeroBounce (HTTP " + std::to_string(statusCode) + ")"; + } + return message; +} + +// Whether a "success" field means failure. The API sends it as a JSON false, +// "false" or "False" depending on the endpoint. +static bool zbSuccessMeansFailure(const json& value) { + if (value.is_boolean()) { + return !value.get(); + } + if (value.is_string()) { + std::string text = value.get(); + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return trimCopy(text) == "false"; + } + return false; +} + +void zbReportHttpError( + long statusCode, + const std::string& body, + const std::function& errorCallback +) { + if (!errorCallback) { + return; + } + errorCallback(ZBErrorResponse::parseError(zbMessageFromBody(body, statusCode), statusCode)); +} + +bool zbReadJsonResponse( + long statusCode, + const std::string& body, + json& out, + const std::function& errorCallback +) { + if (statusCode > 299 || zbBodyLooksLikeHtml(body) || trimCopy(body).empty()) { + zbReportHttpError(statusCode, body, errorCallback); + return false; + } + try { + out = json::parse(body); + } catch (const std::exception&) { + zbReportHttpError(statusCode, body, errorCallback); + return false; + } + // LIVE: {"success": false} on an HTTP 200 is a failure, not a result. The + // API answers an unknown bulk file id that way, with a 200. + if (out.is_object() && out.contains("success") && zbSuccessMeansFailure(out["success"])) { + zbReportHttpError(statusCode, body, errorCallback); + return false; + } + return true; +} diff --git a/src/HttpError.h b/src/HttpError.h new file mode 100644 index 0000000..356f2ba --- /dev/null +++ b/src/HttpError.h @@ -0,0 +1,33 @@ +#ifndef ZB_HTTP_ERROR_H +#define ZB_HTTP_ERROR_H + +#include +#include + +#include + +#include "ZeroBounce/ZBErrorResponse.h" + +using json = nlohmann::json; + +bool zbBodyLooksLikeHtml(const std::string& body); +bool zbContentTypeIsHtml(const std::string& contentType); +std::string zbMessageFromBody(const std::string& body, long statusCode); +void zbReportHttpError( + long statusCode, + const std::string& body, + const std::function& errorCallback +); + +/** + * On HTTP failure, HTML, an empty body, or a body that is not JSON, reports a short + * error and returns false. On a JSON body with status < 300, writes it to `out`. + */ +bool zbReadJsonResponse( + long statusCode, + const std::string& body, + json& out, + const std::function& errorCallback +); + +#endif diff --git a/src/Priority3.cpp b/src/Priority3.cpp new file mode 100644 index 0000000..0a46fe7 --- /dev/null +++ b/src/Priority3.cpp @@ -0,0 +1,1005 @@ +#include +#include +#include + +#include "ZeroBounce/ZeroBounce.h" +#include "ZeroBounce/ZBS3ListFileEntry.h" +#include "HttpError.h" + +using json = nlohmann::json; + +namespace { + +std::string p3UrlEncode(const std::string& value) { + char* escaped = curl_easy_escape(nullptr, value.c_str(), static_cast(value.size())); + if (!escaped) { + throw std::bad_alloc(); + } + std::string result(escaped); + curl_free(escaped); + return result; +} + +bool requireApiKey(ZeroBounce* zb, OnErrorCallback errorCallback) { + if (!zb->getApiKey().empty()) { + return true; + } + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError( + "ZeroBounce is not initialized. Please call ZeroBounce::getInstance()->initialize(apiKey) first" + )); + } + return false; +} + +void postSendFileForm( + ZeroBounce* zb, + const std::string& url, + const std::vector>& fields, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (!requireApiKey(zb, errorCallback)) { + return; + } + try { + std::string body; + for (size_t i = 0; i < fields.size(); ++i) { + if (i > 0) { + body += "&"; + } + body += p3UrlEncode(fields[i].first) + "=" + p3UrlEncode(fields[i].second); + } + cpr::Response reqResponse = zb->getRequestHandler()->Post( + cpr::Url{url}, + cpr::Header{ + {"Accept", "application/json"}, + {"Content-Type", "application/x-www-form-urlencoded"} + }, + cpr::Body{body} + ); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(ZBSendFileResponse::from_json(parsed)); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); + } + } +} + +void sendRawJsonGet( + ZeroBounce* zb, + const std::string& url, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (!requireApiKey(zb, errorCallback)) { + return; + } + try { + cpr::Response reqResponse = zb->getRequestHandler()->Get( + cpr::Url{url}, + cpr::Header{{"Accept", "application/json"}} + ); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(parsed.dump()); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); + } + } +} + +void sendRawJsonFormPost( + ZeroBounce* zb, + const std::string& url, + const std::vector>& fields, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (!requireApiKey(zb, errorCallback)) { + return; + } + try { + std::string body; + for (size_t i = 0; i < fields.size(); ++i) { + if (i > 0) { + body += "&"; + } + body += p3UrlEncode(fields[i].first) + "=" + p3UrlEncode(fields[i].second); + } + cpr::Response reqResponse = zb->getRequestHandler()->Post( + cpr::Url{url}, + cpr::Header{ + {"Accept", "application/json"}, + {"Content-Type", "application/x-www-form-urlencoded"} + }, + cpr::Body{body} + ); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(parsed.dump()); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); + } + } +} + +void bulkRootMultipart( + ZeroBounce* zb, + const std::string& url, + const std::string& filePath, + const std::vector>& fields, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (!requireApiKey(zb, errorCallback)) { + return; + } + try { + cpr::Multipart multipart{{"api_key", zb->getApiKey()}, {"file", cpr::File{filePath}}}; + for (const auto& field : fields) { + multipart.parts.emplace_back(cpr::Part{field.first, field.second}); + } + cpr::Response reqResponse = zb->getRequestHandler()->Post( + cpr::Url{url}, + cpr::Header{{"Content-Type", "multipart/form-data"}}, + multipart + ); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(ZBSendFileResponse::from_json(parsed)); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); + } + } +} + +std::vector> emailFinderFields( + int domainColumn, + const EmailFinderSendFileOptions* options +) { + std::vector> fields{{"domain_column", std::to_string(domainColumn)}}; + if (options != nullptr) { + if (options->hasHeaderRow.has_value()) { + fields.emplace_back("has_header_row", *options->hasHeaderRow ? "true" : "false"); + } + if (options->firstNameColumn > 0) { + fields.emplace_back("first_name_column", std::to_string(options->firstNameColumn)); + } + if (options->lastNameColumn > 0) { + fields.emplace_back("last_name_column", std::to_string(options->lastNameColumn)); + } + if (options->middleNameColumn > 0) { + fields.emplace_back("middle_name_column", std::to_string(options->middleNameColumn)); + } + if (options->fullNameColumn > 0) { + fields.emplace_back("full_name_column", std::to_string(options->fullNameColumn)); + } + } + return fields; +} + +} // namespace + +void ZeroBounce::sendEmailFinderFile( + std::string filePath, + int domainColumn, + const EmailFinderSendFileOptions* options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (invalidApiKey(errorCallback)) { + return; + } + if (domainColumn < 1) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: domain_column")); + return; + } + bulkRootMultipart( + this, + bulkApiRootUrl + "/email-finder/sendfile", + filePath, + emailFinderFields(domainColumn, options), + successCallback, + errorCallback + ); +} + +void ZeroBounce::emailFinderFileStatus( + std::string fileId, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (invalidApiKey(errorCallback)) { + return; + } + sendRequest( + bulkApiRootUrl + "/email-finder/filestatus?api_key=" + p3UrlEncode(apiKey) + "&file_id=" + p3UrlEncode(fileId), + successCallback, + errorCallback + ); +} + +void ZeroBounce::emailFinderGetFile( + std::string fileId, + std::string localDownloadPath, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (invalidApiKey(errorCallback)) { + return; + } + try { + cpr::Response reqResponse = requestHandler->Get( + cpr::Url{bulkApiRootUrl + "/email-finder/getfile?api_key=" + p3UrlEncode(apiKey) + "&file_id=" + p3UrlEncode(fileId)} + ); + std::string rsp = reqResponse.text; + std::string contentType = reqResponse.header["Content-Type"]; + if (reqResponse.status_code > 299 || zbContentTypeIsHtml(contentType) || zbBodyLooksLikeHtml(rsp)) { + zbReportHttpError(reqResponse.status_code, rsp, errorCallback); + return; + } + if (ZeroBounce::getFileJsonIndicatesError(rsp)) { + zbReportHttpError(reqResponse.status_code, rsp, errorCallback); + return; + } + std::ofstream out(localDownloadPath, std::ios::binary); + out.write(rsp.data(), static_cast(rsp.size())); + ZBGetFileResponse response; + response.localFilePath = localDownloadPath; + successCallback(response); + } catch (const std::exception&) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected response.")); + } +} + +void ZeroBounce::emailFinderDeleteFile( + std::string fileId, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + sendRequest( + bulkApiRootUrl + "/email-finder/deletefile?api_key=" + p3UrlEncode(apiKey) + "&file_id=" + p3UrlEncode(fileId), + successCallback, + errorCallback + ); +} + +void ZeroBounce::sendDomainSearchFile( + std::string filePath, + int domainColumn, + const DomainSearchSendFileOptions* options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (domainColumn < 1) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: domain_column")); + return; + } + std::vector> fields{{"domain_column", std::to_string(domainColumn)}}; + if (options != nullptr && options->hasHeaderRow.has_value()) { + fields.emplace_back("has_header_row", *options->hasHeaderRow ? "true" : "false"); + } + bulkRootMultipart( + this, + bulkApiRootUrl + "/domain-search/sendfile", + filePath, + fields, + successCallback, + errorCallback + ); +} + +void ZeroBounce::domainSearchFileStatus( + std::string fileId, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + sendRequest( + bulkApiRootUrl + "/domain-search/filestatus?api_key=" + p3UrlEncode(apiKey) + "&file_id=" + p3UrlEncode(fileId), + successCallback, + errorCallback + ); +} + +void ZeroBounce::domainSearchGetFile( + std::string fileId, + std::string localDownloadPath, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (invalidApiKey(errorCallback)) { + return; + } + try { + cpr::Response reqResponse = requestHandler->Get( + cpr::Url{bulkApiRootUrl + "/domain-search/getfile?api_key=" + p3UrlEncode(apiKey) + "&file_id=" + p3UrlEncode(fileId)} + ); + std::string rsp = reqResponse.text; + std::string contentType = reqResponse.header["Content-Type"]; + if (reqResponse.status_code > 299 || zbContentTypeIsHtml(contentType) || zbBodyLooksLikeHtml(rsp)) { + zbReportHttpError(reqResponse.status_code, rsp, errorCallback); + return; + } + if (ZeroBounce::getFileJsonIndicatesError(rsp)) { + zbReportHttpError(reqResponse.status_code, rsp, errorCallback); + return; + } + std::ofstream out(localDownloadPath, std::ios::binary); + out.write(rsp.data(), static_cast(rsp.size())); + ZBGetFileResponse response; + response.localFilePath = localDownloadPath; + successCallback(response); + } catch (const std::exception&) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected response.")); + } +} + +void ZeroBounce::domainSearchDeleteFile( + std::string fileId, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + sendRequest( + bulkApiRootUrl + "/domain-search/deletefile?api_key=" + p3UrlEncode(apiKey) + "&file_id=" + p3UrlEncode(fileId), + successCallback, + errorCallback + ); +} + +void ZeroBounce::sendListEvaluatorFile( + std::string filePath, + int emailAddressColumn, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (emailAddressColumn < 1) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: email_address_column")); + return; + } + bulkRootMultipart( + this, + listEvaluatorBaseUrl + "/", + filePath, + {{"email_address_column", std::to_string(emailAddressColumn)}}, + successCallback, + errorCallback + ); +} + +void ZeroBounce::getListEvaluatorResult( + std::string fileId, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + sendRawJsonGet( + this, + listEvaluatorBaseUrl + "/" + p3UrlEncode(fileId) + "/?api_key=" + p3UrlEncode(apiKey), + successCallback, + errorCallback + ); +} + +void ZeroBounce::sendEvaluatedFile( + std::string fileId, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (!requireApiKey(this, errorCallback)) { + return; + } + if (fileId.find_first_not_of(" \t\r\n") == std::string::npos) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: file_id")); + } + return; + } + // LIVE: this takes the list evaluator's file_id as a form field, not the + // file upload the docs describe - an upload is rejected with + // "file_id: This field is required". + postSendFileForm( + this, + bulkApiBaseUrl + "/send-evaluated-file/", + {{"api_key", apiKey}, {"file_id", fileId}}, + successCallback, + errorCallback + ); +} + +void ZeroBounce::addFilter( + std::string rule, + std::string target, + std::string value, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + sendRawJsonFormPost( + this, + apiBaseUrl + "/filters/add", + {{"api_key", apiKey}, {"rule", rule}, {"target", target}, {"value", value}}, + successCallback, + errorCallback + ); +} + +void ZeroBounce::listFilters(bool usePost, OnRawJsonSuccessCallback successCallback, OnErrorCallback errorCallback) { + if (usePost) { + sendRawJsonFormPost(this, apiBaseUrl + "/filters/list", {{"api_key", apiKey}}, successCallback, errorCallback); + } else { + sendRawJsonGet(this, apiBaseUrl + "/filters/list?api_key=" + p3UrlEncode(apiKey), successCallback, errorCallback); + } +} + +void ZeroBounce::deleteFilter( + std::string rule, + std::string target, + std::string value, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + sendRawJsonFormPost( + this, + apiBaseUrl + "/filters/delete", + {{"api_key", apiKey}, {"rule", rule}, {"target", target}, {"value", value}}, + successCallback, + errorCallback + ); +} + + +// The documented fields shared by the six S3 / SFTP file jobs. Booleans go on +// the wire as the strings "true"/"false", and an option the caller left unset +// is not sent at all. +// LIVE: file_name and return_url are required on every one of these jobs, +// validateSFTPfile included, although the docs list file_name as optional for +// that one. Without either the API answers "This field is required". +static bool bulkFileJobFields( + const BulkFileJobOptions& options, + const std::string& apiKey, + std::vector>& fields, + OnErrorCallback errorCallback +) { + const bool blankName = !options.fileName.has_value() || + options.fileName->find_first_not_of(" \t\r\n") == std::string::npos; + if (blankName) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: file_name")); + } + return false; + } + const bool blankReturnUrl = !options.returnUrl.has_value() || + options.returnUrl->find_first_not_of(" \t\r\n") == std::string::npos; + if (blankReturnUrl) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: return_url")); + } + return false; + } + fields.emplace_back("has_header_row", options.hasHeaderRow ? "true" : "false"); + fields.emplace_back("file_name", *options.fileName); + fields.emplace_back("return_url", *options.returnUrl); + if (options.removeDuplicates.has_value()) { + fields.emplace_back("remove_duplicates", *options.removeDuplicates ? "true" : "false"); + } + fields.emplace_back("api_key", apiKey); + return true; +} + +void ZeroBounce::validateS3File( + const ValidateS3FileOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (options.fileName.empty()) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: file_name")); + 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)); + } + if (options.lastNameColumn > 0) { + fields.emplace_back("last_name_column", std::to_string(options.lastNameColumn)); + } + if (options.genderColumn > 0) { + fields.emplace_back("gender_column", std::to_string(options.genderColumn)); + } + if (options.ipAddressColumn > 0) { + fields.emplace_back("ip_address_column", std::to_string(options.ipAddressColumn)); + } + 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); +} + +void ZeroBounce::scoreS3File( + const ScoreS3FileOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (options.fileName.empty()) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: file_name")); + 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)); + } + postSendFileForm(this, bulkApiRootUrl + "/scoreS3file", fields, successCallback, errorCallback); +} + +void ZeroBounce::listS3files( + std::optional bucket, + std::optional search, + OnSuccessCallback> successCallback, + OnErrorCallback errorCallback +) { + if (invalidApiKey(errorCallback)) { + return; + } + std::string url = bulkApiRootUrl + "/listS3files?api_key=" + p3UrlEncode(apiKey); + if (bucket.has_value() && !bucket->empty()) { + url += "&bucket=" + p3UrlEncode(*bucket); + } + if (search.has_value() && !search->empty()) { + url += "&search=" + p3UrlEncode(*search); + } + try { + cpr::Response reqResponse = getRequestHandler()->Get( + cpr::Url{url}, + cpr::Header{{"Accept", "application/json"}} + ); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(ZBS3ListFileEntry::listFromJson(parsed)); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); + } + } +} + +void ZeroBounce::exportS3File( + std::string fileId, + std::string exportBucket, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (fileId.empty()) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: file_id")); + return; + } + if (exportBucket.empty()) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: export_bucket")); + return; + } + postSendFileForm( + this, + bulkApiRootUrl + "/exportS3file", + {{"file_id", fileId}, {"export_bucket", exportBucket}, {"api_key", apiKey}}, + successCallback, + errorCallback + ); +} + +void ZeroBounce::emailFinderS3File( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + std::vector> fields; + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { + return; + } + postSendFileForm(this, bulkApiRootUrl + "/emailFinderS3file", fields, successCallback, errorCallback); +} + +void ZeroBounce::domainSearchS3File( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + std::vector> fields; + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { + return; + } + postSendFileForm(this, bulkApiRootUrl + "/domainSearchS3file", fields, successCallback, errorCallback); +} + +// SFTP/FTP bulk. The server, path and protocol come from the account's settings. + +void ZeroBounce::validateSFTPFile( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + std::vector> fields; + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { + return; + } + postSendFileForm(this, bulkApiRootUrl + "/validateSFTPfile", fields, successCallback, errorCallback); +} + +void ZeroBounce::scoreSFTPFile( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + std::vector> fields; + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { + return; + } + postSendFileForm(this, bulkApiRootUrl + "/scoreSFTPfile", fields, successCallback, errorCallback); +} + +void ZeroBounce::emailFinderSFTPFile( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + std::vector> fields; + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { + return; + } + postSendFileForm(this, bulkApiRootUrl + "/emailFinderSFTPfile", fields, successCallback, errorCallback); +} + +void ZeroBounce::domainSearchSFTPFile( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + std::vector> fields; + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { + return; + } + postSendFileForm(this, bulkApiRootUrl + "/domainSearchSFTPfile", fields, successCallback, errorCallback); +} + +void ZeroBounce::exportSFTPFile( + std::string fileId, + std::optional exportPath, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (fileId.find_first_not_of(" \t\r\n") == std::string::npos) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: file_id")); + } + return; + } + std::vector> fields{{"file_id", fileId}}; + if (exportPath.has_value()) { + if (exportPath->find_first_not_of(" \t\r\n") == std::string::npos) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: export_path")); + } + return; + } + // export_path, not the export_bucket exportS3File sends. + fields.emplace_back("export_path", *exportPath); + } + fields.emplace_back("api_key", apiKey); + 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. + +namespace { + +bool iptTestPath(const std::string& testId, std::string& out, OnErrorCallback errorCallback) { + if (testId.find_first_not_of(" \t\r\n") == std::string::npos) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: test_id")); + } + return false; + } + auto first = testId.find_first_not_of(" \t\r\n"); + auto last = testId.find_last_not_of(" \t\r\n"); + out = p3UrlEncode(testId.substr(first, last - first + 1)); + return true; +} + +void iptRequest( + ZeroBounce* zb, + const std::string& method, + const std::string& url, + const std::string* jsonBody, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (!requireApiKey(zb, errorCallback)) { + return; + } + // LIVE: the Inbox Placement Tester authenticates with a Bearer header. + // Sent as an api_key parameter the request is rejected as unauthorized. + const std::string bearer = "Bearer " + zb->getApiKey(); + try { + cpr::Response reqResponse; + if (method == "POST") { + reqResponse = zb->getRequestHandler()->Post( + cpr::Url{url}, + cpr::Header{ + {"Accept", "application/json"}, + {"Content-Type", "application/json"}, + {"Authorization", bearer} + }, + cpr::Body{jsonBody ? *jsonBody : std::string{}} + ); + } else if (method == "PUT") { + reqResponse = zb->getRequestHandler()->Put( + cpr::Url{url}, + cpr::Header{ + {"Accept", "application/json"}, + {"Content-Type", "application/json"}, + {"Authorization", bearer} + }, + cpr::Body{jsonBody ? *jsonBody : std::string{}} + ); + } else if (method == "DELETE") { + reqResponse = zb->getRequestHandler()->Delete( + cpr::Url{url}, + cpr::Header{{"Accept", "application/json"}, {"Authorization", bearer}} + ); + } else { + reqResponse = zb->getRequestHandler()->Get( + cpr::Url{url}, + cpr::Header{{"Accept", "application/json"}, {"Authorization", bearer}} + ); + } + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(parsed.dump()); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); + } + } +} + +} // namespace + +void ZeroBounce::setListEvaluatorBaseUrl(const std::string& url) { + listEvaluatorBaseUrl = url; +} + +void ZeroBounce::setInboxPlacementBaseUrl(const std::string& url) { + inboxPlacementBaseUrl = url; +} + +void ZeroBounce::getInboxPlacementProviders( + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + iptRequest(this, "GET", + inboxPlacementBaseUrl + "/providers", + nullptr, successCallback, errorCallback); +} + +void ZeroBounce::getInboxPlacementTestTypes( + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + iptRequest(this, "GET", + inboxPlacementBaseUrl + "/test-types", + nullptr, successCallback, errorCallback); +} + +void ZeroBounce::createInboxPlacementTest( + const std::string& name, + const CreateInboxPlacementTestOptions& options, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (name.find_first_not_of(" \t\r\n") == std::string::npos) { + if (errorCallback) errorCallback(ZBErrorResponse::parseError("Empty parameter: name")); + return; + } + if (name.size() > 64) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Invalid parameter: name (at most 64 characters)")); + } + return; + } + if (options.testType.has_value()) { + int testType = options.testType.value(); + if (testType != 1 && testType != 2 && testType != 3) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Invalid parameter: test_type (must be 1, 2 or 3)")); + } + return; + } + bool senderMissing = !options.senderEmail.has_value() || + options.senderEmail->find_first_not_of(" \t\r\n") == std::string::npos; + if (testType == 1 && senderMissing) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError( + "Empty parameter: sender_email (required when test_type is 1)")); + } + return; + } + } + if (options.providers.has_value()) { + for (const auto& provider : options.providers.value()) { + if (provider.maxSeeds.has_value() && provider.maxSeeds.value() < 1) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError( + "Invalid parameter: providers (max_seeds must be at least 1)")); + } + return; + } + } + } + + json body; + body["name"] = name; + if (options.testType.has_value()) body["testType"] = options.testType.value(); + if (options.senderEmail.has_value()) body["senderEmail"] = options.senderEmail.value(); + if (options.providerIds.has_value()) body["providerIds"] = options.providerIds.value(); + if (options.seedIds.has_value()) body["seedIds"] = options.seedIds.value(); + if (options.providers.has_value()) { + json providers = json::array(); + for (const auto& provider : options.providers.value()) { + json entry; + entry["id"] = provider.id; + if (provider.maxSeeds.has_value()) entry["maxSeeds"] = provider.maxSeeds.value(); + providers.push_back(entry); + } + body["providers"] = providers; + } + if (options.webhookUrl.has_value()) body["webhookUrl"] = options.webhookUrl.value(); + + std::string encoded = body.dump(); + iptRequest(this, "POST", + inboxPlacementBaseUrl + "/test", + &encoded, successCallback, errorCallback); +} + +void ZeroBounce::getInboxPlacementTest( + const std::string& testId, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + std::string path; + if (!iptTestPath(testId, path, errorCallback)) return; + iptRequest(this, "GET", + inboxPlacementBaseUrl + "/test/" + path, + nullptr, successCallback, errorCallback); +} + +void ZeroBounce::listInboxPlacementTests( + const ListInboxPlacementTestsOptions& options, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (options.status.has_value() && + options.status.value() != "running" && options.status.value() != "finished") { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Invalid parameter: status (must be running or finished)")); + } + return; + } + if (options.page.has_value() && options.page.value() < 1) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Invalid parameter: page (must be at least 1)")); + } + return; + } + if (options.pageSize.has_value() && + (options.pageSize.value() < 1 || options.pageSize.value() > 100)) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Invalid parameter: page_size (must be between 1 and 100)")); + } + return; + } + + // The listing is GET /tests (plural); /test is the single-test path and + // answers "Method \"GET\" not allowed". + std::string url = inboxPlacementBaseUrl + "/tests"; + std::string separator = "?"; + auto addParam = [&url, &separator](const std::string& name, const std::string& value) { + url += separator + name + "=" + value; + separator = "&"; + }; + if (options.status.has_value()) addParam("status", p3UrlEncode(options.status.value())); + if (options.createdAfter.has_value()) addParam("createdAfter", p3UrlEncode(options.createdAfter.value())); + if (options.createdBefore.has_value()) addParam("createdBefore", p3UrlEncode(options.createdBefore.value())); + if (options.page.has_value()) addParam("page", std::to_string(options.page.value())); + if (options.pageSize.has_value()) addParam("page_size", std::to_string(options.pageSize.value())); + iptRequest(this, "GET", url, nullptr, successCallback, errorCallback); +} + +void ZeroBounce::deleteInboxPlacementTest( + const std::string& testId, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + std::string path; + if (!iptTestPath(testId, path, errorCallback)) return; + iptRequest(this, "DELETE", + inboxPlacementBaseUrl + "/test/" + path, + nullptr, successCallback, errorCallback); +} + +void ZeroBounce::getInboxPlacementWebhook( + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + iptRequest(this, "GET", + inboxPlacementBaseUrl + "/webhook", + nullptr, successCallback, errorCallback); +} + +void ZeroBounce::setInboxPlacementWebhook( + const std::string& webhookUrl, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (webhookUrl.find_first_not_of(" \t\r\n") == std::string::npos) { + if (errorCallback) errorCallback(ZBErrorResponse::parseError("Empty parameter: webhook_url")); + return; + } + json body; + body["webhookUrl"] = webhookUrl; + std::string encoded = body.dump(); + iptRequest(this, "PUT", + inboxPlacementBaseUrl + "/webhook", + &encoded, successCallback, errorCallback); +} diff --git a/src/ZBErrorResponse.cpp b/src/ZBErrorResponse.cpp index 995c69c..3bd34bf 100644 --- a/src/ZBErrorResponse.cpp +++ b/src/ZBErrorResponse.cpp @@ -11,6 +11,9 @@ ZBErrorResponse::ZBErrorResponse() {} ZBErrorResponse::ZBErrorResponse(bool success, std::vector errors) : success(success), errors(errors) {} +ZBErrorResponse::ZBErrorResponse(bool success, std::vector errors, long statusCode) : + success(success), errors(errors), statusCode(statusCode) {} + std::string ZBErrorResponse::toString() { std::stringstream stringStream; @@ -25,7 +28,7 @@ std::string ZBErrorResponse::toString() } } - stringStream << "]}"; + stringStream << "], statusCode=" << statusCode << "}"; return stringStream.str(); } @@ -72,6 +75,12 @@ ZBErrorResponse ZBErrorResponse::parseError(std::string error) { return response; } +ZBErrorResponse ZBErrorResponse::parseError(std::string error, long statusCode) { + ZBErrorResponse response = parseError(std::move(error)); + response.statusCode = statusCode; + return response; +} + bool ZBErrorResponse::operator==(const ZBErrorResponse& other) const { return success == other.success && errors == other.errors; diff --git a/src/ZBGetApiUsageResponse.cpp b/src/ZBGetApiUsageResponse.cpp index 6849087..813457f 100644 --- a/src/ZBGetApiUsageResponse.cpp +++ b/src/ZBGetApiUsageResponse.cpp @@ -88,6 +88,8 @@ ZBGetApiUsageResponse ZBGetApiUsageResponse::from_json(const json& j) { r.subStatusAllowed = getOrDefault(j, "sub_status_allowed", 0); r.subStatusGold = getOrDefault(j, "sub_status_gold", 0); r.subStatusRoleBasedAcceptAll = getOrDefault(j, "sub_status_role_based_accept_all", 0); + r.subStatusLeadingPeriodRemoved = getOrDefault(j, "sub_status_leading_period_removed", 0); + r.subStatusAliasAddress = getOrDefault(j, "sub_status_alias_address", 0); r.startDate = getOrDefault(j, "start_date", ""); r.endDate = getOrDefault(j, "end_date", ""); r.error = getOrDefault(j, "error", ""); diff --git a/src/ZBS3ListFileEntry.cpp b/src/ZBS3ListFileEntry.cpp new file mode 100644 index 0000000..8d30636 --- /dev/null +++ b/src/ZBS3ListFileEntry.cpp @@ -0,0 +1,26 @@ +#include "ZeroBounce/ZBS3ListFileEntry.h" + +#include "ZeroBounce/utils.h" + +ZBS3ListFileEntry ZBS3ListFileEntry::from_json(const json& j) { + ZBS3ListFileEntry entry; + entry.name = getOrDefault(j, "name", ""); + entry.size = getOrDefault(j, "size", 0); + entry.lastModified = getOrDefault(j, "last_modified", ""); + return entry; +} + +std::vector ZBS3ListFileEntry::listFromJson(const json& j) { + std::vector entries; + if (!j.is_array()) { + return entries; + } + for (const auto& item : j) { + entries.push_back(from_json(item)); + } + return entries; +} + +bool ZBS3ListFileEntry::operator==(const ZBS3ListFileEntry& other) const { + return name == other.name && size == other.size && lastModified == other.lastModified; +} diff --git a/src/ZBScoreEmailResponse.cpp b/src/ZBScoreEmailResponse.cpp new file mode 100644 index 0000000..ca36ba2 --- /dev/null +++ b/src/ZBScoreEmailResponse.cpp @@ -0,0 +1,53 @@ +#include + +#include "ZeroBounce/utils.h" +#include "ZeroBounce/ZBScoreEmailResponse.h" + +std::string ZBScoreEmailResponse::toString() { + std::stringstream stringStream; + stringStream << "ZBScoreEmailResponse{" + << "email='"; + if (email.has_value()) { + stringStream << email.value(); + } + stringStream << '\'' + << ", score="; + if (score.has_value()) { + stringStream << score.value(); + } else { + stringStream << "null"; + } + stringStream << '}'; + return stringStream.str(); +} + +static std::optional parseScoreField(const json& j) { + if (!j.contains("score") || j["score"].is_null()) { + return std::nullopt; + } + if (j["score"].is_number_integer()) { + return j["score"].get(); + } + if (j["score"].is_number()) { + return static_cast(j["score"].get()); + } + if (j["score"].is_string()) { + return std::stoi(j["score"].get()); + } + return std::nullopt; +} + +ZBScoreEmailResponse ZBScoreEmailResponse::from_json(const json& j) { + ZBScoreEmailResponse r; + + if (j.contains("email") && !j["email"].is_null()) { + r.email = j["email"].get(); + } + r.score = parseScoreField(j); + + return r; +} + +bool ZBScoreEmailResponse::operator==(const ZBScoreEmailResponse& other) const { + return email == other.email && score == other.score; +} diff --git a/src/ZBValidateRespone.cpp b/src/ZBValidateRespone.cpp index cc1b10d..57f8b82 100644 --- a/src/ZBValidateRespone.cpp +++ b/src/ZBValidateRespone.cpp @@ -19,6 +19,8 @@ std::string ZBValidateResponse::toString(bool isBatch) ", account='" << account << '\'' << ", domain='" << domain << '\'' << ", domainAgeDays='" << domainAgeDays << '\'' << + ", activeInDays='" << activeInDays << '\'' << + ", activeFirstSeen='" << activeFirstSeen << '\'' << ", smtpProvider='" << smtpProvider << '\'' << ", catchallDomain=" << (catchallDomain.has_value() ? (catchallDomain.value() ? "true" : "false") : "null") << ", mxFound=" << mxFound << @@ -30,7 +32,10 @@ std::string ZBValidateResponse::toString(bool isBatch) ", region='" << region << '\'' << ", city='" << city << '\'' << ", zipCode='" << zipCode << '\'' << - ", processedAt='" << processedAt << '\''; + ", processedAt='" << processedAt << '\'' << + ", domainWebsiteExists='" << domainWebsiteExists << '\'' << + ", domainRegistrantCompanyName='" << domainRegistrantCompanyName << '\'' << + ", message='" << message << '\''; if (!isBatch) { stringStream << ", error='" << error << '\''; @@ -52,6 +57,8 @@ ZBValidateResponse ZBValidateResponse::from_json(const json& j) { r.account = getOrDefault(j, "account", ""); r.domain = getOrDefault(j, "domain", ""); r.domainAgeDays = getOrDefault(j, "domain_age_days", ""); + r.activeInDays = getOrDefault(j, "active_in_days", ""); + r.activeFirstSeen = getOrDefault(j, "active_first_seen", ""); r.smtpProvider = getOrDefault(j, "smtp_provider", ""); if (j.contains("catchall_domain") && !j["catchall_domain"].is_null()) { @@ -72,6 +79,10 @@ ZBValidateResponse ZBValidateResponse::from_json(const json& j) { r.city = getOrDefault(j, "city", ""); r.zipCode = getOrDefault(j, "zipcode", ""); r.processedAt = getOrDefault(j, "processed_at", ""); + r.domainWebsiteExists = getOrDefault(j, "domain_website_exists", ""); + r.domainRegistrantCompanyName = getOrDefault(j, "domain_registrant_company_name", ""); + // A greylist pickup that is still running returns "Message" instead of a result. + r.message = getOrDefault(j, "Message", ""); r.error = getOrDefault(j, "error", ""); return r; @@ -79,12 +90,14 @@ ZBValidateResponse ZBValidateResponse::from_json(const json& j) { bool ZBValidateResponse::operator==(const ZBValidateResponse& other) const { return address == other.address && - status == other.status; - subStatus == other.subStatus; + status == other.status && + subStatus == other.subStatus && account == other.account && domain == other.domain && didYouMean == other.didYouMean && domainAgeDays == other.domainAgeDays && + activeInDays == other.activeInDays && + activeFirstSeen == other.activeFirstSeen && freeEmail == other.freeEmail && mxFound == other.mxFound && mxRecord == other.mxRecord && diff --git a/src/ZeroBounce.cpp b/src/ZeroBounce.cpp index f4eeab4..33c2063 100644 --- a/src/ZeroBounce.cpp +++ b/src/ZeroBounce.cpp @@ -9,6 +9,7 @@ #include #include "ZeroBounce/ZeroBounce.h" +#include "HttpError.h" namespace fs = std::filesystem; using json = nlohmann::json; @@ -182,29 +183,159 @@ void ZeroBounce::getApiUsage( ); } +static bool checkValidateTimeout(const std::optional& timeout, OnErrorCallback errorCallback) { + if (!timeout.has_value()) { + return true; + } + int t = timeout.value(); + if (t >= 3 && t <= 60) { + return true; + } + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError( + "Invalid parameter: timeout (must be between 3 and 60 seconds)" + )); + } + return false; +} + +static bool checkValidateBatchTimeout(const std::optional& timeout, OnErrorCallback errorCallback) { + if (!timeout.has_value()) { + return true; + } + int t = timeout.value(); + if (t >= 10 && t <= 120) { + return true; + } + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError( + "Invalid parameter: timeout (must be between 10 and 120 seconds)" + )); + } + return false; +} + +void ZeroBounce::validate( + std::string email, + std::string ipAddress, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + validate(email, ipAddress, ZBValidateOptions{}, successCallback, errorCallback); +} + void ZeroBounce::validate( std::string email, std::string ipAddress, + const ZBValidateOptions& options, OnSuccessCallback successCallback, OnErrorCallback errorCallback ) { if (invalidApiKey(errorCallback)) return; + if (!checkValidateTimeout(options.timeout, errorCallback)) return; + + // callbackUrl only means anything while greylist processing is on, so it is + // rejected here rather than letting the API take the request. + if (options.callbackUrl.has_value()) { + if (options.greylistProcessing != std::optional(true)) { + errorCallback(ZBErrorResponse::parseError( + "Invalid parameter: callback_url requires greylist_processing = true")); + return; + } + if (options.callbackUrl->find_first_not_of(" \t\r\n") == std::string::npos) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: callback_url")); + return; + } + } - sendRequest( - apiBaseUrl + "/validate?api_key=" + urlEncode(apiKey) + + if (options.usePost) { + std::string body = + "api_key=" + urlEncode(apiKey) + "&email=" + urlEncode(email) + - "&ip_address=" + (ipAddress.empty() ? "" : urlEncode(ipAddress)), - successCallback, - errorCallback - ); + "&ip_address=" + (ipAddress.empty() ? "" : urlEncode(ipAddress)); + if (options.timeout.has_value()) { + body += "&timeout=" + urlEncode(std::to_string(options.timeout.value())); + } + if (options.activityData.has_value()) { + body += "&activity_data=" + urlEncode(options.activityData.value() ? "true" : "false"); + } + if (options.verifyPlus.has_value()) { + body += "&verify_plus=" + urlEncode(options.verifyPlus.value() ? "true" : "false"); + } + if (options.domainInfo.has_value()) { + body += "&domain_info=" + urlEncode(options.domainInfo.value() ? "true" : "false"); + } + if (options.greylistProcessing.has_value()) { + body += "&greylist_processing=" + urlEncode(options.greylistProcessing.value() ? "true" : "false"); + } + if (options.callbackUrl.has_value()) { + body += "&callback_url=" + urlEncode(options.callbackUrl.value()); + } + try { + cpr::Response reqResponse = requestHandler->Post( + cpr::Url{apiBaseUrl + "/validate"}, + cpr::Header{ + {"Accept", "application/json"}, + {"Content-Type", "application/x-www-form-urlencoded"} + }, + cpr::Body{body} + ); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(ZBValidateResponse::from_json(parsed)); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); + } + } + return; + } + + std::string url = apiBaseUrl + "/validate?api_key=" + urlEncode(apiKey) + + "&email=" + urlEncode(email) + + "&ip_address=" + (ipAddress.empty() ? "" : urlEncode(ipAddress)); + if (options.timeout.has_value()) { + url += "&timeout=" + urlEncode(std::to_string(options.timeout.value())); + } + if (options.activityData.has_value()) { + url += "&activity_data=" + urlEncode(options.activityData.value() ? "true" : "false"); + } + if (options.verifyPlus.has_value()) { + url += "&verify_plus=" + urlEncode(options.verifyPlus.value() ? "true" : "false"); + } + if (options.domainInfo.has_value()) { + url += "&domain_info=" + urlEncode(options.domainInfo.value() ? "true" : "false"); + } + if (options.greylistProcessing.has_value()) { + url += "&greylist_processing=" + urlEncode(options.greylistProcessing.value() ? "true" : "false"); + } + if (options.callbackUrl.has_value()) { + url += "&callback_url=" + urlEncode(options.callbackUrl.value()); + } + + sendRequest(url, successCallback, errorCallback); +} + +void ZeroBounce::validateBatch( + std::vector emailBatch, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + validateBatch(emailBatch, ZBValidateOptions{}, successCallback, errorCallback); } void ZeroBounce::validateBatch( std::vector emailBatch, + const ZBValidateOptions& options, OnSuccessCallback successCallback, OnErrorCallback errorCallback ) { if (invalidApiKey(errorCallback)) return; + if (!checkValidateBatchTimeout(options.timeout, errorCallback)) return; try { json payload; @@ -223,6 +354,19 @@ void ZeroBounce::validateBatch( payload["email_batch"].push_back(emailObj); } + if (options.timeout.has_value()) { + // LIVE: validatebatch takes timeout as a JSON *string*. Sent as a + // number the API rejects the whole batch with the account-level + // "Invalid API Key or your account ran out of credits". + payload["timeout"] = std::to_string(options.timeout.value()); + } + if (options.activityData.has_value()) { + payload["activity_data"] = options.activityData.value(); + } + if (options.verifyPlus.has_value()) { + payload["verify_plus"] = options.verifyPlus.value(); + } + cpr::Response reqResponse = requestHandler->Post( cpr::Url{apiBaseUrl + "/validatebatch"}, cpr::Header{ @@ -232,22 +376,29 @@ void ZeroBounce::validateBatch( cpr::Body{payload.dump()} ); - std::string rsp = reqResponse.text; - - if (reqResponse.status_code > 299) { - if (errorCallback) { - ZBErrorResponse errorResponse = ZBErrorResponse::parseError(rsp); - errorCallback(errorResponse); - } - } else { - if (successCallback) { - ZBValidateBatchResponse response = ZBValidateBatchResponse::from_json(json::parse(rsp)); - successCallback(response); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + ZBValidateBatchResponse response = ZBValidateBatchResponse::from_json(parsed); + for (const auto& item : response.errors) { + if (item.emailAddress == "all") { + std::string message = item.error.empty() + ? "Invalid API Key or your account ran out of credits" + : item.error; + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(message)); + } + return; } } - } catch (const std::exception& e) { - ZBErrorResponse errorResponse = ZBErrorResponse::parseError(e.what()); - errorCallback(errorResponse); + if (successCallback) { + successCallback(response); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); + } } } @@ -355,34 +506,79 @@ void ZeroBounce::getActivityData( ); } +void ZeroBounce::scoreEmail( + std::string email, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (invalidApiKey(errorCallback)) return; + + sendRequest( + apiBaseUrl + "/scoring?api_key=" + urlEncode(apiKey) + "&email=" + urlEncode(email), + successCallback, + errorCallback + ); +} + +void ZeroBounce::scoreEmailPost( + std::string email, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + if (invalidApiKey(errorCallback)) return; + + try { + std::string body = + "api_key=" + urlEncode(apiKey) + "&email=" + urlEncode(email); + + cpr::Response reqResponse = requestHandler->Post( + cpr::Url{apiBaseUrl + "/scoring"}, + cpr::Header{ + {"Accept", "application/json"}, + {"Content-Type", "application/x-www-form-urlencoded"} + }, + cpr::Body{body} + ); + + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(ZBScoreEmailResponse::from_json(parsed)); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); + } + } +} + template void ZeroBounce::sendRequest( std::string urlPath, OnSuccessCallback successCallback, OnErrorCallback errorCallback ) { + if (invalidApiKey(errorCallback)) return; + try { cpr::Response reqResponse = requestHandler->Get( cpr::Url{urlPath}, cpr::Header{{"Accept", "application/json"}} ); - std::string rsp = reqResponse.text; - - if (reqResponse.status_code > 299) { - if (errorCallback) { - ZBErrorResponse errorResponse = ZBErrorResponse::parseError(rsp); - errorCallback(errorResponse); - } - } else { - if (successCallback) { - T response = T::from_json(json::parse(rsp)); - successCallback(response); - } + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(T::from_json(parsed)); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); } - } catch (const std::exception& e) { - ZBErrorResponse errorResponse = ZBErrorResponse::parseError(e.what()); - errorCallback(errorResponse); } } @@ -424,11 +620,11 @@ void ZeroBounce::sendFileInternal( multipart.parts.emplace_back(cpr::Part{"return_url", options.returnUrl}); } - multipart.parts.emplace_back(cpr::Part{"has_header_row", options.hasHeaderRow}); - multipart.parts.emplace_back(cpr::Part{"remove_duplicate", options.removeDuplicate}); + multipart.parts.emplace_back(cpr::Part{"has_header_row", std::string(options.hasHeaderRow ? "true" : "false")}); + multipart.parts.emplace_back(cpr::Part{"remove_duplicate", std::string(options.removeDuplicate ? "true" : "false")}); if (!scoring && options.allowPhase2IsSet) { - multipart.parts.emplace_back(cpr::Part{"allow_phase_2", options.allowPhase2}); + multipart.parts.emplace_back(cpr::Part{"allow_phase_2", std::string(options.allowPhase2 ? "true" : "false")}); } cpr::Response reqResponse = requestHandler->Post( @@ -437,22 +633,17 @@ void ZeroBounce::sendFileInternal( multipart ); - std::string rsp = reqResponse.text; - - if (reqResponse.status_code > 299) { - if (errorCallback) { - ZBErrorResponse errorResponse = ZBErrorResponse::parseError(rsp); - errorCallback(errorResponse); - } - } else { - if (successCallback) { - ZBSendFileResponse response = ZBSendFileResponse::from_json(json::parse(rsp)); - successCallback(response); - } + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(ZBSendFileResponse::from_json(parsed)); + } + } catch (const std::exception&) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); } - } catch (const std::exception& e) { - ZBErrorResponse errorResponse = ZBErrorResponse::parseError(e.what()); - errorCallback(errorResponse); } } @@ -502,14 +693,8 @@ void ZeroBounce::getFileInternal( std::string rsp = reqResponse.text; - if (reqResponse.status_code > 299) { - if (errorCallback) { - std::string errPayload = rsp; - if (!rsp.empty() && rsp[0] == '{') { - errPayload = formatGetFileErrorMessage(rsp); - } - errorCallback(ZBErrorResponse::parseError(errPayload)); - } + if (reqResponse.status_code > 299 || zbContentTypeIsHtml(contentType) || zbBodyLooksLikeHtml(rsp)) { + zbReportHttpError(reqResponse.status_code, rsp, errorCallback); } else { if (successCallback) { if (shouldTreatGetFileBodyAsError(rsp, contentType)) { @@ -719,3 +904,15 @@ void ZeroBounce::findEmail( domain, first_name, "", last_name, successCallback, errorCallback ); } + +template void ZeroBounce::sendRequest( + std::string urlPath, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +); + +template void ZeroBounce::sendRequest( + std::string urlPath, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +); diff --git a/src/ZeroBounceWarmup.cpp b/src/ZeroBounceWarmup.cpp new file mode 100644 index 0000000..379bab0 --- /dev/null +++ b/src/ZeroBounceWarmup.cpp @@ -0,0 +1,687 @@ +#include "ZeroBounce/ZeroBounceWarmup.h" + +#include +#include +#include + +#include + +#include "HttpError.h" + +using json = nlohmann::json; + +namespace { + +const std::vector kOnboardingModels{"self_managed", "zb_managed"}; +const std::vector kEmailTypes{"transactional", "newsletter", "marketing"}; + +std::string trimmed(const std::string& value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) { + return ""; + } + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); +} + +bool hasControlCharacters(const std::string& value) { + return std::any_of(value.begin(), value.end(), [](unsigned char c) { + return std::iscntrl(c) != 0; + }); +} + +std::string urlEncode(const std::string& value) { + static const char* hex = "0123456789ABCDEF"; + std::string out; + out.reserve(value.size() * 3); + for (unsigned char c : value) { + if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + out.push_back(static_cast(c)); + } else { + out.push_back('%'); + out.push_back(hex[c >> 4]); + out.push_back(hex[c & 0x0F]); + } + } + return out; +} + +bool isIsoDate(const std::string& value) { + if (value.size() != 10 || value[4] != '-' || value[7] != '-') { + return false; + } + for (size_t i : {0u, 1u, 2u, 3u, 5u, 6u, 8u, 9u}) { + if (!std::isdigit(static_cast(value[i]))) { + return false; + } + } + return true; +} + +void reportError(const OnErrorCallback& errorCallback, const std::string& message, long statusCode = 0) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(message, statusCode)); + } +} + +} // namespace + +ZeroBounceWarmup::ZeroBounceWarmup() + : defaultRequestHandler(std::make_unique()) { + requestHandler = defaultRequestHandler.get(); + root = std::string(DefaultBaseUrl) + BasePath; +} + +void ZeroBounceWarmup::initialize(const std::string& key, const std::string& baseUrl) { + ready = false; + apiKey = key; + if (trimmed(key).empty()) { + 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)) { + return; + } + std::string base = baseUrl.empty() ? std::string(DefaultBaseUrl) : baseUrl; + std::string lower = base; + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (lower.rfind("https://", 0) != 0) { + return; + } + while (!base.empty() && base.back() == '/') { + base.pop_back(); + } + root = base + BasePath; + ready = true; +} + +// -- internals --------------------------------------------------------------- + +namespace { + +/** Replace every form of a secret in a message with ***. */ +std::string redact(const std::string& text, const std::vector& secrets) { + std::string value = text; + for (const auto& secret : secrets) { + if (secret.empty()) { + continue; + } + for (const auto& form : {secret, urlEncode(secret)}) { + size_t at = 0; + while ((at = value.find(form, at)) != std::string::npos) { + value.replace(at, form.size(), "***"); + at += 3; + } + } + } + return value; +} + +struct WarmupResult { + long statusCode = 0; + std::string contentType; + std::string body; +}; + +} // namespace + +/** + * Why these live in one place: every endpoint needs the same key check, the + * same header, the same redaction and the same short-error handling. + */ +namespace { + +bool warmupReady(const std::string& apiKey, bool ready, const OnErrorCallback& errorCallback) { + if (trimmed(apiKey).empty()) { + reportError(errorCallback, "api_key is required"); + return false; + } + if (apiKey != trimmed(apiKey) || hasControlCharacters(apiKey)) { + reportError(errorCallback, "api_key has surrounding whitespace or control characters"); + return false; + } + if (!ready) { + reportError(errorCallback, "base_url must be an https:// URL"); + return false; + } + return true; +} + +bool warmupSend( + ZeroBounceWarmup* zb, + const std::string& method, + const std::string& url, + const std::string* jsonBody, + const std::vector& extraSecrets, + WarmupResult& out, + const OnErrorCallback& errorCallback +) { + // The key is masked out of every message, whatever else the caller adds. + std::vector secrets{zb->getApiKey()}; + secrets.insert(secrets.end(), extraSecrets.begin(), extraSecrets.end()); + + const cpr::Header jsonHeader{ + {"Accept", "application/json"}, + {"Content-Type", "application/json"}, + {"API-Key", zb->getApiKey()} + }; + const cpr::Header plainHeader{{"Accept", "application/json"}, {"API-Key", zb->getApiKey()}}; + + cpr::Response response; + try { + if (method == "POST") { + response = zb->getRequestHandler()->Post( + cpr::Url{url}, jsonHeader, cpr::Body{jsonBody ? *jsonBody : std::string{}}); + } else if (method == "PUT") { + response = zb->getRequestHandler()->Put( + cpr::Url{url}, jsonHeader, cpr::Body{jsonBody ? *jsonBody : std::string{}}); + } else if (method == "DELETE") { + // DELETE /custom-engagement is the one endpoint with a body. + if (jsonBody != nullptr) { + response = zb->getRequestHandler()->Delete( + cpr::Url{url}, jsonHeader, cpr::Body{*jsonBody}); + } else { + response = zb->getRequestHandler()->Delete(cpr::Url{url}, plainHeader); + } + } else { + response = zb->getRequestHandler()->Get(cpr::Url{url}, plainHeader); + } + } catch (const std::exception& error) { + reportError(errorCallback, redact(error.what(), secrets)); + return false; + } + + out.statusCode = response.status_code; + out.body = response.text; + auto typeIt = response.header.find("Content-Type"); + out.contentType = typeIt == response.header.end() ? "" : typeIt->second; + + if (out.statusCode >= 300 && out.statusCode < 400) { + reportError(errorCallback, + "ZeroBounce returned a redirect, which this client does not follow.", out.statusCode); + return false; + } + if (out.statusCode > 299) { + reportError(errorCallback, + redact(zbMessageFromBody(out.body, out.statusCode), secrets), out.statusCode); + return false; + } + return true; +} + +/** + * The JSON payload of a success. A documented empty success (204, or an empty + * 200) is handed to the caller as "null", which is valid JSON. + */ +bool warmupJson( + ZeroBounceWarmup* zb, + const std::string& method, + const std::string& url, + const std::string* jsonBody, + const std::vector& secrets, + OnRawJsonSuccessCallback successCallback, + const OnErrorCallback& errorCallback +) { + WarmupResult result; + if (!warmupSend(zb, method, url, jsonBody, secrets, result, errorCallback)) { + return false; + } + if (result.statusCode == 204 || trimmed(result.body).empty()) { + if (successCallback) { + successCallback("null"); + } + return true; + } + try { + json parsed = json::parse(result.body); + if (successCallback) { + successCallback(parsed.dump()); + } + return true; + } catch (const std::exception&) { + reportError(errorCallback, + redact(zbMessageFromBody(result.body, result.statusCode), secrets), result.statusCode); + return false; + } +} + +bool warmupCsv( + ZeroBounceWarmup* zb, + const std::string& url, + OnSuccessCallback successCallback, + const OnErrorCallback& errorCallback +) { + WarmupResult result; + if (!warmupSend(zb, "GET", url, nullptr, {}, result, errorCallback)) { + return false; + } + std::string lowerType = result.contentType; + std::transform(lowerType.begin(), lowerType.end(), lowerType.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (trimmed(result.body).empty() || lowerType.find("application/json") != std::string::npos) { + reportError(errorCallback, + redact(zbMessageFromBody(result.body, result.statusCode), {zb->getApiKey()}), + result.statusCode); + return false; + } + if (successCallback) { + successCallback(result.body); + } + return true; +} + +bool requireValue(const std::string& value, const char* name, const OnErrorCallback& errorCallback) { + if (trimmed(value).empty()) { + reportError(errorCallback, std::string(name) + " is required"); + return false; + } + return true; +} + +bool requireRange(int value, int low, int high, const char* name, const OnErrorCallback& errorCallback) { + if (value < low || value > high) { + std::ostringstream message; + message << name << " must be " << low << " to " << high; + reportError(errorCallback, message.str()); + return false; + } + return true; +} + +bool engagementBody( + const std::string& cFrom, int replyPct, int importantPct, int clickPct, + std::string& out, const OnErrorCallback& errorCallback +) { + if (!requireValue(cFrom, "c_from", errorCallback)) return false; + if (!requireRange(replyPct, 5, 45, "reply_pct", errorCallback)) return false; + if (!requireRange(importantPct, 5, 40, "important_pct", errorCallback)) return false; + if (!requireRange(clickPct, 0, 70, "click_pct", errorCallback)) return false; + if (clickPct > static_cast(importantPct * 1.75)) { + reportError(errorCallback, "click_pct must be at most important_pct x 1.75"); + return false; + } + json body; + body["c_from"] = cFrom; + body["reply_pct"] = replyPct; + body["important_pct"] = importantPct; + body["click_pct"] = clickPct; + out = body.dump(); + return true; +} + +} // namespace + +// -- sending accounts -------------------------------------------------------- + +void ZeroBounceWarmup::listSendingAccounts(OnRawJsonSuccessCallback ok, OnErrorCallback err) { + if (!warmupReady(apiKey, ready, 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 (!requireValue(fromAddress, "from_address", err)) return; + warmupJson(this, "GET", root + "/sending-account?from_address=" + urlEncode(fromAddress), + nullptr, {}, ok, err); +} + +void ZeroBounceWarmup::addSendingAccount( + const std::string& fromAddress, + const std::string& onboardingModel, + const WarmupAddSendingAccountOptions& options, + OnRawJsonSuccessCallback ok, + OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + if (!requireValue(fromAddress, "from_address", err)) return; + if (std::find(kOnboardingModels.begin(), kOnboardingModels.end(), onboardingModel) + == kOnboardingModels.end()) { + reportError(err, "onboarding_model must be self_managed or zb_managed"); + return; + } + + json body; + body["from_address"] = fromAddress; + body["onboarding_model"] = onboardingModel; + + if (onboardingModel == "self_managed") { + if (!options.engagementRuleId.has_value()) { + reportError(err, "engagement_rule_id is required for self_managed"); + return; + } + if (!requireRange(*options.engagementRuleId, 0, 4, "engagement_rule_id", err)) return; + body["engagement_rule_id"] = *options.engagementRuleId; + if (options.emailContent.has_value() || options.managedSending.has_value()) { + reportError(err, "email_content and managed_sending are for zb_managed"); + return; + } + } + if (options.ignoreConflict.has_value()) { + body["ignore_conflict"] = *options.ignoreConflict; + } + if (options.emailContent.has_value()) { + const auto& content = *options.emailContent; + json object = json::object(); + if (!content.typesOfEmails.empty()) { + for (const auto& kind : content.typesOfEmails) { + if (std::find(kEmailTypes.begin(), kEmailTypes.end(), kind) == kEmailTypes.end()) { + reportError(err, + "types_of_emails must be transactional, newsletter and/or marketing"); + return; + } + } + object["types_of_emails"] = content.typesOfEmails; + } + if (content.industry.has_value()) object["industry"] = *content.industry; + if (content.companyName.has_value()) object["company_name"] = *content.companyName; + if (content.website.has_value()) object["website"] = *content.website; + body["email_content"] = object; + } + if (options.managedSending.has_value()) { + const auto& sending = *options.managedSending; + json object = json::object(); + if (sending.timezoneOffset.has_value()) { + if (!requireRange(*sending.timezoneOffset, -11, 14, "timezone_offset", err)) return; + object["timezone_offset"] = *sending.timezoneOffset; + } + if (sending.warmupStartDate.has_value()) { + if (!isIsoDate(*sending.warmupStartDate)) { + reportError(err, "warmup_start_date must be YYYY-MM-DD"); + return; + } + object["warmup_start_date"] = *sending.warmupStartDate; + } + if (sending.dailyLimit.has_value()) object["daily_limit"] = *sending.dailyLimit; + body["managed_sending"] = object; + } + + const std::string payload = body.dump(); + warmupJson(this, "POST", root + "/sending-accounts", &payload, {}, ok, err); +} + +void ZeroBounceWarmup::updateSendingAccount( + const std::string& fromAddress, int engagementRuleId, int statusId, bool autoEnable, + OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, 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; + + json body; + body["team_id"] = AnyTeamId; + body["from_address"] = fromAddress; + body["engagement_rule_id"] = engagementRuleId; + body["status_id"] = statusId; + body["auto_enable"] = autoEnable; + const std::string payload = body.dump(); + warmupJson(this, "PUT", root + "/sending-accounts", &payload, {}, ok, err); +} + +void ZeroBounceWarmup::deleteSendingAccount( + const std::string& fromAddress, OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + if (!requireValue(fromAddress, "from_address", err)) return; + warmupJson(this, "DELETE", root + "/sending-accounts?from_address=" + urlEncode(fromAddress), + nullptr, {}, ok, err); +} + +void ZeroBounceWarmup::changeFromAddress( + const std::string& oldFromAddress, const std::string& newFromAddress, + OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + if (!requireValue(oldFromAddress, "old_from_address", err)) return; + if (!requireValue(newFromAddress, "new_from_address", err)) return; + + json body; + body["team_id"] = AnyTeamId; + body["old_from_address"] = oldFromAddress; + body["new_from_address"] = newFromAddress; + const std::string payload = body.dump(); + warmupJson(this, "PUT", root + "/smtp-credentials", &payload, {}, ok, err); +} + +void ZeroBounceWarmup::listFromAddresses( + const WarmupListFromAddressesOptions& options, OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + + std::string url = root + "/from-addresses"; + std::string separator = "?"; + if (options.searchFrom.has_value()) { + const std::string& search = *options.searchFrom; + const bool allowed = std::all_of(search.begin(), search.end(), [](unsigned char c) { + return std::isalnum(c) || c == '.' || c == '-' || c == '_' || c == '@' || c == '+'; + }); + if (!allowed) { + reportError(err, "search_from takes letters, digits and . - _ @ + only"); + return; + } + url += separator + "search_from=" + urlEncode(search); + separator = "&"; + } + // The API takes the paging values as strings. + if (options.offset.has_value()) { + if (*options.offset < 0) { + reportError(err, "offset must be a non-negative integer"); + return; + } + url += separator + "offset=" + std::to_string(*options.offset); + separator = "&"; + } + if (options.limit.has_value()) { + if (*options.limit < 0) { + reportError(err, "limit must be a non-negative integer"); + return; + } + url += separator + "limit=" + std::to_string(*options.limit); + } + warmupJson(this, "GET", url, nullptr, {}, ok, err); +} + +// -- engagement rules -------------------------------------------------------- + +void ZeroBounceWarmup::getEngagementRules(OnRawJsonSuccessCallback ok, OnErrorCallback err) { + if (!warmupReady(apiKey, ready, 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 (!requireValue(cFrom, "c_from", err)) return; + if (!requireRange(ruleId, 0, 4, "rule_id", err)) return; + + json body; + body["rule_id"] = ruleId; + const std::string payload = body.dump(); + warmupJson(this, "PUT", root + "/engagement-rule?c_from=" + urlEncode(cFrom), + &payload, {}, ok, err); +} + +void ZeroBounceWarmup::getCustomEngagement( + const std::string& cFrom, OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + if (!requireValue(cFrom, "c_from", err)) return; + const std::string segment = trimmed(cFrom); + if (segment == "." || segment == "..") { + reportError(err, "c_from is invalid"); + return; + } + warmupJson(this, "GET", root + "/custom-engagement/" + urlEncode(segment), nullptr, {}, ok, err); +} + +void ZeroBounceWarmup::createCustomEngagement( + const std::string& cFrom, int replyPct, int importantPct, int clickPct, + OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + std::string payload; + if (!engagementBody(cFrom, replyPct, importantPct, clickPct, payload, err)) return; + warmupJson(this, "POST", root + "/custom-engagement", &payload, {}, ok, err); +} + +void ZeroBounceWarmup::updateCustomEngagement( + const std::string& cFrom, int replyPct, int importantPct, int clickPct, + OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + std::string payload; + if (!engagementBody(cFrom, replyPct, importantPct, clickPct, payload, err)) return; + warmupJson(this, "PUT", root + "/custom-engagement", &payload, {}, ok, err); +} + +void ZeroBounceWarmup::deleteCustomEngagement( + const std::string& cFrom, int engagementRuleId, OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + if (!requireValue(cFrom, "c_from", err)) return; + if (!requireRange(engagementRuleId, 1, 4, "engagement_rule_id", err)) return; + + json body; + body["c_from"] = cFrom; + body["engagement_rule_id"] = engagementRuleId; + const std::string payload = body.dump(); + warmupJson(this, "DELETE", root + "/custom-engagement", &payload, {}, ok, err); +} + +// -- statistics, seeds and utilities ----------------------------------------- + +void ZeroBounceWarmup::getSeeds(OnRawJsonSuccessCallback ok, OnErrorCallback err) { + if (!warmupReady(apiKey, ready, err)) return; + warmupJson(this, "GET", root + "/seeds", nullptr, {}, ok, err); +} + +void ZeroBounceWarmup::downloadSeeds(OnSuccessCallback ok, OnErrorCallback err) { + if (!warmupReady(apiKey, ready, err)) return; + warmupCsv(this, root + "/seeds/download", ok, err); +} + +void ZeroBounceWarmup::getSeedCount(OnRawJsonSuccessCallback ok, OnErrorCallback err) { + if (!warmupReady(apiKey, ready, err)) return; + warmupJson(this, "GET", root + "/seed-count", nullptr, {}, ok, err); +} + +void ZeroBounceWarmup::getProgressToday(OnRawJsonSuccessCallback ok, OnErrorCallback err) { + if (!warmupReady(apiKey, ready, err)) return; + warmupJson(this, "GET", root + "/progress-today", nullptr, {}, ok, err); +} + +void ZeroBounceWarmup::getSentToday(OnRawJsonSuccessCallback ok, OnErrorCallback err) { + if (!warmupReady(apiKey, ready, err)) return; + warmupJson(this, "GET", root + "/sent-today", nullptr, {}, ok, err); +} + +void ZeroBounceWarmup::getEmailsPerDay(OnRawJsonSuccessCallback ok, OnErrorCallback err) { + if (!warmupReady(apiKey, ready, 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 (!requireValue(fromAddress, "from_address", err)) return; + warmupJson(this, "GET", root + "/statistics?from_address=" + urlEncode(fromAddress), + nullptr, {}, ok, err); +} + +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 (!requireValue(fromAddress, "from_address", err)) return; + if (!isIsoDate(startDate)) { + reportError(err, "start_date must be YYYY-MM-DD"); + return; + } + if (!isIsoDate(endDate)) { + reportError(err, "end_date must be YYYY-MM-DD"); + return; + } + if (startDate > endDate) { + reportError(err, "start_date is later than end_date"); + return; + } + warmupJson(this, "GET", + root + "/statistics-per-day?from_address=" + urlEncode(fromAddress) + + "&start_date=" + urlEncode(startDate) + "&end_date=" + urlEncode(endDate), + nullptr, {}, ok, err); +} + +void ZeroBounceWarmup::getStatisticsDetailed( + const std::string& cFrom, OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + if (!requireValue(cFrom, "c_from", err)) return; + warmupJson(this, "GET", root + "/statistics-detailed?c_from=" + urlEncode(cFrom), + nullptr, {}, ok, err); +} + +void ZeroBounceWarmup::downloadStatistics( + const std::string& cFrom, OnSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + if (!requireValue(cFrom, "c_from", err)) return; + warmupCsv(this, root + "/statistics/download?c_from=" + urlEncode(cFrom), ok, err); +} + +void ZeroBounceWarmup::getFromAddressStatistics( + const std::string& fromAddress, OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, err)) return; + if (!requireValue(fromAddress, "from_address", err)) return; + warmupJson(this, "GET", root + "/from-address-statistics?from_address=" + urlEncode(fromAddress), + nullptr, {}, ok, err); +} + +void ZeroBounceWarmup::checkDomainDmarc( + const std::string& fromAddress, const std::optional& dkimSelector, + OnRawJsonSuccessCallback ok, OnErrorCallback err +) { + if (!warmupReady(apiKey, ready, 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"); + return; + } + std::string url = root + "/check-domain-dmarc?from_address=" + urlEncode(fromAddress); + if (dkimSelector.has_value()) { + if (!requireValue(*dkimSelector, "dkim_selector", err)) return; + url += "&dkim_selector=" + urlEncode(*dkimSelector); + } + warmupJson(this, "GET", url, nullptr, {}, ok, err); +} + +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 (!requireValue(fromAddress, "from_address", err)) return; + if (!requireValue(username, "username", err)) return; + if (!requireValue(password, "password", err)) return; + if (!requireValue(server, "server", err)) return; + if (!requireRange(port, 1, 65535, "port", err)) return; + + json body; + body["from_address"] = fromAddress; + body["username"] = username; + body["password"] = password; + body["server"] = server; + body["port"] = port; + const std::string payload = body.dump(); + warmupJson(this, "POST", root + "/test-smtp-configuration", &payload, + {password, username}, ok, err); +} diff --git a/test/OfflineApiTest.cpp b/test/OfflineApiTest.cpp new file mode 100644 index 0000000..e3909b0 --- /dev/null +++ b/test/OfflineApiTest.cpp @@ -0,0 +1,1401 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "ZeroBounce/ZeroBounce.h" + +using json = nlohmann::json; + +namespace { + +struct RecordedRequest { + std::string method; + std::string host; + std::string path; + std::string apiKey; + /// The Authorization header, for the Inbox Placement Tester's Bearer key. + std::string authorization; + std::map fields; + /// The full request URL, for assertions on path encoding and query order. + std::string url; + /// The raw request body, for assertions on JSON shape. + std::string body; +}; + +std::string urlDecode(const std::string& value) { + std::string out; + out.reserve(value.size()); + for (size_t i = 0; i < value.size(); ++i) { + if (value[i] == '%' && i + 2 < value.size()) { + auto hex = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + int hi = hex(value[i + 1]); + int lo = hex(value[i + 2]); + if (hi >= 0 && lo >= 0) { + out.push_back(static_cast((hi << 4) | lo)); + i += 2; + continue; + } + } + out.push_back(value[i] == '+' ? ' ' : value[i]); + } + return out; +} + +void parseQuery(const std::string& query, std::map& fields) { + size_t start = 0; + while (start < query.size()) { + size_t amp = query.find('&', start); + if (amp == std::string::npos) { + amp = query.size(); + } + std::string pair = query.substr(start, amp - start); + size_t eq = pair.find('='); + if (eq == std::string::npos) { + fields[urlDecode(pair)] = ""; + } else { + fields[urlDecode(pair.substr(0, eq))] = urlDecode(pair.substr(eq + 1)); + } + start = amp + 1; + } +} + +struct ParsedUrl { + std::string host; + std::string path; + std::map query; +}; + +ParsedUrl parseUrl(const std::string& url) { + ParsedUrl parsed; + size_t hostStart = 0; + auto scheme = url.find("://"); + if (scheme != std::string::npos) { + hostStart = scheme + 3; + } + auto pathStart = url.find('/', hostStart); + auto queryStart = url.find('?', hostStart); + if (pathStart == std::string::npos) { + parsed.host = url.substr(hostStart); + parsed.path = "/"; + } else { + parsed.host = url.substr(hostStart, pathStart - hostStart); + if (queryStart == std::string::npos) { + parsed.path = url.substr(pathStart); + } else { + parsed.path = url.substr(pathStart, queryStart - pathStart); + } + } + if (queryStart != std::string::npos) { + parseQuery(url.substr(queryStart + 1), parsed.query); + } + return parsed; +} + +void flattenJson(const json& body, std::map& fields) { + if (!body.is_object()) { + return; + } + for (auto it = body.begin(); it != body.end(); ++it) { + if (it.value().is_string()) { + fields[it.key()] = it.value().get(); + } else if (it.value().is_boolean()) { + fields[it.key()] = it.value().get() ? "true" : "false"; + } else if (it.value().is_number_integer()) { + fields[it.key()] = std::to_string(it.value().get()); + } else if (it.value().is_null()) { + fields[it.key()] = "null"; + } else { + fields[it.key()] = it.value().dump(); + } + } +} + +std::string htmlAccessDeniedPage() { + std::string page = + "Access denied | zerobounce.net | Zero Bounce"; + page.reserve(page.size() + 20000 * 16); + for (int i = 0; i < 20000; ++i) { + page += "

blocked

"; + } + page += ""; + return page; +} + +const std::string& accessDeniedPage() { + static const std::string page = htmlAccessDeniedPage(); + return page; +} + +std::string jsonEscape(const std::string& value) { + return json(value).dump(); +} + +class FakeApi : public BaseRequestHandler { +public: + std::vector take() { + std::lock_guard lock(mu_); + return records_; + } + + void clear() { + std::lock_guard lock(mu_); + records_.clear(); + } + + size_t size() { + std::lock_guard lock(mu_); + return records_.size(); + } + +protected: + cpr::Response doGet(const cpr::Url& url) override { return respond("GET", url.str(), {}, "", {}); } + + cpr::Response doGet(const cpr::Url& url, const cpr::Header& header) override { + return respond("GET", url.str(), {}, "", header); + } + + cpr::Response doGetWithParameters(const cpr::Url& url, const cpr::Parameters& parameters) override { + cpr::CurlHolder holder; + std::string query = parameters.GetContent(holder); + return respond("GET", url.str(), {}, query, {}); + } + + cpr::Response doPost(const cpr::Url& url, const cpr::Header& header, const cpr::Body& body) override { + return respond("POST", url.str(), {}, body.str(), header); + } + + cpr::Response doPut(const cpr::Url& url, const cpr::Header& header, const cpr::Body& body) override { + return respond("PUT", url.str(), {}, std::string(body), header); + } + + cpr::Response doDelete(const cpr::Url& url, const cpr::Header& header) override { + return respond("DELETE", url.str(), {}, "", header); + } + + cpr::Response doPost(const cpr::Url& url, const cpr::Header& header, const cpr::Multipart& multipart) override { + std::map fields; + for (const auto& part : multipart.parts) { + fields[part.name] = part.is_file ? "" : part.value; + } + return respond("POST", url.str(), fields, "", header); + } + +private: + cpr::Response respond( + const std::string& method, + const std::string& url, + std::map fields, + const std::string& extra, + const cpr::Header& header + ) { + ParsedUrl parsed = parseUrl(url); + fields.insert(parsed.query.begin(), parsed.query.end()); + if (!extra.empty()) { + if (!extra.empty() && extra.front() == '{') { + try { + flattenJson(json::parse(extra), fields); + } catch (...) { + fields["__raw"] = extra; + } + } else if (extra.find('=') != std::string::npos && extra.front() != '<') { + parseQuery(extra, fields); + } + fields["__raw"] = extra; + } + + RecordedRequest recorded; + recorded.method = method; + recorded.host = parsed.host; + recorded.path = parsed.path; + recorded.fields = fields; + recorded.url = url; + recorded.body = extra; + auto authIt = header.find("Authorization"); + recorded.authorization = authIt == header.end() ? "" : authIt->second; + auto keyIt = fields.find("api_key"); + recorded.apiKey = keyIt == fields.end() ? "" : keyIt->second; + // The Inbox Placement Tester authenticates with a Bearer header rather + // than an api_key parameter, so the key can arrive either way. + if (recorded.apiKey.empty() && recorded.authorization.rfind("Bearer ", 0) == 0) { + recorded.apiKey = recorded.authorization.substr(7); + } + + { + std::lock_guard lock(mu_); + records_.push_back(recorded); + } + + std::mt19937 rng{std::random_device{}()}; + std::uniform_int_distribution delay(0, 20); + std::this_thread::sleep_for(std::chrono::milliseconds(delay(rng))); + + return answer(recorded); + } + + static cpr::Response answer(const RecordedRequest& request) { + const std::string& key = request.apiKey; + cpr::Response response; + if (key.rfind("html-403-", 0) == 0 || key.rfind("html-200-", 0) == 0) { + response.status_code = key.rfind("html-403-", 0) == 0 ? 403 : 200; + response.text = accessDeniedPage(); + response.header["Content-Type"] = "text/html; charset=UTF-8"; + return response; + } + if (key.rfind("success-false-", 0) == 0) { + // LIVE: an unknown file id comes back as an HTTP 200 whose body + // says the call failed. + response.status_code = 200; + response.text = R"({"success":false,"message":"File cannot be found."})"; + response.header["Content-Type"] = "application/json"; + return response; + } + if (key.rfind("empty-", 0) == 0) { + response.status_code = 200; + response.text = ""; + response.header["Content-Type"] = "application/json"; + return response; + } + if (key.rfind("malformed-", 0) == 0) { + response.status_code = 200; + response.text = "<<>>" + std::string(5000, 'x'); + response.header["Content-Type"] = "application/json"; + return response; + } + if (key.rfind("cf-json-", 0) == 0) { + response.status_code = 403; + response.text = + R"({"type":"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1020/","title":"Error 1020: Access denied","status":403,"detail":"The request was blocked by a Cloudflare firewall rule configured by the site owner.","error_code":1020,"cloudflare_error":true,"what_you_should_do":"Do not retry. This block is intentional."})"; + response.header["Content-Type"] = "application/json"; + return response; + } + if (key.rfind("double-error-", 0) == 0) { + response.status_code = 400; + response.text = "Error: Error: api_key is invalid"; + response.header["Content-Type"] = "text/plain"; + return response; + } + if (request.path.find("validatebatch") != std::string::npos && key.rfind("batch-account-", 0) == 0) { + response.status_code = 200; + response.text = + R"({"email_batch":[],"errors":[{"email_address":"all","error":"Invalid API Key or your account ran out of credits"}]})"; + response.header["Content-Type"] = "application/json"; + return response; + } + if (request.path.find("validatebatch") != std::string::npos && key.rfind("batch-partial-", 0) == 0) { + response.status_code = 200; + response.text = + R"({"email_batch":[{"address":"good@example.com","status":"valid"}],"errors":[{"email_address":"bad@","error":"Invalid email address"}]})"; + response.header["Content-Type"] = "application/json"; + return response; + } + + response.status_code = 200; + response.header["Content-Type"] = "application/json"; + const std::string quoted = jsonEscape(key); + auto endsWith = [](const std::string& value, const std::string& suffix) { + return value.size() >= suffix.size() && + value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; + }; + if (request.path.find("/api/ipt") != std::string::npos) { + if (key.rfind("ipt-detail", 0) == 0) { + // The Inbox Placement Tester (Django REST framework) reports "detail". + response.status_code = 404; + response.text = "{\"detail\":\"Inbox placement test not found.\"}"; + } else { + response.text = "{\"ok\":true,\"echo_api_key\":" + quoted + "}"; + } + } else if (request.path.find("listS3files") != std::string::npos) { + response.text = "[{\"name\":" + quoted + ",\"size\":1,\"last_modified\":\"\"}]"; +} else if (request.path.find("getfile") != std::string::npos) { + response.header["Content-Type"] = "application/octet-stream"; + response.text = "email\n" + key + "@example.com\n"; + } else if (request.path.find("validatebatch") != std::string::npos) { + response.text = "{\"email_batch\":[{\"address\":" + quoted + ",\"status\":\"valid\"}],\"errors\":[]}"; + } else if (endsWith(request.path, "/validate")) { + response.text = "{\"address\":" + quoted + ",\"status\":\"valid\"}"; + } else if (endsWith(request.path, "/scoring")) { + response.text = "{\"email\":" + quoted + ",\"score\":10}"; + } else if (request.path.find("getcredits") != std::string::npos) { + response.text = "{\"Credits\":\"7\"}"; + } else if (request.path.find("getapiusage") != std::string::npos) { + response.text = "{\"total\":1}"; + } else if (endsWith(request.path, "/activity")) { + response.text = "{\"found\":true,\"active_in_days\":\"3\"}"; + } else if (request.path.find("filters") != std::string::npos || + (request.method == "GET" && request.path.find("listevaluator") != std::string::npos && + request.path.find("sendfile") == std::string::npos)) { + response.text = "{\"echo_api_key\":" + quoted + "}"; + } else if (request.path.find("guessformat") != std::string::npos) { + response.text = "{\"email\":" + quoted + ",\"domain\":" + quoted + ",\"other_domain_formats\":[]}"; + } else { + response.text = "{\"success\":true,\"message\":\"ok\",\"file_id\":" + quoted + + ",\"file_name\":\"f.csv\",\"email\":" + quoted + ",\"domain\":" + quoted + + ",\"other_domain_formats\":[]}"; + } + return response; + } + + std::mutex mu_; + std::vector records_; +}; + +class OfflineClient : public ZeroBounce { +public: + OfflineClient(FakeApi& api, std::string key) { + initialize(std::move(key)); + requestHandler = &api; + } +}; + +struct ErrorCapture { + bool called = false; + std::string text; +}; + +void expectShort(const std::string& message) { + EXPECT_LT(message.size(), 500u) << message.substr(0, 120); + EXPECT_EQ(message.find("blocked

"), std::string::npos); + EXPECT_EQ(message.find("Error: Error:"), std::string::npos); + EXPECT_EQ(message.find("[json.exception"), std::string::npos); +} + +ErrorCapture captureError(const ZBErrorResponse& error) { + ErrorCapture captured; + captured.called = true; + captured.text = error.errors.empty() ? "" : error.errors.front(); + for (const auto& message : error.errors) { + expectShort(message); + } + return captured; +} + +RecordedRequest onlyRequest(FakeApi& api) { + auto records = api.take(); + EXPECT_EQ(records.size(), 1u); + if (records.empty()) { + return {}; + } + return records.front(); +} + +const char* kCsv = "../email_file.csv"; + +} // namespace + +TEST(OfflineApi, GetCreditsReachesEndpoint) { + FakeApi api; + OfflineClient client(api, "credits-key"); + bool ok = false; + client.getCredits( + [&](ZBCreditsResponse response) { + ok = true; + EXPECT_EQ(response.credits, 7); + }, + [&](ZBErrorResponse error) { FAIL() << error.toString(); } + ); + EXPECT_TRUE(ok); + auto request = onlyRequest(api); + EXPECT_EQ(request.method, "GET"); + EXPECT_EQ(request.host, "api.zerobounce.net"); + EXPECT_EQ(request.path, "/v2/getcredits"); + EXPECT_EQ(request.apiKey, "credits-key"); +} + +#if ZB_OFFLINE_CURRENT +TEST(OfflineApi, ValidateOmitsUnsetOptionsAndEncodesFalse) { + FakeApi api; + OfflineClient client(api, "validate-key"); + ZBValidateOptions options; + options.activityData = false; + options.verifyPlus = true; + std::string echoed; + client.validate( + "person@example.com", + "", + options, + [&](ZBValidateResponse response) { echoed = response.address; }, + [&](ZBErrorResponse error) { FAIL() << error.toString(); } + ); + EXPECT_EQ(echoed, "validate-key"); + auto request = onlyRequest(api); + EXPECT_EQ(request.method, "GET"); + EXPECT_EQ(request.path, "/v2/validate"); + EXPECT_EQ(request.fields["email"], "person@example.com"); + EXPECT_EQ(request.fields["ip_address"], ""); + EXPECT_EQ(request.fields["activity_data"], "false"); + EXPECT_EQ(request.fields["verify_plus"], "true"); + EXPECT_EQ(request.fields.count("timeout"), 0u); +} + +TEST(OfflineApi, ValidatePostFormAndTimeout) { + FakeApi api; + OfflineClient client(api, "post-key"); + ZBValidateOptions options; + options.usePost = true; + options.timeout = 15; + client.validate( + "person@example.com", + "203.0.113.5", + options, + [&](ZBValidateResponse) {}, + [&](ZBErrorResponse error) { FAIL() << error.toString(); } + ); + auto request = onlyRequest(api); + EXPECT_EQ(request.method, "POST"); + EXPECT_EQ(request.host, "api.zerobounce.net"); + EXPECT_EQ(request.path, "/v2/validate"); + EXPECT_EQ(request.fields["api_key"], "post-key"); + EXPECT_EQ(request.fields["email"], "person@example.com"); + EXPECT_EQ(request.fields["ip_address"], "203.0.113.5"); + EXPECT_EQ(request.fields["timeout"], "15"); + EXPECT_EQ(request.fields.count("activity_data"), 0u); +} + +#endif + +TEST(OfflineApi, ValidateBatchUsesApiHostAndOmitsUnsetOptions) { + FakeApi api; + OfflineClient client(api, "batch-key"); + std::string echoed; + client.validateBatch( + {{"a@example.com", ""}, {"b@example.com", "203.0.113.9"}}, + [&](ZBValidateBatchResponse response) { + ASSERT_EQ(response.emailBatch.size(), 1u); + echoed = response.emailBatch.front().address; + EXPECT_TRUE(response.errors.empty()); + }, + [&](ZBErrorResponse error) { FAIL() << error.toString(); } + ); + EXPECT_EQ(echoed, "batch-key"); + auto request = onlyRequest(api); + EXPECT_EQ(request.method, "POST"); + EXPECT_EQ(request.host, "api.zerobounce.net"); + EXPECT_EQ(request.path, "/v2/validatebatch"); + EXPECT_EQ(request.fields["api_key"], "batch-key"); + EXPECT_NE(request.fields["email_batch"].find("\"email_address\":\"a@example.com\""), std::string::npos); + EXPECT_NE(request.fields["email_batch"].find("\"ip_address\":null"), std::string::npos); + EXPECT_NE(request.fields["email_batch"].find("\"ip_address\":\"203.0.113.9\""), std::string::npos); + EXPECT_EQ(request.fields.count("timeout"), 0u); + EXPECT_EQ(request.fields.count("activity_data"), 0u); + EXPECT_EQ(request.fields.count("verify_plus"), 0u); +} + +#if ZB_OFFLINE_CURRENT +TEST(OfflineApi, ValidateBatchSendsExplicitOptions) { + FakeApi api; + OfflineClient client(api, "batch-opt"); + ZBValidateOptions options; + options.timeout = 30; + options.activityData = false; + options.verifyPlus = true; + client.validateBatch( + {{"a@example.com"}}, + options, + [&](ZBValidateBatchResponse) {}, + [&](ZBErrorResponse error) { FAIL() << error.toString(); } + ); + auto request = onlyRequest(api); + EXPECT_EQ(request.fields["timeout"], "30"); + EXPECT_EQ(request.fields["activity_data"], "false"); + EXPECT_EQ(request.fields["verify_plus"], "true"); +} + +#endif + +TEST(OfflineApi, SendFileEncodesBooleansAndKeepsZeroColumn) { + FakeApi api; + OfflineClient client(api, "file-key"); + SendFileOptions options; + options.returnUrl = "https://example.com/hook"; + options.allowPhase2IsSet = true; + options.allowPhase2 = false; + options.removeDuplicate = false; + options.hasHeaderRow = true; + client.sendFile( + kCsv, + 0, + options, + [&](ZBSendFileResponse response) { EXPECT_EQ(response.fileId, "file-key"); }, + [&](ZBErrorResponse error) { FAIL() << error.toString(); } + ); + auto request = onlyRequest(api); + EXPECT_EQ(request.method, "POST"); + EXPECT_EQ(request.host, "bulkapi.zerobounce.net"); + EXPECT_EQ(request.path, "/v2/sendfile"); + EXPECT_EQ(request.fields["api_key"], "file-key"); + EXPECT_EQ(request.fields["email_address_column"], "0"); + EXPECT_EQ(request.fields["file"], ""); + EXPECT_EQ(request.fields["has_header_row"], "true"); + EXPECT_EQ(request.fields["remove_duplicate"], "false"); + EXPECT_EQ(request.fields["allow_phase_2"], "false"); + EXPECT_EQ(request.fields["return_url"], "https://example.com/hook"); + EXPECT_EQ(request.fields.count("first_name_column"), 0u); + EXPECT_EQ(request.fields.count("last_name_column"), 0u); + EXPECT_EQ(request.fields.count("gender_column"), 0u); + EXPECT_EQ(request.fields.count("ip_address_column"), 0u); +} + +TEST(OfflineApi, SendFileSendsSetNameColumn) { + FakeApi api; + OfflineClient client(api, "cols"); + SendFileOptions options; + options.firstNameColumn = 2; + client.sendFile(kCsv, 1, options, [&](ZBSendFileResponse) {}, [&](ZBErrorResponse error) { FAIL() << error.toString(); }); + auto request = onlyRequest(api); + EXPECT_EQ(request.fields["email_address_column"], "1"); + EXPECT_EQ(request.fields["first_name_column"], "2"); + EXPECT_EQ(request.fields.count("allow_phase_2"), 0u); +} + +TEST(OfflineApi, ScoringSendFileOmitsValidationOnlyFields) { + FakeApi api; + OfflineClient client(api, "score-file"); + SendFileOptions options; + options.firstNameColumn = 2; + options.allowPhase2IsSet = true; + options.allowPhase2 = true; + client.scoringSendFile(kCsv, 1, options, [&](ZBSendFileResponse) {}, [&](ZBErrorResponse error) { FAIL() << error.toString(); }); + auto request = onlyRequest(api); + EXPECT_EQ(request.host, "bulkapi.zerobounce.net"); + EXPECT_EQ(request.path, "/v2/scoring/sendfile"); + EXPECT_EQ(request.fields.count("first_name_column"), 0u); + EXPECT_EQ(request.fields.count("allow_phase_2"), 0u); + EXPECT_EQ(request.fields["has_header_row"], "true"); +} + +TEST(OfflineApi, FileStatusAndDeleteUseBulkHost) { + FakeApi api; + OfflineClient client(api, "status-key"); + client.fileStatus("file-1", [&](ZBFileStatusResponse response) { EXPECT_EQ(response.fileId, "status-key"); }, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto status = onlyRequest(api); + EXPECT_EQ(status.path, "/v2/filestatus"); + EXPECT_EQ(status.host, "bulkapi.zerobounce.net"); + EXPECT_EQ(status.fields["file_id"], "file-1"); + api.clear(); + client.scoringFileStatus("file-2", [&](ZBFileStatusResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + EXPECT_EQ(onlyRequest(api).path, "/v2/scoring/filestatus"); + api.clear(); + client.deleteFile("file-1", [&](ZBDeleteFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + EXPECT_EQ(onlyRequest(api).path, "/v2/deletefile"); + api.clear(); + client.scoringDeleteFile("file-2", [&](ZBDeleteFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + EXPECT_EQ(onlyRequest(api).path, "/v2/scoring/deletefile"); +} + +TEST(OfflineApi, GetFileWithOptionsParameterOrder) { + FakeApi api; + OfflineClient client(api, "getfile-key"); + GetFileOptions options; + options.downloadType = ZBDownloadType::Phase2; + options.activityData = true; + bool ok = false; + client.getFileWithOptions( + "file-9", + "../test/downloads/offline-get.csv", + options, + [&](ZBGetFileResponse response) { + ok = true; + std::ifstream in(response.localFilePath); + std::string body((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + EXPECT_NE(body.find("getfile-key@example.com"), std::string::npos); + EXPECT_EQ(body.find(" files) { + ASSERT_EQ(files.size(), 1u); + EXPECT_EQ(files.front().name, "p3-key"); + }, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto listedFiles = onlyRequest(api); + EXPECT_EQ(listedFiles.path, "/listS3files"); + EXPECT_EQ(listedFiles.fields.count("bucket"), 0u); + EXPECT_EQ(listedFiles.fields.count("search"), 0u); + + api.clear(); + client.listS3files(std::string("inbox"), std::string("csv"), [&](std::vector) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto filtered = onlyRequest(api); + EXPECT_EQ(filtered.fields["bucket"], "inbox"); + EXPECT_EQ(filtered.fields["search"], "csv"); + +} + +TEST(OfflineApi, S3AndSftpFileJobsSendTheDocumentedFieldsOnly) { + FakeApi api; + OfflineClient client(api, "p3-key"); + auto fail = [&](ZBErrorResponse e) { FAIL() << e.toString(); }; + + BulkFileJobOptions finder; + finder.fileName = "lists/leads.csv"; + finder.hasHeaderRow = true; + finder.returnUrl = "https://example.com/done"; + client.emailFinderS3File(finder, [&](ZBSendFileResponse) {}, fail); + auto found = onlyRequest(api); + EXPECT_EQ(found.method, "POST"); + EXPECT_EQ(found.path, "/emailFinderS3file"); + EXPECT_EQ(found.fields["file_name"], "lists/leads.csv"); + // Booleans go on the wire as the strings "true"/"false". + EXPECT_EQ(found.fields["has_header_row"], "true"); + EXPECT_EQ(found.fields["return_url"], "https://example.com/done"); + // Unset options stay off the request entirely. + EXPECT_EQ(found.fields.count("remove_duplicates"), 0u); + + api.clear(); + BulkFileJobOptions domains; + domains.fileName = "lists/leads.csv"; + domains.hasHeaderRow = false; + domains.removeDuplicates = true; + domains.returnUrl = "https://example.com/done"; + client.domainSearchS3File(domains, [&](ZBSendFileResponse) {}, fail); + auto searched = onlyRequest(api); + EXPECT_EQ(searched.path, "/domainSearchS3file"); + EXPECT_EQ(searched.fields["has_header_row"], "false"); + EXPECT_EQ(searched.fields["remove_duplicates"], "true"); + EXPECT_EQ(searched.fields["return_url"], "https://example.com/done"); + + // LIVE: file_name is required here too, although the docs list it as + // optional for this one endpoint. + api.clear(); + BulkFileJobOptions named; + named.fileName = "my_file.txt"; + named.returnUrl = "https://example.com/done"; + client.validateSFTPFile(named, [&](ZBSendFileResponse) {}, fail); + auto validated = onlyRequest(api); + EXPECT_EQ(validated.path, "/validateSFTPfile"); + EXPECT_EQ(validated.fields["file_name"], "my_file.txt"); + EXPECT_EQ(validated.fields["has_header_row"], "true"); + + api.clear(); + client.scoreSFTPFile(named, [&](ZBSendFileResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).path, "/scoreSFTPfile"); + + api.clear(); + client.emailFinderSFTPFile(named, [&](ZBSendFileResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).path, "/emailFinderSFTPfile"); + + api.clear(); + client.domainSearchSFTPFile(named, [&](ZBSendFileResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).path, "/domainSearchSFTPfile"); + + // exportSFTPfile takes export_path, not the export_bucket exportS3file uses. + api.clear(); + client.exportSFTPFile("fid", std::string("/exports"), [&](ZBSendFileResponse) {}, fail); + auto exportedSftp = onlyRequest(api); + EXPECT_EQ(exportedSftp.path, "/exportSFTPfile"); + EXPECT_EQ(exportedSftp.fields["file_id"], "fid"); + EXPECT_EQ(exportedSftp.fields["export_path"], "/exports"); + EXPECT_EQ(exportedSftp.fields.count("export_bucket"), 0u); + + api.clear(); + client.exportSFTPFile("fid", std::nullopt, [&](ZBSendFileResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).fields.count("export_path"), 0u); +} + +TEST(OfflineApi, S3AndSftpFileJobsRejectInvalidArgumentsBeforeRequest) { + FakeApi api; + OfflineClient client(api, "p3-key"); + auto succeeded = [&](ZBSendFileResponse) { FAIL() << "success"; }; + auto expectFileName = [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("file_name"), std::string::npos); + }; + + BulkFileJobOptions missing; + missing.returnUrl = "https://example.com/done"; + client.emailFinderS3File(missing, succeeded, expectFileName); + client.domainSearchS3File(missing, succeeded, expectFileName); + client.scoreSFTPFile(missing, succeeded, expectFileName); + client.emailFinderSFTPFile(missing, succeeded, expectFileName); + client.domainSearchSFTPFile(missing, succeeded, expectFileName); + + BulkFileJobOptions blank; + blank.fileName = " "; + blank.returnUrl = "https://example.com/done"; + client.validateSFTPFile(blank, succeeded, expectFileName); + + // LIVE: return_url is required on every one of these jobs. + auto expectReturnUrl = [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("return_url"), std::string::npos); + }; + BulkFileJobOptions noReturnUrl; + noReturnUrl.fileName = "f.csv"; + client.emailFinderS3File(noReturnUrl, succeeded, expectReturnUrl); + client.domainSearchS3File(noReturnUrl, succeeded, expectReturnUrl); + client.validateSFTPFile(noReturnUrl, succeeded, expectReturnUrl); + client.scoreSFTPFile(noReturnUrl, succeeded, expectReturnUrl); + client.emailFinderSFTPFile(noReturnUrl, succeeded, expectReturnUrl); + client.domainSearchSFTPFile(noReturnUrl, succeeded, expectReturnUrl); + + client.exportSFTPFile(" ", std::nullopt, succeeded, [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("file_id"), std::string::npos); + }); + client.exportSFTPFile("fid", std::string(" "), succeeded, [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("export_path"), std::string::npos); + }); + + EXPECT_EQ(api.size(), 0u); +} + +#endif + +TEST(OfflineApi, RejectsMissingKeyAndInvalidArgumentsBeforeRequest) { + FakeApi api; + OfflineClient client(api, ""); + auto reject = [&](const ZBErrorResponse& error) { + auto captured = captureError(error); + EXPECT_TRUE(captured.called); + EXPECT_NE(captured.text.find("initialize"), std::string::npos); + }; + client.getCredits([&](ZBCreditsResponse) { FAIL() << "success"; }, reject); + client.findEmailByDomain("example.com", "Ada", [&](ZBFindEmailResponse) { FAIL() << "success"; }, reject); + client.searchDomainByCompanyName("ZeroBounce", [&](ZBDomainSearchResponse) { FAIL() << "success"; }, reject); +#if ZB_OFFLINE_CURRENT + client.listFilters(false, [&](std::string) { FAIL() << "success"; }, reject); + client.emailFinderDeleteFile("fid", [&](ZBDeleteFileResponse) { FAIL() << "success"; }, reject); + client.sendDomainSearchFile(kCsv, 1, nullptr, [&](ZBSendFileResponse) { FAIL() << "success"; }, reject); +#endif + EXPECT_EQ(api.size(), 0u); + +#if ZB_OFFLINE_CURRENT + api.clear(); + OfflineClient keyed(api, "keyed"); + keyed.validate("a@example.com", "", ZBValidateOptions{2, std::nullopt, std::nullopt, false}, [&](ZBValidateResponse) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("timeout"), std::string::npos); + }); + ZBValidateOptions batchTimeout; + batchTimeout.timeout = 9; + keyed.validateBatch({{"a@example.com"}}, batchTimeout, [&](ZBValidateBatchResponse) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("timeout"), std::string::npos); + }); + keyed.sendEmailFinderFile(kCsv, 0, nullptr, [&](ZBSendFileResponse) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("domain_column"), std::string::npos); + }); + ValidateS3FileOptions missingName; + keyed.validateS3File(missingName, [&](ZBSendFileResponse) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + EXPECT_NE(error.toString().find("file_name"), std::string::npos); + }); + EXPECT_EQ(api.size(), 0u); +#endif +} + +TEST(OfflineApi, Html403IsAShortError) { + FakeApi api; + OfflineClient client(api, "html-403-user"); + bool errored = false; + client.getCredits([&](ZBCreditsResponse) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + auto captured = captureError(error); + errored = true; + EXPECT_NE(captured.text.find("Access denied"), std::string::npos); + EXPECT_GT(accessDeniedPage().size(), 100000u); + }); + EXPECT_TRUE(errored); + EXPECT_EQ(api.size(), 1u); +} + +TEST(OfflineApi, Html200OnValidateIsAnError) { + FakeApi api; + OfflineClient client(api, "html-200-user"); + bool errored = false; + client.validate("a@example.com", "", [&](ZBValidateResponse) { FAIL() << "HTML was treated as JSON"; }, [&](ZBErrorResponse error) { + auto captured = captureError(error); + errored = true; + EXPECT_NE(captured.text.find("Access denied"), std::string::npos); + }); + EXPECT_TRUE(errored); +} + +TEST(OfflineApi, Html200GetFileIsNotSavedAsCsv) { + FakeApi api; + OfflineClient client(api, "html-200-file"); + const std::string path = "../test/downloads/offline-html.csv"; + std::filesystem::remove(path); + bool errored = false; + client.getFile("fid", path, [&](ZBGetFileResponse) { FAIL() << "HTML page was saved as a file"; }, [&](ZBErrorResponse error) { + auto captured = captureError(error); + errored = true; + EXPECT_NE(captured.text.find("Access denied"), std::string::npos); + }); + EXPECT_TRUE(errored); + EXPECT_FALSE(std::filesystem::exists(path)); +} + +TEST(OfflineApi, CloudflareJson403DoesNotDumpTheBody) { + FakeApi api; + OfflineClient client(api, "cf-json-user"); + bool errored = false; + client.getCredits([&](ZBCreditsResponse) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + auto captured = captureError(error); + errored = true; + EXPECT_EQ(captured.text.find("developers.cloudflare.com"), std::string::npos); + EXPECT_NE(captured.text.find("Access denied"), std::string::npos); + }); + EXPECT_TRUE(errored); +} + +TEST(OfflineApi, EmptyAndMalformedBodiesAreShortErrors) { + FakeApi api; + OfflineClient emptyClient(api, "empty-user"); + bool emptyError = false; + emptyClient.validate("a@example.com", "", [&](ZBValidateResponse) { FAIL() << "empty success"; }, [&](ZBErrorResponse error) { + auto captured = captureError(error); + emptyError = true; + EXPECT_NE(captured.text.find("Empty response"), std::string::npos); + }); + EXPECT_TRUE(emptyError); + + OfflineClient malformedClient(api, "malformed-user"); + bool malformed = false; + malformedClient.validate("a@example.com", "", [&](ZBValidateResponse) { FAIL() << "malformed success"; }, [&](ZBErrorResponse error) { + auto captured = captureError(error); + malformed = true; + EXPECT_NE(captured.text.find("not valid JSON"), std::string::npos); + EXPECT_EQ(captured.text.find("<<>>"), std::string::npos); + }); + EXPECT_TRUE(malformed); +} + +TEST(OfflineApi, DoubledErrorPrefixIsCollapsed) { + FakeApi api; + OfflineClient client(api, "double-error-user"); + bool errored = false; + client.getActivityData("a@example.com", [&](ZBActivityDataResponse) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + auto captured = captureError(error); + errored = true; + EXPECT_EQ(captured.text, "api_key is invalid"); + }); + EXPECT_TRUE(errored); +} + +TEST(OfflineApi, BatchAccountErrorIsNotASuccess) { + FakeApi api; + OfflineClient client(api, "batch-account-user"); + bool errored = false; + client.validateBatch( + {{"a@example.com"}}, + [&](ZBValidateBatchResponse response) { FAIL() << response.toString(); }, + [&](ZBErrorResponse error) { + auto captured = captureError(error); + errored = true; + EXPECT_NE(captured.text.find("Invalid API Key"), std::string::npos); + EXPECT_NE(captured.text.find("ran out of credits"), std::string::npos); + } + ); + EXPECT_TRUE(errored); +} + +TEST(OfflineApi, BatchPartialErrorsStayOnTheSuccessResult) { + FakeApi api; + OfflineClient client(api, "batch-partial-user"); + bool ok = false; + client.validateBatch( + {{"good@example.com"}, {"bad@"}}, + [&](ZBValidateBatchResponse response) { + ok = true; + ASSERT_EQ(response.emailBatch.size(), 1u); + EXPECT_EQ(response.emailBatch.front().address, "good@example.com"); + ASSERT_EQ(response.errors.size(), 1u); + EXPECT_EQ(response.errors.front().emailAddress, "bad@"); + EXPECT_EQ(response.errors.front().error, "Invalid email address"); + }, + [&](ZBErrorResponse error) { FAIL() << error.toString(); } + ); + EXPECT_TRUE(ok); +} + +TEST(OfflineApi, ConcurrentClientsDoNotShareKeys) { + FakeApi api; + constexpr int kCalls = 12; + std::vector echoes(kCalls); + std::vector threads; + for (int i = 0; i < kCalls; ++i) { + threads.emplace_back([&, i] { + const std::string key = "conc-" + std::to_string(i); + OfflineClient client(api, key); + client.validate( + "person@example.com", + "", + [&](ZBValidateResponse response) { echoes[i] = response.address; }, + [&](ZBErrorResponse error) { echoes[i] = "ERROR " + error.toString(); } + ); + }); + } + for (auto& thread : threads) { + thread.join(); + } + auto records = api.take(); + ASSERT_EQ(records.size(), static_cast(kCalls)); + std::map seen; + for (const auto& request : records) { + seen[request.apiKey]++; + EXPECT_EQ(request.apiKey.rfind("conc-", 0), 0u); + EXPECT_EQ(request.fields.at("api_key"), request.apiKey); + } + for (int i = 0; i < kCalls; ++i) { + const std::string key = "conc-" + std::to_string(i); + EXPECT_EQ(seen[key], 1); + EXPECT_EQ(echoes[i], key); + } +} + +TEST(OfflineApi, ValidateResponseEqualityIncludesSubStatus) { + ZBValidateResponse left; + left.address = "a@example.com"; + left.status = ZBValidateStatus::Valid; + left.subStatus = ZBValidateSubStatus::None; + ZBValidateResponse right = left; + right.subStatus = ZBValidateSubStatus::MailboxNotFound; + EXPECT_NE(left, right); + right.subStatus = left.subStatus; + right.catchallDomain = true; + EXPECT_NE(left, right); +} + +TEST(OfflineApi, ValidateSendsTheNewOptionsOnlyWhenSet) { + FakeApi api; + OfflineClient client(api, "parity-key"); + + client.validate("a@example.com", "", ZBValidateOptions{}, [&](ZBValidateResponse) {}, + [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto plain = onlyRequest(api); + EXPECT_EQ(plain.url.find("domain_info"), std::string::npos); + EXPECT_EQ(plain.url.find("greylist_processing"), std::string::npos); + EXPECT_EQ(plain.url.find("callback_url"), std::string::npos); + + api.clear(); + ZBValidateOptions set; + set.domainInfo = true; + set.greylistProcessing = false; + client.validate("a@example.com", "", set, [&](ZBValidateResponse) {}, + [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto withOptions = onlyRequest(api); + EXPECT_NE(withOptions.url.find("domain_info=true"), std::string::npos); + EXPECT_NE(withOptions.url.find("greylist_processing=false"), std::string::npos); + + api.clear(); + ZBValidateOptions posted; + posted.greylistProcessing = true; + posted.callbackUrl = std::string("https://example.com/cb"); + posted.usePost = true; + client.validate("a@example.com", "", posted, [&](ZBValidateResponse) {}, + [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto form = onlyRequest(api); + EXPECT_EQ(form.method, "POST"); + EXPECT_NE(form.body.find("greylist_processing=true"), std::string::npos); + EXPECT_NE(form.body.find("callback_url="), std::string::npos); +} + +TEST(OfflineApi, CallbackUrlWithoutGreylistProcessingSendsNoRequest) { + FakeApi api; + OfflineClient client(api, "parity-key"); + + ZBValidateOptions lonely; + lonely.callbackUrl = std::string("https://example.com/cb"); + bool rejected = false; + client.validate("a@example.com", "", lonely, [&](ZBValidateResponse) { FAIL() << "success"; }, + [&](ZBErrorResponse error) { + rejected = true; + EXPECT_NE(error.toString().find("greylist_processing"), std::string::npos); + }); + EXPECT_TRUE(rejected); + + ZBValidateOptions blank; + blank.greylistProcessing = true; + blank.callbackUrl = std::string(" "); + rejected = false; + client.validate("a@example.com", "", blank, [&](ZBValidateResponse) { FAIL() << "success"; }, + [&](ZBErrorResponse error) { + rejected = true; + EXPECT_NE(error.toString().find("callback_url"), std::string::npos); + }); + EXPECT_TRUE(rejected); + EXPECT_EQ(api.size(), 0u); +} + +TEST(OfflineApi, InboxPlacementUsesItsOwnHostAndCamelCaseJson) { + FakeApi api; + OfflineClient client(api, "ipt-key"); + + client.getInboxPlacementProviders([&](std::string) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto providers = onlyRequest(api); + EXPECT_EQ(providers.method, "GET"); + EXPECT_EQ(providers.path, "/api/ipt/providers"); + // LIVE: the Inbox Placement Tester authenticates with a Bearer header, and + // the key must not reach the query string, where it would be logged. + EXPECT_EQ(providers.authorization, "Bearer ipt-key"); + EXPECT_EQ(providers.url.find("api_key"), std::string::npos); + + api.clear(); + client.getInboxPlacementTestTypes([&](std::string) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + EXPECT_EQ(onlyRequest(api).path, "/api/ipt/test-types"); + + api.clear(); + client.createInboxPlacementTest("basic", CreateInboxPlacementTestOptions{}, [&](std::string) {}, + [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto created = onlyRequest(api); + EXPECT_EQ(created.method, "POST"); + EXPECT_EQ(created.path, "/api/ipt/test"); + EXPECT_EQ(created.body, "{\"name\":\"basic\"}"); + + api.clear(); + CreateInboxPlacementTestOptions full; + full.testType = 1; + full.senderEmail = std::string("from@example.com"); + full.webhookUrl = std::string("https://example.com/hook"); + client.createInboxPlacementTest("full", full, [&](std::string) {}, + [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto body = onlyRequest(api).body; + EXPECT_NE(body.find("\"testType\":1"), std::string::npos); + EXPECT_NE(body.find("\"senderEmail\":\"from@example.com\""), std::string::npos); + EXPECT_NE(body.find("\"webhookUrl\""), std::string::npos); + + api.clear(); + client.getInboxPlacementTest("a b/c", [&](std::string) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + EXPECT_NE(onlyRequest(api).url.find("/api/ipt/test/a%20b%2Fc"), std::string::npos); + + api.clear(); + client.deleteInboxPlacementTest("t1", [&](std::string) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + EXPECT_EQ(onlyRequest(api).method, "DELETE"); + + api.clear(); + client.setInboxPlacementWebhook("https://example.com/hook", [&](std::string) {}, + [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto webhook = onlyRequest(api); + EXPECT_EQ(webhook.method, "PUT"); + EXPECT_NE(webhook.body.find("\"webhookUrl\""), std::string::npos); +} + +TEST(OfflineApi, InboxPlacementListSendsOnlyTheFiltersThatAreSet) { + FakeApi api; + OfflineClient client(api, "ipt-key"); + + client.listInboxPlacementTests(ListInboxPlacementTestsOptions{}, [&](std::string) {}, + [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto bare = onlyRequest(api); + EXPECT_EQ(bare.url.find("status="), std::string::npos); + EXPECT_EQ(bare.url.find("page="), std::string::npos); + + api.clear(); + ListInboxPlacementTestsOptions filters; + filters.status = std::string("finished"); + filters.createdAfter = std::string("2026-01-01"); + filters.page = 2; + filters.pageSize = 50; + client.listInboxPlacementTests(filters, [&](std::string) {}, + [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto url = onlyRequest(api).url; + EXPECT_NE(url.find("status=finished"), std::string::npos); + EXPECT_NE(url.find("createdAfter=2026-01-01"), std::string::npos); + EXPECT_NE(url.find("page=2"), std::string::npos); + EXPECT_NE(url.find("page_size=50"), std::string::npos); +} + +TEST(OfflineApi, InboxPlacementRejectsInvalidArgumentsBeforeRequest) { + FakeApi api; + OfflineClient client(api, "ipt-key"); + auto expectRejected = [&](auto call) { + bool rejected = false; + call([&](std::string) { FAIL() << "success"; }, [&](ZBErrorResponse) { rejected = true; }); + EXPECT_TRUE(rejected); + }; + + expectRejected([&](auto ok, auto err) { client.createInboxPlacementTest("", {}, ok, err); }); + expectRejected([&](auto ok, auto err) { client.createInboxPlacementTest(std::string(65, 'x'), {}, ok, err); }); + expectRejected([&](auto ok, auto err) { + CreateInboxPlacementTestOptions bad; bad.testType = 4; + client.createInboxPlacementTest("t", bad, ok, err); + }); + expectRejected([&](auto ok, auto err) { + CreateInboxPlacementTestOptions bad; bad.testType = 1; + client.createInboxPlacementTest("t", bad, ok, err); + }); + expectRejected([&](auto ok, auto err) { + CreateInboxPlacementTestOptions bad; + bad.providers = std::vector{{1, 0}}; + client.createInboxPlacementTest("t", bad, ok, err); + }); + expectRejected([&](auto ok, auto err) { + ListInboxPlacementTestsOptions bad; bad.status = std::string("queued"); + client.listInboxPlacementTests(bad, ok, err); + }); + expectRejected([&](auto ok, auto err) { + ListInboxPlacementTestsOptions bad; bad.pageSize = 101; + client.listInboxPlacementTests(bad, ok, err); + }); + expectRejected([&](auto ok, auto err) { client.getInboxPlacementTest(" ", ok, err); }); + expectRejected([&](auto ok, auto err) { client.deleteInboxPlacementTest("", ok, err); }); + expectRejected([&](auto ok, auto err) { client.setInboxPlacementWebhook(" ", ok, err); }); + EXPECT_EQ(api.size(), 0u); +} + +TEST(OfflineApi, InboxPlacementDetailErrorBecomesTheMessage) { + FakeApi api; + OfflineClient client(api, "ipt-detail-key"); + + bool rejected = false; + client.getInboxPlacementProviders([&](std::string) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + rejected = true; + EXPECT_NE(error.toString().find("Inbox placement test not found"), std::string::npos); + EXPECT_LT(error.toString().size(), 500u); + }); + EXPECT_TRUE(rejected); +} + +TEST(OfflineApi, SuccessFalseOnAnHttp200IsAnError) { + FakeApi api; + OfflineClient client(api, "success-false-key"); + + // LIVE: the API answers an unknown file id with an HTTP 200 whose body + // says success: false. + bool rejected = false; + client.fileStatus("0d1b7a4e-1111-2222-3333-444455556666", + [&](ZBFileStatusResponse) { FAIL() << "success"; }, + [&](ZBErrorResponse error) { + rejected = true; + EXPECT_EQ(error.statusCode, 200); + EXPECT_NE(error.toString().find("File cannot be found."), std::string::npos); + EXPECT_LT(error.toString().size(), 500u); + }); + EXPECT_TRUE(rejected); +} + +TEST(OfflineApi, ListEvaluatorUsesItsOwnBaseUrlAndEncodesFileId) { + FakeApi api; + OfflineClient client(api, "le-key"); + + client.getListEvaluatorResult("a b/c", [&](std::string) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + EXPECT_NE(onlyRequest(api).url.find("/v2/listevaluator/a%20b%2Fc/"), std::string::npos); + + api.clear(); + client.setListEvaluatorBaseUrl("https://example.com/custom/listevaluator"); + client.getListEvaluatorResult("fid", [&](std::string) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + EXPECT_NE(onlyRequest(api).url.find("/custom/listevaluator/fid/"), std::string::npos); +} diff --git a/test/OfflineWarmupTest.cpp b/test/OfflineWarmupTest.cpp new file mode 100644 index 0000000..74a424b --- /dev/null +++ b/test/OfflineWarmupTest.cpp @@ -0,0 +1,670 @@ +// Offline tests for the Warmup API client. A fake request handler records +// every request and answers without the network. + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "ZeroBounce/ZeroBounceWarmup.h" + +using json = nlohmann::json; + +namespace { + +struct WarmupRequest { + std::string method; + std::string host; + std::string path; + std::string apiKey; + std::map query; + std::string body; +}; + +std::string urlDecode(const std::string& value) { + std::string out; + for (size_t i = 0; i < value.size(); ++i) { + if (value[i] == '%' && i + 2 < value.size()) { + out.push_back(static_cast(std::stoi(value.substr(i + 1, 2), nullptr, 16))); + i += 2; + } else if (value[i] == '+') { + out.push_back(' '); + } else { + out.push_back(value[i]); + } + } + return out; +} + +class FakeWarmupApi : public BaseRequestHandler { +public: + std::vector take() { + std::lock_guard lock(mu_); + auto records = records_; + records_.clear(); + return records; + } + + size_t size() { + std::lock_guard lock(mu_); + return records_.size(); + } + + void replyWith(long status, std::string contentType, std::string body) { + std::lock_guard lock(mu_); + next_ = cpr::Response{}; + next_->status_code = status; + next_->text = std::move(body); + next_->header["Content-Type"] = std::move(contentType); + } + +protected: + cpr::Response doGet(const cpr::Url& url) override { return respond("GET", url.str(), "", {}); } + cpr::Response doGet(const cpr::Url& url, const cpr::Header& header) override { + return respond("GET", url.str(), "", header); + } + cpr::Response doGetWithParameters(const cpr::Url& url, const cpr::Parameters&) override { + return respond("GET", url.str(), "", {}); + } + cpr::Response doPost(const cpr::Url& url, const cpr::Header& header, const cpr::Body& body) override { + return respond("POST", url.str(), body.str(), header); + } + cpr::Response doPost(const cpr::Url& url, const cpr::Header& header, const cpr::Multipart&) override { + return respond("POST", url.str(), "", header); + } + cpr::Response doPut(const cpr::Url& url, const cpr::Header& header, const cpr::Body& body) override { + return respond("PUT", url.str(), std::string(body), header); + } + cpr::Response doDelete(const cpr::Url& url, const cpr::Header& header) override { + return respond("DELETE", url.str(), "", header); + } + cpr::Response doDelete(const cpr::Url& url, const cpr::Header& header, const cpr::Body& body) override { + return respond("DELETE", url.str(), std::string(body), header); + } + +private: + cpr::Response respond( + const std::string& method, + const std::string& url, + const std::string& body, + const cpr::Header& header + ) { + WarmupRequest recorded; + recorded.method = method; + recorded.body = body; + + auto schemeEnd = url.find("://"); + auto hostStart = schemeEnd == std::string::npos ? 0 : schemeEnd + 3; + auto pathStart = url.find('/', hostStart); + recorded.host = url.substr(hostStart, pathStart - hostStart); + auto queryStart = url.find('?', pathStart); + recorded.path = url.substr(pathStart, queryStart - pathStart); + if (queryStart != std::string::npos) { + std::string query = url.substr(queryStart + 1); + size_t at = 0; + while (at < query.size()) { + auto amp = query.find('&', at); + std::string pair = query.substr(at, amp - at); + auto eq = pair.find('='); + if (eq != std::string::npos) { + recorded.query[urlDecode(pair.substr(0, eq))] = urlDecode(pair.substr(eq + 1)); + } + if (amp == std::string::npos) break; + at = amp + 1; + } + } + auto keyIt = header.find("API-Key"); + recorded.apiKey = keyIt == header.end() ? "" : keyIt->second; + + cpr::Response response; + { + std::lock_guard lock(mu_); + records_.push_back(recorded); + if (next_.has_value()) { + response = *next_; + next_.reset(); + return response; + } + } + response.status_code = 200; + response.text = R"({"ok":true,"echo_api_key":")" + recorded.apiKey + R"("})"; + response.header["Content-Type"] = "application/json"; + return response; + } + + std::mutex mu_; + std::vector records_; + std::optional next_; +}; + +class OfflineWarmupClient : public ZeroBounceWarmup { +public: + OfflineWarmupClient(FakeWarmupApi& api, const std::string& key = "warmup-key", + const std::string& baseUrl = "https://warmup-api.zerobounce.net") { + initialize(key, baseUrl); + setRequestHandler(&api); + } +}; + +WarmupRequest onlyRequest(FakeWarmupApi& api) { + auto records = api.take(); + EXPECT_EQ(records.size(), 1u); + if (records.empty()) { + return {}; + } + return records.front(); +} + +/// Run a call that must fail, and hand back the error's text. +std::string captureError(const std::function& call) { + std::string message; + bool rejected = false; + call([](std::string) { ADD_FAILURE() << "unexpected success"; }, + [&](ZBErrorResponse error) { + rejected = true; + message = error.toString(); + EXPECT_LT(message.size(), 500u) << message.substr(0, 120); + }); + EXPECT_TRUE(rejected); + return message; +} + +auto succeed = [](std::string) {}; +auto failOnError = [](ZBErrorResponse error) { FAIL() << error.toString(); }; + +std::string accessDeniedPage() { + std::string page = + "Access denied | zerobounce.net" + ""; + for (int i = 0; i < 2000; ++i) { + page += "

blocked

"; + } + return page + ""; +} + +} // namespace + +// -- construction ------------------------------------------------------------ + +TEST(OfflineWarmup, TheKeyTravelsInTheApiKeyHeader) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + + client.getSeedCount(succeed, failOnError); + auto request = onlyRequest(api); + EXPECT_EQ(request.host, "warmup-api.zerobounce.net"); + EXPECT_EQ(request.path, "/api/v1/warmup/seed-count"); + EXPECT_EQ(request.apiKey, "warmup-key"); + // The key must never reach the query string, where it would be logged. + EXPECT_TRUE(request.query.empty()); +} + +TEST(OfflineWarmup, AKeyThatIsEmptyOrPaddedIsRejected) { + FakeWarmupApi api; + for (const std::string& bad : {std::string(""), std::string(" key "), std::string("key\n"), + std::string("key\tx")}) { + OfflineWarmupClient client(api, bad); + std::string message = captureError([&](auto ok, auto err) { client.getSeeds(ok, err); }); + EXPECT_NE(message.find("api_key"), std::string::npos) << message; + } + EXPECT_EQ(api.size(), 0u); +} + +TEST(OfflineWarmup, APlainHttpBaseUrlIsRejected) { + FakeWarmupApi api; + OfflineWarmupClient client(api, "warmup-key", "http://warmup-api.zerobounce.net"); + std::string message = captureError([&](auto ok, auto err) { client.getSeeds(ok, err); }); + EXPECT_NE(message.find("https"), std::string::npos) << message; + EXPECT_EQ(api.size(), 0u); +} + +TEST(OfflineWarmup, ACustomHttpsBaseUrlIsUsed) { + FakeWarmupApi api; + OfflineWarmupClient client(api, "warmup-key", "https://warmup.example.com/"); + client.getSeeds(succeed, failOnError); + auto request = onlyRequest(api); + EXPECT_EQ(request.host, "warmup.example.com"); + EXPECT_EQ(request.path, "/api/v1/warmup/seeds"); +} + +// -- paths and verbs --------------------------------------------------------- + +TEST(OfflineWarmup, EveryEndpointUsesItsDocumentedPathAndVerb) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + + client.listSendingAccounts(succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/sending-accounts"); + client.getSendingAccount("a@b.com", succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/sending-account"); + client.listFromAddresses({}, succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/from-addresses"); + client.getEngagementRules(succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/engagement-rules"); + client.getCustomEngagement("a@b.com", succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/custom-engagement/a%40b.com"); + client.getSeeds(succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/seeds"); + client.getProgressToday(succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/progress-today"); + client.getSentToday(succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/sent-today"); + client.getEmailsPerDay(succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/emails-per-day"); + client.getStatistics("a@b.com", succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/statistics"); + client.getStatisticsDetailed("a@b.com", succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/statistics-detailed"); + client.getFromAddressStatistics("a@b.com", succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/from-address-statistics"); + client.checkDomainDmarc("a@b.com", std::nullopt, succeed, failOnError); + EXPECT_EQ(onlyRequest(api).path, "/api/v1/warmup/check-domain-dmarc"); + + WarmupAddSendingAccountOptions selfManaged; + selfManaged.engagementRuleId = 0; + client.addSendingAccount("a@b.com", "self_managed", selfManaged, succeed, failOnError); + auto added = onlyRequest(api); + EXPECT_EQ(added.method, "POST"); + EXPECT_EQ(added.path, "/api/v1/warmup/sending-accounts"); + + client.updateSendingAccount("a@b.com", 1, 4, true, succeed, failOnError); + EXPECT_EQ(onlyRequest(api).method, "PUT"); + client.deleteSendingAccount("a@b.com", succeed, failOnError); + EXPECT_EQ(onlyRequest(api).method, "DELETE"); + client.changeFromAddress("a@b.com", "c@d.com", succeed, failOnError); + auto changed = onlyRequest(api); + EXPECT_EQ(changed.method, "PUT"); + EXPECT_EQ(changed.path, "/api/v1/warmup/smtp-credentials"); + client.setEngagementRule("a@b.com", 2, succeed, failOnError); + auto rule = onlyRequest(api); + EXPECT_EQ(rule.method, "PUT"); + EXPECT_EQ(rule.path, "/api/v1/warmup/engagement-rule"); + client.createCustomEngagement("a@b.com", 10, 10, 5, succeed, failOnError); + auto created = onlyRequest(api); + EXPECT_EQ(created.method, "POST"); + EXPECT_EQ(created.path, "/api/v1/warmup/custom-engagement"); + client.updateCustomEngagement("a@b.com", 10, 10, 5, succeed, failOnError); + EXPECT_EQ(onlyRequest(api).method, "PUT"); + client.deleteCustomEngagement("a@b.com", 1, succeed, failOnError); + EXPECT_EQ(onlyRequest(api).method, "DELETE"); + client.testSmtpConfiguration("a@b.com", "u", "p", "smtp.example.com", 587, succeed, failOnError); + auto smtp = onlyRequest(api); + EXPECT_EQ(smtp.method, "POST"); + EXPECT_EQ(smtp.path, "/api/v1/warmup/test-smtp-configuration"); +} + +TEST(OfflineWarmup, StatisticsPerDaySendsBothDates) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + client.getStatisticsPerDay("a@b.com", "2026-01-02", "2026-01-09", succeed, failOnError); + auto request = onlyRequest(api); + EXPECT_EQ(request.path, "/api/v1/warmup/statistics-per-day"); + EXPECT_EQ(request.query["start_date"], "2026-01-02"); + EXPECT_EQ(request.query["end_date"], "2026-01-09"); +} + +// -- argument validation ----------------------------------------------------- + +TEST(OfflineWarmup, RequiredArgumentsAreCheckedBeforeAnyRequest) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + + captureError([&](auto ok, auto err) { client.getSendingAccount("", ok, err); }); + captureError([&](auto ok, auto err) { client.deleteSendingAccount(" ", ok, err); }); + captureError([&](auto ok, auto err) { client.changeFromAddress("", "b@c.com", ok, err); }); + captureError([&](auto ok, auto err) { client.changeFromAddress("a@b.com", "", ok, err); }); + captureError([&](auto ok, auto err) { client.setEngagementRule("", 1, ok, err); }); + captureError([&](auto ok, auto err) { client.getStatistics("", ok, err); }); + captureError([&](auto ok, auto err) { client.checkDomainDmarc("", std::nullopt, ok, err); }); + EXPECT_EQ(api.size(), 0u) << "nothing should reach the network"; +} + +TEST(OfflineWarmup, EngagementRuleIdsAreRangeChecked) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + for (int bad : {-1, 5}) { + std::string message = + captureError([&](auto ok, auto err) { client.setEngagementRule("a@b.com", bad, ok, err); }); + EXPECT_NE(message.find("rule_id"), std::string::npos) << message; + } + EXPECT_EQ(api.size(), 0u); + + for (int good = 0; good <= 4; ++good) { + client.setEngagementRule("a@b.com", good, succeed, failOnError); + EXPECT_EQ(json::parse(onlyRequest(api).body)["rule_id"], good); + } +} + +TEST(OfflineWarmup, CustomEngagementPercentagesAreRangeChecked) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + const std::vector> bad{ + {4, 10, 5}, // reply_pct below 5 + {46, 10, 5}, // reply_pct above 45 + {10, 4, 5}, // important_pct below 5 + {10, 41, 5}, // important_pct above 40 + {10, 10, 71}, // click_pct above 70 + {10, 10, 18}, // click_pct above important_pct x 1.75 + }; + for (const auto& values : bad) { + captureError([&](auto ok, auto err) { + client.createCustomEngagement("a@b.com", values[0], values[1], values[2], ok, err); + }); + } + EXPECT_EQ(api.size(), 0u); + + client.createCustomEngagement("a@b.com", 20, 20, 35, succeed, failOnError); + auto body = json::parse(onlyRequest(api).body); + EXPECT_EQ(body["reply_pct"], 20); + EXPECT_EQ(body["click_pct"], 35); +} + +TEST(OfflineWarmup, DeleteCustomEngagementOnlyFallsBackToAStandardRule) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + std::string message = + captureError([&](auto ok, auto err) { client.deleteCustomEngagement("a@b.com", 0, ok, err); }); + EXPECT_NE(message.find("engagement_rule_id"), std::string::npos) << message; + EXPECT_EQ(api.size(), 0u); + + client.deleteCustomEngagement("a@b.com", 3, succeed, failOnError); + EXPECT_EQ(json::parse(onlyRequest(api).body)["engagement_rule_id"], 3); +} + +TEST(OfflineWarmup, UpdateSendingAccountChecksItsRanges) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + captureError([&](auto ok, auto err) { client.updateSendingAccount("a@b.com", 5, 4, true, ok, err); }); + captureError([&](auto ok, auto err) { client.updateSendingAccount("a@b.com", 1, 9, true, ok, err); }); + EXPECT_EQ(api.size(), 0u); + + client.updateSendingAccount("a@b.com", 1, 5, false, succeed, failOnError); + auto body = json::parse(onlyRequest(api).body); + EXPECT_EQ(body["team_id"], 1); + EXPECT_EQ(body["status_id"], 5); + EXPECT_EQ(body["auto_enable"], false); +} + +TEST(OfflineWarmup, AddSendingAccountChecksTheOnboardingModel) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + + std::string message = captureError([&](auto ok, auto err) { + client.addSendingAccount("a@b.com", "managed", {}, ok, err); + }); + EXPECT_NE(message.find("self_managed"), std::string::npos) << message; + + // self_managed needs its engagement rule and takes no ZB-managed fields. + captureError([&](auto ok, auto err) { + client.addSendingAccount("a@b.com", "self_managed", {}, ok, err); + }); + WarmupAddSendingAccountOptions mixed; + mixed.engagementRuleId = 0; + mixed.emailContent = WarmupEmailContent{}; + captureError([&](auto ok, auto err) { + client.addSendingAccount("a@b.com", "self_managed", mixed, ok, err); + }); + EXPECT_EQ(api.size(), 0u); + + WarmupAddSendingAccountOptions good; + good.engagementRuleId = 2; + good.ignoreConflict = true; + client.addSendingAccount("a@b.com", "self_managed", good, succeed, failOnError); + auto body = json::parse(onlyRequest(api).body); + EXPECT_EQ(body["onboarding_model"], "self_managed"); + EXPECT_EQ(body["engagement_rule_id"], 2); + EXPECT_EQ(body["ignore_conflict"], true); +} + +TEST(OfflineWarmup, AddSendingAccountChecksTheZbManagedPayload) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + + WarmupAddSendingAccountOptions badTypes; + badTypes.emailContent = WarmupEmailContent{{"promotional"}, std::nullopt, std::nullopt, std::nullopt}; + captureError([&](auto ok, auto err) { + client.addSendingAccount("a@b.com", "zb_managed", badTypes, ok, err); + }); + + WarmupAddSendingAccountOptions badOffset; + WarmupManagedSending offset; + offset.timezoneOffset = 15; + badOffset.managedSending = offset; + captureError([&](auto ok, auto err) { + client.addSendingAccount("a@b.com", "zb_managed", badOffset, ok, err); + }); + + WarmupAddSendingAccountOptions badDate; + WarmupManagedSending date; + date.warmupStartDate = "02/01/2026"; + badDate.managedSending = date; + captureError([&](auto ok, auto err) { + client.addSendingAccount("a@b.com", "zb_managed", badDate, ok, err); + }); + EXPECT_EQ(api.size(), 0u); + + WarmupAddSendingAccountOptions good; + good.emailContent = WarmupEmailContent{ + {"newsletter", "marketing"}, std::nullopt, std::nullopt, std::nullopt}; + WarmupManagedSending sending; + sending.timezoneOffset = -5; + sending.warmupStartDate = "2026-03-04"; + good.managedSending = sending; + client.addSendingAccount("a@b.com", "zb_managed", good, succeed, failOnError); + auto body = json::parse(onlyRequest(api).body); + EXPECT_EQ(body["onboarding_model"], "zb_managed"); + EXPECT_EQ(body["email_content"]["types_of_emails"][1], "marketing"); + EXPECT_EQ(body["managed_sending"]["warmup_start_date"], "2026-03-04"); +} + +TEST(OfflineWarmup, ListFromAddressesChecksItsSearchAndPaging) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + + WarmupListFromAddressesOptions bad; + bad.searchFrom = "bad search!"; + captureError([&](auto ok, auto err) { client.listFromAddresses(bad, ok, err); }); + WarmupListFromAddressesOptions negative; + negative.offset = -1; + captureError([&](auto ok, auto err) { client.listFromAddresses(negative, ok, err); }); + EXPECT_EQ(api.size(), 0u); + + WarmupListFromAddressesOptions good; + good.searchFrom = "a.b-c_d@e+f"; + good.offset = 0; + good.limit = 50; + client.listFromAddresses(good, succeed, failOnError); + auto request = onlyRequest(api); + EXPECT_EQ(request.query["search_from"], "a.b-c_d@e+f"); + EXPECT_EQ(request.query["offset"], "0"); + EXPECT_EQ(request.query["limit"], "50"); +} + +TEST(OfflineWarmup, StatisticsPerDayRejectsABackwardsRangeAndBadDates) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + captureError([&](auto ok, auto err) { + client.getStatisticsPerDay("a@b.com", "2026-02-02", "2026-01-01", ok, err); + }); + captureError([&](auto ok, auto err) { + client.getStatisticsPerDay("a@b.com", "01/02/2026", "2026-01-01", ok, err); + }); + EXPECT_EQ(api.size(), 0u); +} + +TEST(OfflineWarmup, CheckDomainDmarcRequiresAnEmailAddressNotADomain) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + // LIVE: the API answers a bare domain with "Please provide a valid email + // address", although the docs describe a domain. + std::string message = captureError([&](auto ok, auto err) { + client.checkDomainDmarc("example.com", std::nullopt, ok, err); + }); + EXPECT_NE(message.find("email address"), std::string::npos) << message; + EXPECT_EQ(api.size(), 0u); + + client.checkDomainDmarc("sender@example.com", std::string("zb"), succeed, failOnError); + EXPECT_EQ(onlyRequest(api).query["dkim_selector"], "zb"); +} + +TEST(OfflineWarmup, CustomEngagementPathIdsCannotTraverse) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + for (const std::string& bad : {std::string("."), std::string(".."), std::string(" .. ")}) { + captureError([&](auto ok, auto err) { client.getCustomEngagement(bad, ok, err); }); + } + EXPECT_EQ(api.size(), 0u); +} + +TEST(OfflineWarmup, TheSmtpTestChecksItsPort) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + for (int bad : {0, 65536}) { + std::string message = captureError([&](auto ok, auto err) { + client.testSmtpConfiguration("a@b.com", "u", "p", "s", bad, ok, err); + }); + EXPECT_NE(message.find("port"), std::string::npos) << message; + } + EXPECT_EQ(api.size(), 0u); +} + +// -- responses --------------------------------------------------------------- + +TEST(OfflineWarmup, AnEmptySuccessBecomesNull) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + + api.replyWith(204, "application/json", ""); + std::string body; + client.deleteSendingAccount("a@b.com", [&](std::string text) { body = text; }, failOnError); + EXPECT_EQ(body, "null"); + api.take(); + + api.replyWith(200, "application/json", ""); + body.clear(); + client.getSeedCount([&](std::string text) { body = text; }, failOnError); + EXPECT_EQ(body, "null"); +} + +TEST(OfflineWarmup, CsvDownloadsComeBackAsText) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + + api.replyWith(200, "text/csv", "seed,provider\na@b.com,gmail\n"); + std::string csv; + client.downloadSeeds([&](std::string text) { csv = text; }, + [](ZBErrorResponse e) { FAIL() << e.toString(); }); + EXPECT_EQ(csv, "seed,provider\na@b.com,gmail\n"); + api.take(); + + api.replyWith(200, "text/csv", "day,sent\n2026-01-01,10\n"); + client.downloadStatistics("a@b.com", [](std::string) {}, + [](ZBErrorResponse e) { FAIL() << e.toString(); }); + EXPECT_EQ(onlyRequest(api).query["c_from"], "a@b.com"); +} + +TEST(OfflineWarmup, AJsonErrorInPlaceOfACsvIsAnError) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + api.replyWith(200, "application/json", R"({"message":"No statistics for this address."})"); + bool rejected = false; + client.downloadSeeds([](std::string) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + rejected = true; + EXPECT_NE(error.toString().find("No statistics for this address."), std::string::npos); + }); + EXPECT_TRUE(rejected); +} + +TEST(OfflineWarmup, TheHttpStatusIsOnTheError) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + api.replyWith(429, "application/json", + R"({"message":"Custom rule was updated less than 7 days ago."})"); + bool rejected = false; + client.updateCustomEngagement("a@b.com", 10, 10, 5, [](std::string) { FAIL() << "success"; }, + [&](ZBErrorResponse error) { + rejected = true; + EXPECT_EQ(error.statusCode, 429); + EXPECT_NE(error.toString().find("Custom rule was updated"), std::string::npos); + }); + EXPECT_TRUE(rejected); +} + +TEST(OfflineWarmup, AValidationErrorListBecomesOneShortMessage) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + api.replyWith(422, "application/json", + R"({"detail":[{"loc":["body","reply_pct"],"msg":"ensure this value is <= 45"}]})"); + std::string message = + captureError([&](auto ok, auto err) { client.createCustomEngagement("a@b.com", 10, 10, 5, ok, err); }); + EXPECT_LT(message.size(), 500u); +} + +TEST(OfflineWarmup, AnHtmlErrorPageIsNotPastedIntoTheMessage) { + FakeWarmupApi api; + OfflineWarmupClient client(api); + api.replyWith(403, "text/html", accessDeniedPage()); + bool rejected = false; + client.getSeeds([](std::string) { FAIL() << "success"; }, [&](ZBErrorResponse error) { + rejected = true; + EXPECT_EQ(error.statusCode, 403); + EXPECT_LT(error.toString().size(), 500u); + EXPECT_EQ(error.toString().find(">>" + std::string(5000, 'x')); + std::string message = captureError([&](auto ok, auto err) { client.getSeeds(ok, err); }); + EXPECT_NE(message.find("not valid JSON"), std::string::npos) << message; +} diff --git a/test/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index a78e968..2770907 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -18,13 +18,16 @@ class MockRequestHandler : public BaseRequestHandler { protected: cpr::Response doGet(const cpr::Url& url) { + lastGetUrl = url.str(); return response; } cpr::Response doGet(const cpr::Url& url, const cpr::Header& header) { + lastGetUrl = url.str(); return response; } cpr::Response doPost(const cpr::Url& url, const cpr::Header& header, const cpr::Body& body) { lastPostUrl = url.str(); + lastPostBody = body.str(); return response; } cpr::Response doPost(const cpr::Url& url, const cpr::Header& header, const cpr::Multipart& multipart) { @@ -37,6 +40,8 @@ class MockRequestHandler : public BaseRequestHandler { public: std::string lastPostUrl; + std::string lastPostBody; + std::string lastGetUrl; void setResponse(cpr::Response response) { this->response = response; @@ -313,6 +318,74 @@ TEST_F(Tests, testSingleEmailValidateValid) { ); } +TEST_F(Tests, testSubStatusAiAgentMailboxValid) { + std::string responseJson = R"({ + "address": "agent@example.com", + "status": "valid", + "sub_status": "ai_agent_mailbox" + })"; + + mockRequestHandler->setResponse(mockResponse(responseJson, 200)); + + ZeroBounceTest::getInstance()->validate( + "agent@example.com", + "127.0.0.1", + [&](ZBValidateResponse response) { + ASSERT_EQ(response.status, ZBValidateStatus::Valid); + ASSERT_EQ(response.subStatus, ZBValidateSubStatus::AiAgentMailbox); + }, + [&](ZBErrorResponse errorResponse) { + FAIL() << errorResponse.toString(); + } + ); +} + +TEST_F(Tests, testSubStatusAiAgentMailboxCatchAll) { + std::string responseJson = R"({ + "address": "agent@example.com", + "status": "catch-all", + "sub_status": "ai_agent_mailbox" + })"; + + mockRequestHandler->setResponse(mockResponse(responseJson, 200)); + + ZeroBounceTest::getInstance()->validate( + "agent@example.com", + "127.0.0.1", + [&](ZBValidateResponse response) { + ASSERT_EQ(response.subStatus, ZBValidateSubStatus::AiAgentMailbox); + }, + [&](ZBErrorResponse errorResponse) { + FAIL() << errorResponse.toString(); + } + ); +} + +TEST_F(Tests, testUnknownSubStatusFallsBackToNone) { + // NLOHMANN_JSON_SERIALIZE_ENUM falls back to the first entry in its map, + // which is None, so a sub_status added to the API after this SDK shipped + // parses to None instead of throwing. + std::string responseJson = R"({ + "address": "future@example.com", + "status": "valid", + "sub_status": "some_future_sub_status" + })"; + + mockRequestHandler->setResponse(mockResponse(responseJson, 200)); + + ZeroBounceTest::getInstance()->validate( + "future@example.com", + "127.0.0.1", + [&](ZBValidateResponse response) { + ASSERT_EQ(response.status, ZBValidateStatus::Valid); + ASSERT_EQ(response.subStatus, ZBValidateSubStatus::None); + }, + [&](ZBErrorResponse errorResponse) { + FAIL() << errorResponse.toString(); + } + ); +} + TEST_F(Tests, testSingleEmailValidateWithEmptyIpAddress) { std::string responseJson = R"({ "address": "valid@example.com", @@ -374,6 +447,32 @@ TEST_F(Tests, testBatchEmailValidateInvalid) { ); } +TEST_F(Tests, testSingleEmailValidateWithOptions) { + std::string responseJson = R"({"address":"valid@example.com","status":"valid","sub_status":"","processed_at":"2023-04-05 13:29:47.553"})"; + cpr::Response reqResponse = mockResponse(responseJson, 200); + mockRequestHandler->setResponse(reqResponse); + + ZBValidateOptions options; + options.timeout = 30; + options.activityData = true; + options.verifyPlus = false; + + ZeroBounceTest::getInstance()->validate( + "valid@example.com", + "", + options, + [&](ZBValidateResponse response) { + ASSERT_EQ(response.address, "valid@example.com"); + ASSERT_NE(mockRequestHandler->lastGetUrl.find("timeout=30"), std::string::npos); + ASSERT_NE(mockRequestHandler->lastGetUrl.find("activity_data=true"), std::string::npos); + ASSERT_NE(mockRequestHandler->lastGetUrl.find("verify_plus=false"), std::string::npos); + }, + [&](ZBErrorResponse errorResponse) { + FAIL() << errorResponse.toString(); + } + ); +} + TEST_F(Tests, testBatchEmailValidateValid) { std::string responseJson = R"({ "email_batch": [ @@ -433,12 +532,19 @@ TEST_F(Tests, testBatchEmailValidateValid) { {"invalid@example.com"} }; + ZBValidateOptions batchOptions; + batchOptions.timeout = 60; + batchOptions.verifyPlus = true; + ZeroBounceTest::getInstance()->validateBatch( emails, + batchOptions, [&](ZBValidateBatchResponse response) { ASSERT_EQ(response, expectedResponse); ASSERT_EQ(mockRequestHandler->lastPostUrl, "https://api.zerobounce.net/v2/validatebatch"); ASSERT_EQ(mockRequestHandler->lastPostUrl.find("bulkapi"), std::string::npos); + ASSERT_NE(mockRequestHandler->lastPostBody.find("\"timeout\":\"60\""), std::string::npos); + ASSERT_NE(mockRequestHandler->lastPostBody.find("\"verify_plus\":true"), std::string::npos); }, [&](ZBErrorResponse errorResponse) { FAIL() << errorResponse.toString(); @@ -960,6 +1066,49 @@ TEST_F(Tests, testActivityDataValid) { ); } +TEST_F(Tests, testScoreEmailValid) { + std::string responseJson = R"({"email":"mark@gmail.com","score":10})"; + + cpr::Response reqResponse = mockResponse(responseJson, 200); + mockRequestHandler->setResponse(reqResponse); + + ZBScoreEmailResponse expectedResponse = ZBScoreEmailResponse::from_json(json::parse(responseJson)); + + ZeroBounceTest::getInstance()->scoreEmail( + "mark@gmail.com", + [&](ZBScoreEmailResponse response) { + ASSERT_EQ(response, expectedResponse); + ASSERT_TRUE(mockRequestHandler->lastGetUrl.find("/scoring?") != std::string::npos); + ASSERT_TRUE(mockRequestHandler->lastGetUrl.find("email=") != std::string::npos); + }, + [&](ZBErrorResponse errorResponse) { + FAIL() << errorResponse.toString(); + } + ); +} + +TEST_F(Tests, testScoreEmailPostValid) { + std::string responseJson = R"({"email":"mark@gmail.com","score":10})"; + + cpr::Response reqResponse = mockResponse(responseJson, 200); + mockRequestHandler->setResponse(reqResponse); + + ZBScoreEmailResponse expectedResponse = ZBScoreEmailResponse::from_json(json::parse(responseJson)); + + ZeroBounceTest::getInstance()->scoreEmailPost( + "mark@gmail.com", + [&](ZBScoreEmailResponse response) { + ASSERT_EQ(response, expectedResponse); + ASSERT_EQ(mockRequestHandler->lastPostUrl, "https://api.zerobounce.net/v2/scoring"); + ASSERT_TRUE(mockRequestHandler->lastPostBody.find("api_key=") != std::string::npos); + ASSERT_TRUE(mockRequestHandler->lastPostBody.find("email=") != std::string::npos); + }, + [&](ZBErrorResponse errorResponse) { + FAIL() << errorResponse.toString(); + } + ); +} + TEST_F(Tests, testFindMailStatusInvalid) { std::string responseJson = "{\n" " \"email\": \"\",\n" @@ -1318,4 +1467,51 @@ TEST_F(Tests, testGetCreditsValidResponseButInvalidJsonCallsErrorCallback) { ASSERT_FALSE(errorResponse.errors.empty()); } ); -} \ No newline at end of file +} + +TEST_F(Tests, testValidateS3FileValid) { + std::string responseJson = R"({"success":true,"file_id":"s3-1","message":""})"; + cpr::Response reqResponse = mockResponse(responseJson, 200); + mockRequestHandler->setResponse(reqResponse); + + ValidateS3FileOptions options; + options.fileName = "bucket/key.csv"; + + ZeroBounceTest::getInstance()->validateS3File( + options, + [&](ZBSendFileResponse response) { + ASSERT_TRUE(response.success); + ASSERT_EQ(response.fileId, "s3-1"); + ASSERT_TRUE(mockRequestHandler->lastPostUrl.find("/validateS3file") != std::string::npos); + }, + [&](ZBErrorResponse errorResponse) { + FAIL() << errorResponse.toString(); + } + ); +} + +TEST_F(Tests, testListS3filesValid) { + std::string responseJson = + R"([{"name":"uploads/list.csv","size":100,"last_modified":"2024-01-01T00:00:00Z"}])"; + cpr::Response reqResponse = mockResponse(responseJson, 200); + mockRequestHandler->setResponse(reqResponse); + + ZeroBounceTest::getInstance()->listS3files( + std::string("my-bucket"), + std::string("list"), + [&](std::vector entries) { + ASSERT_EQ(entries.size(), 1u); + ASSERT_EQ(entries[0].name, "uploads/list.csv"); + ASSERT_EQ(entries[0].size, 100); + ASSERT_EQ(entries[0].lastModified, "2024-01-01T00:00:00Z"); + ASSERT_EQ( + mockRequestHandler->lastGetUrl, + "https://bulkapi.zerobounce.net/listS3files?api_key=api-key&bucket=my-bucket&search=list" + ); + }, + [&](ZBErrorResponse errorResponse) { + FAIL() << errorResponse.toString(); + } + ); +} +