From 883f66c86690a82a9fe8fb632365a8239aa34d71 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:59:29 +0400 Subject: [PATCH 01/17] feat(validate): add ZBValidateOptions and activity response fields Support verify_plus, activity_data, and timeouts for validate/validatebatch with client-side range checks. Co-authored-by: Cursor --- include/ZeroBounce/ZBValidateResponse.h | 2 + include/ZeroBounce/ZeroBounce.h | 24 +++++++ src/ZBValidateRespone.cpp | 6 ++ src/ZeroBounce.cpp | 84 ++++++++++++++++++++++--- test/ZeroBounceTest.cpp | 37 +++++++++++ 5 files changed, 146 insertions(+), 7 deletions(-) diff --git a/include/ZeroBounce/ZBValidateResponse.h b/include/ZeroBounce/ZBValidateResponse.h index 2d77d4b..84f8a34 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; diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index 430c9d9..7f404e2 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -60,6 +60,15 @@ 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; +}; + /** * Base class for handling requests. */ @@ -325,6 +334,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 +355,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. * diff --git a/src/ZBValidateRespone.cpp b/src/ZBValidateRespone.cpp index cc1b10d..8fdff6f 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 << @@ -52,6 +54,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()) { @@ -85,6 +89,8 @@ bool ZBValidateResponse::operator==(const ZBValidateResponse& other) const { 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..89cfb63 100644 --- a/src/ZeroBounce.cpp +++ b/src/ZeroBounce.cpp @@ -182,29 +182,89 @@ 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; - sendRequest( - apiBaseUrl + "/validate?api_key=" + urlEncode(apiKey) + - "&email=" + urlEncode(email) + - "&ip_address=" + (ipAddress.empty() ? "" : urlEncode(ipAddress)), - successCallback, - errorCallback - ); + 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"); + } + + 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 +283,16 @@ void ZeroBounce::validateBatch( payload["email_batch"].push_back(emailObj); } + if (options.timeout.has_value()) { + payload["timeout"] = 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{ diff --git a/test/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index a78e968..e773f7d 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -18,6 +18,7 @@ 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) { @@ -25,6 +26,7 @@ class MockRequestHandler : public BaseRequestHandler { } 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 +39,8 @@ class MockRequestHandler : public BaseRequestHandler { public: std::string lastPostUrl; + std::string lastPostBody; + std::string lastGetUrl; void setResponse(cpr::Response response) { this->response = response; @@ -374,6 +378,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 +463,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(); From b11b5ea4a9f37884188f820e835c0b923f262c40 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:21:11 +0400 Subject: [PATCH 02/17] test: record GET URL when Accept header is sent MockRequestHandler now captures lastGetUrl for the two-argument doGet used by sendRequest. Co-authored-by: Cursor --- test/ZeroBounceTest.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/test/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index e773f7d..e850aa2 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -22,6 +22,7 @@ class MockRequestHandler : public BaseRequestHandler { 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) { From 00cee38423d4493a7aec2b4bbc8102f4fb43e7be Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:59:33 +0400 Subject: [PATCH 03/17] feat(scoring): add scoreEmail GET/POST for /v2/scoring Expose real-time AI scoring on the main API with ZBScoreEmailResponse and unit tests aligned with JS #38. Co-authored-by: Cursor --- include/ZeroBounce/ZBScoreEmailResponse.h | 26 +++++++++++ include/ZeroBounce/ZeroBounce.h | 19 ++++++++ src/ZBScoreEmailResponse.cpp | 53 +++++++++++++++++++++++ src/ZeroBounce.cpp | 53 +++++++++++++++++++++++ test/ZeroBounceTest.cpp | 43 ++++++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 include/ZeroBounce/ZBScoreEmailResponse.h create mode 100644 src/ZBScoreEmailResponse.cpp 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/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index 7f404e2..6089a80 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -17,6 +17,7 @@ #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" @@ -525,6 +526,24 @@ 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 + ); + /** * @brief Email Address Search - Identifies and validates a person’s primary email address * 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/ZeroBounce.cpp b/src/ZeroBounce.cpp index 89cfb63..0fa5b2f 100644 --- a/src/ZeroBounce.cpp +++ b/src/ZeroBounce.cpp @@ -425,6 +425,59 @@ 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} + ); + + std::string rsp = reqResponse.text; + + if (reqResponse.status_code > 299) { + if (errorCallback) { + ZBErrorResponse errorResponse = ZBErrorResponse::parseError(rsp); + errorCallback(errorResponse); + } + } else if (successCallback) { + ZBScoreEmailResponse response = + ZBScoreEmailResponse::from_json(json::parse(rsp)); + successCallback(response); + } + } catch (const std::exception& e) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(e.what())); + } + } +} + template void ZeroBounce::sendRequest( std::string urlPath, diff --git a/test/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index e850aa2..3a3b465 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -998,6 +998,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" From 2830a43abd725ef9d30027695957b357dbc51fe0 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:53:40 +0400 Subject: [PATCH 04/17] feat(priority3): bulk finder, evaluator, filters, and S3 Add Priority3Options and bulkapi helpers aligned with Java/Go surface. Co-authored-by: Cursor --- include/ZeroBounce/Priority3Options.h | 41 ++ include/ZeroBounce/ZeroBounce.h | 122 ++++++ src/Priority3.cpp | 518 ++++++++++++++++++++++++++ test/ZeroBounceTest.cpp | 21 ++ 4 files changed, 702 insertions(+) create mode 100644 include/ZeroBounce/Priority3Options.h create mode 100644 src/Priority3.cpp diff --git a/include/ZeroBounce/Priority3Options.h b/include/ZeroBounce/Priority3Options.h new file mode 100644 index 0000000..d14100d --- /dev/null +++ b/include/ZeroBounce/Priority3Options.h @@ -0,0 +1,41 @@ +#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; +}; + +struct ScoreS3FileOptions { + std::string fileName; + std::optional hasHeaderRow; + std::optional removeDuplicates; + std::optional removeDuplicate; + std::optional emailAddressColumn; +}; + +#endif diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index 6089a80..636e7ed 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -21,6 +21,7 @@ #include "ZeroBounce/ZBFindEmailResponse.h" #include "ZeroBounce/ZBDomainSearchResponse.h" #include "ZeroBounce/ZBApiURL.h" +#include "ZeroBounce/Priority3Options.h" #include @@ -28,6 +29,7 @@ template using OnSuccessCallback = std::function; using OnErrorCallback = std::function; +using OnRawJsonSuccessCallback = std::function; /** * Struct representing the options for sending files in requests. @@ -131,6 +133,7 @@ class ZeroBounce { private: std::string apiKey; std::string apiBaseUrl = "https://api.zerobounce.net/v2"; + const std::string bulkApiRootUrl = "https://bulkapi.zerobounce.net"; const std::string bulkApiBaseUrl = "https://bulkapi.zerobounce.net/v2"; const std::string bulkApiScoringBaseUrl = "https://bulkapi.zerobounce.net/v2/scoring"; @@ -266,6 +269,9 @@ class ZeroBounce { ); public: + BaseRequestHandler* getRequestHandler() { return requestHandler; } + const std::string& getApiKey() const { return apiKey; } + ZeroBounce(); ZeroBounce(const ZeroBounce& obj) = delete; @@ -544,6 +550,122 @@ class ZeroBounce { 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 + ); + + void sendEvaluatedFile( + std::string filePath, + 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 + ); + /** * @brief Email Address Search - Identifies and validates a person’s primary email address * diff --git a/src/Priority3.cpp b/src/Priority3.cpp new file mode 100644 index 0000000..f6855e5 --- /dev/null +++ b/src/Priority3.cpp @@ -0,0 +1,518 @@ +#include +#include +#include + +#include "ZeroBounce/ZeroBounce.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; +} + +void postSendFileForm( + ZeroBounce* zb, + const std::string& url, + const std::vector>& fields, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + 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} + ); + std::string rsp = reqResponse.text; + if (reqResponse.status_code > 299) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(rsp)); + } + } else if (successCallback) { + successCallback(ZBSendFileResponse::from_json(json::parse(rsp))); + } + } catch (const std::exception& e) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(e.what())); + } + } +} + +void sendRawJsonGet( + ZeroBounce* zb, + const std::string& url, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + try { + cpr::Response reqResponse = zb->getRequestHandler()->Get( + cpr::Url{url}, + cpr::Header{{"Accept", "application/json"}} + ); + std::string rsp = reqResponse.text; + if (reqResponse.status_code > 299) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(rsp)); + } + } else if (successCallback) { + successCallback(rsp); + } + } catch (const std::exception& e) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(e.what())); + } + } +} + +void sendRawJsonFormPost( + ZeroBounce* zb, + const std::string& url, + const std::vector>& fields, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + 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} + ); + std::string rsp = reqResponse.text; + if (reqResponse.status_code > 299) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(rsp)); + } + } else if (successCallback) { + successCallback(rsp); + } + } catch (const std::exception& e) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(e.what())); + } + } +} + +void bulkRootMultipart( + ZeroBounce* zb, + const std::string& url, + const std::string& filePath, + const std::vector>& fields, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + 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 + ); + std::string rsp = reqResponse.text; + if (reqResponse.status_code > 299) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(rsp)); + } + } else if (successCallback) { + successCallback(ZBSendFileResponse::from_json(json::parse(rsp))); + } + } catch (const std::exception& e) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(e.what())); + } + } +} + +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() && *options->hasHeaderRow) { + fields.emplace_back("has_header_row", "true"); + } + 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; + if (reqResponse.status_code > 299) { + errorCallback(ZBErrorResponse::parseError(rsp)); + return; + } + if (ZeroBounce::getFileJsonIndicatesError(rsp)) { + errorCallback(ZBErrorResponse::parseError(rsp)); + 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& e) { + errorCallback(ZBErrorResponse::parseError(e.what())); + } +} + +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() && *options->hasHeaderRow) { + fields.emplace_back("has_header_row", "true"); + } + 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; + if (reqResponse.status_code > 299) { + errorCallback(ZBErrorResponse::parseError(rsp)); + return; + } + if (ZeroBounce::getFileJsonIndicatesError(rsp)) { + errorCallback(ZBErrorResponse::parseError(rsp)); + 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& e) { + errorCallback(ZBErrorResponse::parseError(e.what())); + } +} + +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, + bulkApiBaseUrl + "/listevaluator/", + filePath, + {{"email_address_column", std::to_string(emailAddressColumn)}}, + successCallback, + errorCallback + ); +} + +void ZeroBounce::getListEvaluatorResult( + std::string fileId, + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + sendRawJsonGet( + this, + bulkApiBaseUrl + "/listevaluator/" + fileId + "/?api_key=" + p3UrlEncode(apiKey), + successCallback, + errorCallback + ); +} + +void ZeroBounce::sendEvaluatedFile( + std::string filePath, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + bulkRootMultipart(this, bulkApiBaseUrl + "/send-evaluated-file/", filePath, {}, 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 + ); +} + +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::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 + ); +} diff --git a/test/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index 3a3b465..07a502d 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -1019,6 +1019,27 @@ TEST_F(Tests, testScoreEmailValid) { ); } +TEST_F(Tests, testValidateS3FileValid) { + std::string responseJson = R"({"success":true,"file_id":"s3-1"})"; + 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, testScoreEmailPostValid) { std::string responseJson = R"({"email":"mark@gmail.com","score":10})"; From 7cb8a62144deefd66842033badd1e9a1044e7302 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:17:26 +0400 Subject: [PATCH 05/17] feat: POST /validate transport parity and listS3files Add form-encoded POST option on single validate (alongside GET) and GET listS3files on bulkapi root, with unit tests and API parity doc updates. --- include/ZeroBounce/ZeroBounce.h | 1 + src/ZeroBounce.cpp | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index 636e7ed..fbba2a6 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -70,6 +70,7 @@ struct ZBValidateOptions { std::optional timeout; std::optional activityData; std::optional verifyPlus; + bool usePost = false; }; /** diff --git a/src/ZeroBounce.cpp b/src/ZeroBounce.cpp index 0fa5b2f..d581109 100644 --- a/src/ZeroBounce.cpp +++ b/src/ZeroBounce.cpp @@ -233,6 +233,45 @@ void ZeroBounce::validate( if (invalidApiKey(errorCallback)) return; if (!checkValidateTimeout(options.timeout, errorCallback)) return; + if (options.usePost) { + std::string body = + "api_key=" + urlEncode(apiKey) + + "&email=" + urlEncode(email) + + "&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"); + } + 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} + ); + std::string rsp = reqResponse.text; + if (reqResponse.status_code > 299) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(rsp)); + } + } else if (successCallback) { + successCallback(ZBValidateResponse::from_json(json::parse(rsp))); + } + } catch (const std::exception& e) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(e.what())); + } + } + return; + } + std::string url = apiBaseUrl + "/validate?api_key=" + urlEncode(apiKey) + "&email=" + urlEncode(email) + "&ip_address=" + (ipAddress.empty() ? "" : urlEncode(ipAddress)); From c4b4091143272a3c4fab381f1b05d9b876f3bed6 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:25:54 +0400 Subject: [PATCH 06/17] feat(priority3): add listS3files with gtest coverage Introduce ZBS3ListFileEntry and GET bulkapi /listS3files helper. Co-authored-by: Cursor --- include/ZeroBounce/ZBS3ListFileEntry.h | 23 ++++++++++++++++ include/ZeroBounce/ZeroBounce.h | 8 ++++++ src/Priority3.cpp | 36 ++++++++++++++++++++++++++ src/ZBS3ListFileEntry.cpp | 26 +++++++++++++++++++ test/ZeroBounceTest.cpp | 27 ++++++++++++++++++- 5 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 include/ZeroBounce/ZBS3ListFileEntry.h create mode 100644 src/ZBS3ListFileEntry.cpp 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/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index fbba2a6..309e5ee 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -22,6 +22,7 @@ #include "ZeroBounce/ZBDomainSearchResponse.h" #include "ZeroBounce/ZBApiURL.h" #include "ZeroBounce/Priority3Options.h" +#include "ZeroBounce/ZBS3ListFileEntry.h" #include @@ -667,6 +668,13 @@ class ZeroBounce { OnErrorCallback errorCallback ); + void listS3files( + std::optional bucket, + std::optional search, + OnSuccessCallback> successCallback, + OnErrorCallback errorCallback + ); + /** * @brief Email Address Search - Identifies and validates a person’s primary email address * diff --git a/src/Priority3.cpp b/src/Priority3.cpp index f6855e5..ecea793 100644 --- a/src/Priority3.cpp +++ b/src/Priority3.cpp @@ -494,6 +494,42 @@ void ZeroBounce::scoreS3File( 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"}} + ); + std::string rsp = reqResponse.text; + if (reqResponse.status_code > 299) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(rsp)); + } + } else if (successCallback) { + successCallback(ZBS3ListFileEntry::listFromJson(json::parse(rsp))); + } + } catch (const std::exception& e) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError(e.what())); + } + } +} + void ZeroBounce::exportS3File( std::string fileId, std::string exportBucket, 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/test/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index 07a502d..176d79c 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -1020,7 +1020,7 @@ TEST_F(Tests, testScoreEmailValid) { } TEST_F(Tests, testValidateS3FileValid) { - std::string responseJson = R"({"success":true,"file_id":"s3-1"})"; + std::string responseJson = R"({"success":true,"file_id":"s3-1","message":""})"; cpr::Response reqResponse = mockResponse(responseJson, 200); mockRequestHandler->setResponse(reqResponse); @@ -1040,6 +1040,31 @@ TEST_F(Tests, testValidateS3FileValid) { ); } +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(); + } + ); +} + TEST_F(Tests, testScoreEmailPostValid) { std::string responseJson = R"({"email":"mark@gmail.com","score":10})"; From 2c457c7c773160d240b4035fc217d837e43b22d6 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:59:43 +0400 Subject: [PATCH 07/17] fix: explicit sendRequest template instantiations for Priority3 Co-authored-by: Cursor --- src/ZeroBounce.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ZeroBounce.cpp b/src/ZeroBounce.cpp index d581109..f6e8612 100644 --- a/src/ZeroBounce.cpp +++ b/src/ZeroBounce.cpp @@ -881,3 +881,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 +); From b8dca97db1c6a6710fed168bc74001cadeec76c8 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:41:15 +0400 Subject: [PATCH 08/17] fix: report short API errors and test requests offline HTML access-denied pages, empty bodies, and account-level batch failures were returned as raw pages or successful results. The offline suite records each request and fails on those cases. Co-authored-by: Cursor --- AGENTS.md | 57 +++ CHANGELOG.md | 105 ++++ CMakeLists.txt | 3 +- src/HttpError.cpp | 149 ++++++ src/HttpError.h | 33 ++ src/Priority3.cpp | 140 ++--- src/ZBValidateRespone.cpp | 4 +- src/ZeroBounce.cpp | 142 +++-- test/OfflineApiTest.cpp | 1024 +++++++++++++++++++++++++++++++++++++ 9 files changed, 1518 insertions(+), 139 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 src/HttpError.cpp create mode 100644 src/HttpError.h create mode 100644 test/OfflineApiTest.cpp diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..46e72f7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# 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, S3. +- `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. + +## Conventions + +- **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. +- 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..302ef28 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,105 @@ +# 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`). + +### Changed + +- API requests time out after 120 seconds and do not follow redirects. +- Query values, including the API key, email, and file id, are percent-encoded. + +### Fixed + +- 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. + +## [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..bbefcb2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,7 +51,8 @@ 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) +target_compile_definitions(ZeroBounceTests PRIVATE ZB_OFFLINE_CURRENT=1) target_link_libraries(ZeroBounceTests PRIVATE ${PROJECT_NAME} gtest gtest_main) include(GoogleTest) gtest_add_tests(TARGET ZeroBounceTests) diff --git a/src/HttpError.cpp b/src/HttpError.cpp new file mode 100644 index 0000000..3e2027b --- /dev/null +++ b/src/HttpError.cpp @@ -0,0 +1,149 @@ +#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; + } +} + +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; + } + } + 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; +} + +void zbReportHttpError( + long statusCode, + const std::string& body, + const std::function& errorCallback +) { + if (!errorCallback) { + return; + } + errorCallback(ZBErrorResponse::parseError(zbMessageFromBody(body, 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); + return true; + } catch (const std::exception&) { + zbReportHttpError(statusCode, body, errorCallback); + return false; + } +} 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 index ecea793..4925dd8 100644 --- a/src/Priority3.cpp +++ b/src/Priority3.cpp @@ -3,6 +3,7 @@ #include #include "ZeroBounce/ZeroBounce.h" +#include "HttpError.h" using json = nlohmann::json; @@ -18,6 +19,18 @@ std::string p3UrlEncode(const std::string& value) { 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, @@ -25,6 +38,9 @@ void postSendFileForm( OnSuccessCallback successCallback, OnErrorCallback errorCallback ) { + if (!requireApiKey(zb, errorCallback)) { + return; + } try { std::string body; for (size_t i = 0; i < fields.size(); ++i) { @@ -41,17 +57,16 @@ void postSendFileForm( }, cpr::Body{body} ); - std::string rsp = reqResponse.text; - if (reqResponse.status_code > 299) { - if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(rsp)); - } - } else if (successCallback) { - successCallback(ZBSendFileResponse::from_json(json::parse(rsp))); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(ZBSendFileResponse::from_json(parsed)); } - } catch (const std::exception& e) { + } catch (const std::exception&) { if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(e.what())); + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); } } } @@ -62,22 +77,24 @@ void sendRawJsonGet( OnRawJsonSuccessCallback successCallback, OnErrorCallback errorCallback ) { + if (!requireApiKey(zb, errorCallback)) { + return; + } try { cpr::Response reqResponse = zb->getRequestHandler()->Get( cpr::Url{url}, cpr::Header{{"Accept", "application/json"}} ); - std::string rsp = reqResponse.text; - if (reqResponse.status_code > 299) { - if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(rsp)); - } - } else if (successCallback) { - successCallback(rsp); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(parsed.dump()); } - } catch (const std::exception& e) { + } catch (const std::exception&) { if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(e.what())); + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); } } } @@ -89,6 +106,9 @@ void sendRawJsonFormPost( OnRawJsonSuccessCallback successCallback, OnErrorCallback errorCallback ) { + if (!requireApiKey(zb, errorCallback)) { + return; + } try { std::string body; for (size_t i = 0; i < fields.size(); ++i) { @@ -105,17 +125,16 @@ void sendRawJsonFormPost( }, cpr::Body{body} ); - std::string rsp = reqResponse.text; - if (reqResponse.status_code > 299) { - if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(rsp)); - } - } else if (successCallback) { - successCallback(rsp); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(parsed.dump()); } - } catch (const std::exception& e) { + } catch (const std::exception&) { if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(e.what())); + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); } } } @@ -128,6 +147,9 @@ void bulkRootMultipart( 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) { @@ -138,17 +160,16 @@ void bulkRootMultipart( cpr::Header{{"Content-Type", "multipart/form-data"}}, multipart ); - std::string rsp = reqResponse.text; - if (reqResponse.status_code > 299) { - if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(rsp)); - } - } else if (successCallback) { - successCallback(ZBSendFileResponse::from_json(json::parse(rsp))); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(ZBSendFileResponse::from_json(parsed)); } - } catch (const std::exception& e) { + } catch (const std::exception&) { if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(e.what())); + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); } } } @@ -159,8 +180,8 @@ std::vector> emailFinderFields( ) { std::vector> fields{{"domain_column", std::to_string(domainColumn)}}; if (options != nullptr) { - if (options->hasHeaderRow.has_value() && *options->hasHeaderRow) { - fields.emplace_back("has_header_row", "true"); + 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)); @@ -233,12 +254,13 @@ void ZeroBounce::emailFinderGetFile( cpr::Url{bulkApiRootUrl + "/email-finder/getfile?api_key=" + p3UrlEncode(apiKey) + "&file_id=" + p3UrlEncode(fileId)} ); std::string rsp = reqResponse.text; - if (reqResponse.status_code > 299) { - errorCallback(ZBErrorResponse::parseError(rsp)); + 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)) { - errorCallback(ZBErrorResponse::parseError(rsp)); + zbReportHttpError(reqResponse.status_code, rsp, errorCallback); return; } std::ofstream out(localDownloadPath, std::ios::binary); @@ -246,8 +268,8 @@ void ZeroBounce::emailFinderGetFile( ZBGetFileResponse response; response.localFilePath = localDownloadPath; successCallback(response); - } catch (const std::exception& e) { - errorCallback(ZBErrorResponse::parseError(e.what())); + } catch (const std::exception&) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected response.")); } } @@ -275,8 +297,8 @@ void ZeroBounce::sendDomainSearchFile( return; } std::vector> fields{{"domain_column", std::to_string(domainColumn)}}; - if (options != nullptr && options->hasHeaderRow.has_value() && *options->hasHeaderRow) { - fields.emplace_back("has_header_row", "true"); + if (options != nullptr && options->hasHeaderRow.has_value()) { + fields.emplace_back("has_header_row", *options->hasHeaderRow ? "true" : "false"); } bulkRootMultipart( this, @@ -314,12 +336,13 @@ void ZeroBounce::domainSearchGetFile( cpr::Url{bulkApiRootUrl + "/domain-search/getfile?api_key=" + p3UrlEncode(apiKey) + "&file_id=" + p3UrlEncode(fileId)} ); std::string rsp = reqResponse.text; - if (reqResponse.status_code > 299) { - errorCallback(ZBErrorResponse::parseError(rsp)); + 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)) { - errorCallback(ZBErrorResponse::parseError(rsp)); + zbReportHttpError(reqResponse.status_code, rsp, errorCallback); return; } std::ofstream out(localDownloadPath, std::ios::binary); @@ -327,8 +350,8 @@ void ZeroBounce::domainSearchGetFile( ZBGetFileResponse response; response.localFilePath = localDownloadPath; successCallback(response); - } catch (const std::exception& e) { - errorCallback(ZBErrorResponse::parseError(e.what())); + } catch (const std::exception&) { + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected response.")); } } @@ -515,17 +538,16 @@ void ZeroBounce::listS3files( cpr::Url{url}, cpr::Header{{"Accept", "application/json"}} ); - std::string rsp = reqResponse.text; - if (reqResponse.status_code > 299) { - if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(rsp)); - } - } else if (successCallback) { - successCallback(ZBS3ListFileEntry::listFromJson(json::parse(rsp))); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(ZBS3ListFileEntry::listFromJson(parsed)); } - } catch (const std::exception& e) { + } catch (const std::exception&) { if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(e.what())); + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); } } } diff --git a/src/ZBValidateRespone.cpp b/src/ZBValidateRespone.cpp index 8fdff6f..e898405 100644 --- a/src/ZBValidateRespone.cpp +++ b/src/ZBValidateRespone.cpp @@ -83,8 +83,8 @@ 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 && diff --git a/src/ZeroBounce.cpp b/src/ZeroBounce.cpp index f6e8612..20c2fe7 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; @@ -256,17 +257,16 @@ void ZeroBounce::validate( }, cpr::Body{body} ); - std::string rsp = reqResponse.text; - if (reqResponse.status_code > 299) { - if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(rsp)); - } - } else if (successCallback) { - successCallback(ZBValidateResponse::from_json(json::parse(rsp))); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; + } + if (successCallback) { + successCallback(ZBValidateResponse::from_json(parsed)); } - } catch (const std::exception& e) { + } catch (const std::exception&) { if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(e.what())); + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); } } return; @@ -341,22 +341,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.")); + } } } @@ -498,21 +505,16 @@ void ZeroBounce::scoreEmailPost( cpr::Body{body} ); - std::string rsp = reqResponse.text; - - if (reqResponse.status_code > 299) { - if (errorCallback) { - ZBErrorResponse errorResponse = ZBErrorResponse::parseError(rsp); - errorCallback(errorResponse); - } - } else if (successCallback) { - ZBScoreEmailResponse response = - ZBScoreEmailResponse::from_json(json::parse(rsp)); - successCallback(response); + json parsed; + if (!zbReadJsonResponse(reqResponse.status_code, reqResponse.text, parsed, errorCallback)) { + return; } - } catch (const std::exception& e) { + if (successCallback) { + successCallback(ZBScoreEmailResponse::from_json(parsed)); + } + } catch (const std::exception&) { if (errorCallback) { - errorCallback(ZBErrorResponse::parseError(e.what())); + errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); } } } @@ -523,28 +525,25 @@ void ZeroBounce::sendRequest( 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); } } @@ -586,11 +585,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( @@ -599,22 +598,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); } } @@ -664,14 +658,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)) { diff --git a/test/OfflineApiTest.cpp b/test/OfflineApiTest.cpp new file mode 100644 index 0000000..721f667 --- /dev/null +++ b/test/OfflineApiTest.cpp @@ -0,0 +1,1024 @@ +#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; + std::map fields; +}; + +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&) override { + return respond("GET", url.str(), {}, ""); + } + + 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&, const cpr::Body& body) override { + return respond("POST", url.str(), {}, body.str()); + } + + cpr::Response doPost(const cpr::Url& url, const cpr::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, ""); + } + +private: + cpr::Response respond( + const std::string& method, + const std::string& url, + std::map fields, + const std::string& extra + ) { + 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; + auto keyIt = fields.find("api_key"); + recorded.apiKey = keyIt == fields.end() ? "" : keyIt->second; + + { + 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("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("getfile") != std::string::npos) { + response.header["Content-Type"] = "application/octet-stream"; + response.text = "email\n" + key + "@example.com\n"; + } else if (request.path.find("listS3files") != std::string::npos) { + response.text = "[{\"name\":" + quoted + ",\"size\":1,\"last_modified\":\"\"}]"; + } 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"); +} + +#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); +} From f2fa00ab4a0b459db71c515ae09f658b6f695348 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:33:05 +0400 Subject: [PATCH 09/17] feat: validate WHOIS and greylist options, Inbox Placement Tester, list evaluator base URL Ports the Python SDK's parity changes (407f0a8, a6710e5) to this SDK. - ZBValidateOptions takes domainInfo, greylistProcessing and callbackUrl, sent as the strings "true"/"false" on both the query and the form POST. callbackUrl without greylistProcessing = true, and a blank one, are rejected before any request goes out. - ZBValidateResponse carries domainWebsiteExists, domainRegistrantCompanyName and message, the last read from the API's "Message" key, which a greylist pickup returns while the retry is still running. - Inbox Placement Tester on bulkapi.zerobounce.net/api/ipt: providers, test types, create/get/list/delete tests, and get/set webhook. The API key goes on the query string, bodies are camelCase JSON, and only fields the caller set are sent. BaseRequestHandler gains Put and Delete for it; both have default implementations so existing subclasses keep compiling. - List evaluator requests use listEvaluatorBaseUrl 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. Both new base URLs are settable. - An error body whose JSON carries error, message, error_message or detail now reports that text instead of the raw JSON. "detail" is what the Inbox Placement Tester (Django REST framework) returns. Removes validateS3File, scoreS3File, exportS3File and listS3files along with ZBS3ListFileEntry, the S3 option structs, tests and changelog entries: https://www.zerobounce.net/docs/ does not document those endpoints, and AGENTS.md now records that the SDK covers only documented ones. The offline recorder keeps the full URL and raw body so the new tests can assert on path encoding and JSON shape. Tests: 84 pass (was 77). Co-Authored-By: Claude Opus 5 --- AGENTS.md | 8 +- CHANGELOG.md | 12 +- README.md | 66 +++++ documentation.md | 66 +++++ include/ZeroBounce/Priority3Options.h | 22 -- include/ZeroBounce/ZBS3ListFileEntry.h | 23 -- include/ZeroBounce/ZBValidateResponse.h | 6 + include/ZeroBounce/ZeroBounce.h | 121 +++++++-- src/HttpError.cpp | 21 ++ src/Priority3.cpp | 318 +++++++++++++++++------- src/ZBS3ListFileEntry.cpp | 26 -- src/ZBValidateRespone.cpp | 9 +- src/ZeroBounce.cpp | 32 +++ test/OfflineApiTest.cpp | 277 +++++++++++++++++---- test/ZeroBounceTest.cpp | 46 ---- 15 files changed, 765 insertions(+), 288 deletions(-) delete mode 100644 include/ZeroBounce/ZBS3ListFileEntry.h delete mode 100644 src/ZBS3ListFileEntry.cpp diff --git a/AGENTS.md b/AGENTS.md index 46e72f7..2609d30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Guidance for AI coding agents working in this repository. - `include/ZeroBounce/`: public headers. `ZeroBounce.h` is the client. - `src/ZeroBounce.cpp`: validation, bulk file, credits, activity, scoring. -- `src/Priority3.cpp`: bulk email finder, domain search, list evaluator, filters, S3. +- `src/Priority3.cpp`: bulk email finder, domain search, list evaluator, filters, and the Inbox Placement Tester. - `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. @@ -28,8 +28,12 @@ CI (`.github/workflows/sdk_ci.yml`) configures, builds, and runs `ctest`. That b ## 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. +- **The list evaluator and the Inbox Placement Tester have their own base URLs** (`listEvaluatorBaseUrl`, `inboxPlacementBaseUrl`, settable via `setListEvaluatorBaseUrl` / `setInboxPlacementBaseUrl`). They are not built from the bulk base, and `sendEvaluatedFile` stays on it. +- **The Inbox Placement Tester takes the API key as the `api_key` query parameter and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`. It is the only caller of `BaseRequestHandler::Put` and `Delete`, which have default implementations so existing subclasses keep compiling. + - **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`. +- **Column indexes are 1-based.** `SendFileOptions` name, gender, and IP columns default to `0`, and `0` means the field is left out. `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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 302ef28..b70da6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,20 +16,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bulk domain search (`sendDomainSearchFile`, `domainSearchFileStatus`, `domainSearchGetFile`, `domainSearchDeleteFile`). - List evaluator (`sendListEvaluatorFile`, `getListEvaluatorResult`, `sendEvaluatedFile`). - Filters (`addFilter`, `listFilters`, `deleteFilter`). -- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3files`). +- `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. Both have default implementations, so existing subclasses keep compiling. ### 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. - Query values, including the API key, email, and file id, are percent-encoded. ### Fixed +- 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. +- `findEmail`, `searchDomain`, and the bulk finder, domain search, list evaluator, and filter calls reject an empty API key before a request is sent. - `ZBValidateResponse::operator==` compares every field. It previously stopped after `address` and `status`. ### Security diff --git a/README.md b/README.md index 6a25e1f..d530ef4 100644 --- a/README.md +++ b/README.md @@ -380,3 +380,69 @@ 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` 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); +``` + +## 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..bdc765d 100644 --- a/documentation.md +++ b/documentation.md @@ -362,3 +362,69 @@ 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); +``` + +## 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 index d14100d..359fef2 100644 --- a/include/ZeroBounce/Priority3Options.h +++ b/include/ZeroBounce/Priority3Options.h @@ -16,26 +16,4 @@ 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; -}; - -struct ScoreS3FileOptions { - std::string fileName; - std::optional hasHeaderRow; - std::optional removeDuplicates; - std::optional removeDuplicate; - std::optional emailAddressColumn; -}; - #endif diff --git a/include/ZeroBounce/ZBS3ListFileEntry.h b/include/ZeroBounce/ZBS3ListFileEntry.h deleted file mode 100644 index 068de29..0000000 --- a/include/ZeroBounce/ZBS3ListFileEntry.h +++ /dev/null @@ -1,23 +0,0 @@ -#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/ZBValidateResponse.h b/include/ZeroBounce/ZBValidateResponse.h index 84f8a34..dae7230 100644 --- a/include/ZeroBounce/ZBValidateResponse.h +++ b/include/ZeroBounce/ZBValidateResponse.h @@ -38,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/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index 309e5ee..98f16a5 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -22,7 +22,6 @@ #include "ZeroBounce/ZBDomainSearchResponse.h" #include "ZeroBounce/ZBApiURL.h" #include "ZeroBounce/Priority3Options.h" -#include "ZeroBounce/ZBS3ListFileEntry.h" #include @@ -71,9 +70,48 @@ 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. */ @@ -95,12 +133,31 @@ 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 only used by the Inbox Placement Tester. 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}); + } }; /** @@ -136,6 +193,9 @@ class ZeroBounce { 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"; @@ -552,7 +612,7 @@ class ZeroBounce { OnErrorCallback errorCallback ); - // Priority 3 — bulk email finder, domain search, list evaluator, filters, S3 + // Priority 3 — bulk email finder, domain search, list evaluator, filters void sendEmailFinderFile( std::string filePath, @@ -649,29 +709,58 @@ class ZeroBounce { OnErrorCallback errorCallback ); - void validateS3File( - const ValidateS3FileOptions& options, - OnSuccessCallback successCallback, + /** 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 scoreS3File( - const ScoreS3FileOptions& options, - OnSuccessCallback successCallback, + void getInboxPlacementTestTypes( + OnRawJsonSuccessCallback successCallback, OnErrorCallback errorCallback ); - void exportS3File( - std::string fileId, - std::string exportBucket, - OnSuccessCallback successCallback, + void createInboxPlacementTest( + const std::string& name, + const CreateInboxPlacementTestOptions& options, + OnRawJsonSuccessCallback successCallback, OnErrorCallback errorCallback ); - void listS3files( - std::optional bucket, - std::optional search, - OnSuccessCallback> successCallback, + 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 ); diff --git a/src/HttpError.cpp b/src/HttpError.cpp index 3e2027b..1e3e64c 100644 --- a/src/HttpError.cpp +++ b/src/HttpError.cpp @@ -57,6 +57,23 @@ std::string stripErrorPrefix(std::string 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) { @@ -103,6 +120,10 @@ std::string zbMessageFromBody(const std::string& body, long statusCode) { 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."; diff --git a/src/Priority3.cpp b/src/Priority3.cpp index 4925dd8..97780c0 100644 --- a/src/Priority3.cpp +++ b/src/Priority3.cpp @@ -379,7 +379,7 @@ void ZeroBounce::sendListEvaluatorFile( } bulkRootMultipart( this, - bulkApiBaseUrl + "/listevaluator/", + listEvaluatorBaseUrl + "/", filePath, {{"email_address_column", std::to_string(emailAddressColumn)}}, successCallback, @@ -394,7 +394,7 @@ void ZeroBounce::getListEvaluatorResult( ) { sendRawJsonGet( this, - bulkApiBaseUrl + "/listevaluator/" + fileId + "/?api_key=" + p3UrlEncode(apiKey), + listEvaluatorBaseUrl + "/" + p3UrlEncode(fileId) + "/?api_key=" + p3UrlEncode(apiKey), successCallback, errorCallback ); @@ -448,129 +448,255 @@ void ZeroBounce::deleteFilter( ); } -void ZeroBounce::validateS3File( - const ValidateS3FileOptions& options, - OnSuccessCallback successCallback, + +// 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 (options.fileName.empty()) { - errorCallback(ZBErrorResponse::parseError("Empty parameter: file_name")); + if (!requireApiKey(zb, errorCallback)) { return; } - std::vector> fields{{"file_name", options.fileName}}; - if (options.hasHeaderRow.has_value()) { - fields.emplace_back("has_header_row", *options.hasHeaderRow ? "true" : "false"); - } - if (options.removeDuplicates.has_value()) { - fields.emplace_back("remove_duplicates", *options.removeDuplicates ? "true" : "false"); - } - if (options.removeDuplicate.has_value()) { - fields.emplace_back("remove_duplicate", *options.removeDuplicate ? "true" : "false"); - } - if (options.emailAddressColumn.has_value()) { - fields.emplace_back("email_address_column", std::to_string(*options.emailAddressColumn)); - } - if (!options.returnUrl.empty()) { - fields.emplace_back("return_url", options.returnUrl); - } - if (options.firstNameColumn > 0) { - fields.emplace_back("first_name_column", std::to_string(options.firstNameColumn)); - } - 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"); + try { + cpr::Response reqResponse; + if (method == "POST") { + reqResponse = zb->getRequestHandler()->Post( + cpr::Url{url}, + cpr::Header{{"Accept", "application/json"}, {"Content-Type", "application/json"}}, + 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"}}, + cpr::Body{jsonBody ? *jsonBody : std::string{}} + ); + } else if (method == "DELETE") { + reqResponse = zb->getRequestHandler()->Delete( + cpr::Url{url}, + cpr::Header{{"Accept", "application/json"}} + ); + } else { + 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.")); + } } - fields.emplace_back("api_key", apiKey); - postSendFileForm(this, bulkApiRootUrl + "/validateS3file", fields, successCallback, errorCallback); } -void ZeroBounce::scoreS3File( - const ScoreS3FileOptions& options, - OnSuccessCallback successCallback, +} // 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?api_key=" + p3UrlEncode(apiKey), + nullptr, successCallback, errorCallback); +} + +void ZeroBounce::getInboxPlacementTestTypes( + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + iptRequest(this, "GET", + inboxPlacementBaseUrl + "/test-types?api_key=" + p3UrlEncode(apiKey), + nullptr, successCallback, errorCallback); +} + +void ZeroBounce::createInboxPlacementTest( + const std::string& name, + const CreateInboxPlacementTestOptions& options, + OnRawJsonSuccessCallback successCallback, OnErrorCallback errorCallback ) { - if (options.fileName.empty()) { - errorCallback(ZBErrorResponse::parseError("Empty parameter: file_name")); + if (name.find_first_not_of(" \t\r\n") == std::string::npos) { + if (errorCallback) errorCallback(ZBErrorResponse::parseError("Empty parameter: 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 (name.size() > 64) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Invalid parameter: name (at most 64 characters)")); + } + return; } - if (options.removeDuplicates.has_value()) { - fields.emplace_back("remove_duplicates", *options.removeDuplicates ? "true" : "false"); + 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.removeDuplicate.has_value()) { - fields.emplace_back("remove_duplicate", *options.removeDuplicate ? "true" : "false"); + 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; + } + } } - if (options.emailAddressColumn.has_value()) { - fields.emplace_back("email_address_column", std::to_string(*options.emailAddressColumn)); + + 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; } - postSendFileForm(this, bulkApiRootUrl + "/scoreS3file", fields, successCallback, errorCallback); + if (options.webhookUrl.has_value()) body["webhookUrl"] = options.webhookUrl.value(); + + std::string encoded = body.dump(); + iptRequest(this, "POST", + inboxPlacementBaseUrl + "/test?api_key=" + p3UrlEncode(apiKey), + &encoded, successCallback, errorCallback); } -void ZeroBounce::listS3files( - std::optional bucket, - std::optional search, - OnSuccessCallback> successCallback, +void ZeroBounce::getInboxPlacementTest( + const std::string& testId, + OnRawJsonSuccessCallback successCallback, OnErrorCallback errorCallback ) { - if (invalidApiKey(errorCallback)) { + std::string path; + if (!iptTestPath(testId, path, errorCallback)) return; + iptRequest(this, "GET", + inboxPlacementBaseUrl + "/test/" + path + "?api_key=" + p3UrlEncode(apiKey), + 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; } - 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)); + if (options.page.has_value() && options.page.value() < 1) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Invalid parameter: page (must be at least 1)")); } - } catch (const std::exception&) { + return; + } + if (options.pageSize.has_value() && + (options.pageSize.value() < 1 || options.pageSize.value() > 100)) { if (errorCallback) { - errorCallback(ZBErrorResponse::parseError("ZeroBounce returned an unexpected JSON response.")); + errorCallback(ZBErrorResponse::parseError("Invalid parameter: page_size (must be between 1 and 100)")); } + return; } + + std::string url = inboxPlacementBaseUrl + "/tests?api_key=" + p3UrlEncode(apiKey); + if (options.status.has_value()) url += "&status=" + p3UrlEncode(options.status.value()); + if (options.createdAfter.has_value()) url += "&createdAfter=" + p3UrlEncode(options.createdAfter.value()); + if (options.createdBefore.has_value()) url += "&createdBefore=" + p3UrlEncode(options.createdBefore.value()); + if (options.page.has_value()) url += "&page=" + std::to_string(options.page.value()); + if (options.pageSize.has_value()) url += "&page_size=" + std::to_string(options.pageSize.value()); + iptRequest(this, "GET", url, nullptr, successCallback, errorCallback); } -void ZeroBounce::exportS3File( - std::string fileId, - std::string exportBucket, - OnSuccessCallback successCallback, +void ZeroBounce::deleteInboxPlacementTest( + const std::string& testId, + OnRawJsonSuccessCallback successCallback, OnErrorCallback errorCallback ) { - if (fileId.empty()) { - errorCallback(ZBErrorResponse::parseError("Empty parameter: file_id")); - return; - } - if (exportBucket.empty()) { - errorCallback(ZBErrorResponse::parseError("Empty parameter: export_bucket")); + std::string path; + if (!iptTestPath(testId, path, errorCallback)) return; + iptRequest(this, "DELETE", + inboxPlacementBaseUrl + "/test/" + path + "?api_key=" + p3UrlEncode(apiKey), + nullptr, successCallback, errorCallback); +} + +void ZeroBounce::getInboxPlacementWebhook( + OnRawJsonSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + iptRequest(this, "GET", + inboxPlacementBaseUrl + "/webhook?api_key=" + p3UrlEncode(apiKey), + 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; } - postSendFileForm( - this, - bulkApiRootUrl + "/exportS3file", - {{"file_id", fileId}, {"export_bucket", exportBucket}, {"api_key", apiKey}}, - successCallback, - errorCallback - ); + json body; + body["webhookUrl"] = webhookUrl; + std::string encoded = body.dump(); + iptRequest(this, "PUT", + inboxPlacementBaseUrl + "/webhook?api_key=" + p3UrlEncode(apiKey), + &encoded, successCallback, errorCallback); } diff --git a/src/ZBS3ListFileEntry.cpp b/src/ZBS3ListFileEntry.cpp deleted file mode 100644 index 8d30636..0000000 --- a/src/ZBS3ListFileEntry.cpp +++ /dev/null @@ -1,26 +0,0 @@ -#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/ZBValidateRespone.cpp b/src/ZBValidateRespone.cpp index e898405..57f8b82 100644 --- a/src/ZBValidateRespone.cpp +++ b/src/ZBValidateRespone.cpp @@ -32,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 << '\''; @@ -76,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; diff --git a/src/ZeroBounce.cpp b/src/ZeroBounce.cpp index 20c2fe7..034b4ab 100644 --- a/src/ZeroBounce.cpp +++ b/src/ZeroBounce.cpp @@ -234,6 +234,20 @@ void ZeroBounce::validate( 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; + } + } + if (options.usePost) { std::string body = "api_key=" + urlEncode(apiKey) + @@ -248,6 +262,15 @@ void ZeroBounce::validate( 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"}, @@ -284,6 +307,15 @@ void ZeroBounce::validate( 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); } diff --git a/test/OfflineApiTest.cpp b/test/OfflineApiTest.cpp index 721f667..608145c 100644 --- a/test/OfflineApiTest.cpp +++ b/test/OfflineApiTest.cpp @@ -23,6 +23,10 @@ struct RecordedRequest { std::string path; std::string apiKey; 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) { @@ -172,6 +176,14 @@ class FakeApi : public BaseRequestHandler { return respond("POST", url.str(), {}, body.str()); } + cpr::Response doPut(const cpr::Url& url, const cpr::Header&, const cpr::Body& body) override { + return respond("PUT", url.str(), {}, std::string(body)); + } + + cpr::Response doDelete(const cpr::Url& url, const cpr::Header&) override { + return respond("DELETE", url.str(), {}, ""); + } + cpr::Response doPost(const cpr::Url& url, const cpr::Header&, const cpr::Multipart& multipart) override { std::map fields; for (const auto& part : multipart.parts) { @@ -207,6 +219,8 @@ class FakeApi : public BaseRequestHandler { recorded.host = parsed.host; recorded.path = parsed.path; recorded.fields = fields; + recorded.url = url; + recorded.body = extra; auto keyIt = fields.find("api_key"); recorded.apiKey = keyIt == fields.end() ? "" : keyIt->second; @@ -278,11 +292,17 @@ class FakeApi : public BaseRequestHandler { return value.size() >= suffix.size() && value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; }; - if (request.path.find("getfile") != std::string::npos) { + 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("getfile") != std::string::npos) { response.header["Content-Type"] = "application/octet-stream"; response.text = "email\n" + key + "@example.com\n"; - } else if (request.path.find("listS3files") != std::string::npos) { - response.text = "[{\"name\":" + quoted + ",\"size\":1,\"last_modified\":\"\"}]"; } else if (request.path.find("validatebatch") != std::string::npos) { response.text = "{\"email_batch\":[{\"address\":" + quoted + ",\"status\":\"valid\"}],\"errors\":[]}"; } else if (endsWith(request.path, "/validate")) { @@ -760,53 +780,6 @@ TEST(OfflineApi, Priority3EndpointsAndZeroColumn) { client.deleteFilter("exclude", "domain", "example.com", [&](std::string) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); EXPECT_EQ(onlyRequest(api).path, "/v2/filters/delete"); - api.clear(); - ValidateS3FileOptions s3; - s3.fileName = "inbox.csv"; - s3.emailAddressColumn = 0; - s3.hasHeaderRow = false; - s3.removeDuplicate = true; - client.validateS3File(s3, [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); - auto validated = onlyRequest(api); - EXPECT_EQ(validated.method, "POST"); - EXPECT_EQ(validated.path, "/validateS3file"); - EXPECT_EQ(validated.fields["file_name"], "inbox.csv"); - EXPECT_EQ(validated.fields["email_address_column"], "0"); - EXPECT_EQ(validated.fields["has_header_row"], "false"); - EXPECT_EQ(validated.fields["remove_duplicate"], "true"); - EXPECT_EQ(validated.fields.count("remove_duplicates"), 0u); - EXPECT_EQ(validated.fields.count("first_name_column"), 0u); - - api.clear(); - ScoreS3FileOptions score; - score.fileName = "inbox.csv"; - client.scoreS3File(score, [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); - auto scored = onlyRequest(api); - EXPECT_EQ(scored.path, "/scoreS3file"); - EXPECT_EQ(scored.fields.count("email_address_column"), 0u); - - api.clear(); - client.exportS3File("fid", "bucket-out", [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); - auto exported = onlyRequest(api); - EXPECT_EQ(exported.path, "/exportS3file"); - EXPECT_EQ(exported.fields["file_id"], "fid"); - EXPECT_EQ(exported.fields["export_bucket"], "bucket-out"); - - api.clear(); - client.listS3files(std::nullopt, std::nullopt, [&](std::vector 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"); } #endif @@ -843,10 +816,6 @@ TEST(OfflineApi, RejectsMissingKeyAndInvalidArgumentsBeforeRequest) { 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 } @@ -1022,3 +991,203 @@ TEST(OfflineApi, ValidateResponseEqualityIncludesSubStatus) { 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"); + EXPECT_NE(providers.url.find("api_key=ipt-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, 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/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index 176d79c..3a3b465 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -1019,52 +1019,6 @@ TEST_F(Tests, testScoreEmailValid) { ); } -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(); - } - ); -} - TEST_F(Tests, testScoreEmailPostValid) { std::string responseJson = R"({"email":"mark@gmail.com","score":10})"; From abcdae6f70c94f6e12c616381a4f96ed9f0b461a Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:42:00 +0400 Subject: [PATCH 10/17] revert: restore the S3 bulk methods They are documented, at https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api, which lists listS3files, validateS3file, scoreS3file, exportS3file, emailFinderS3file and domainSearchS3file on bulkapi.zerobounce.net. The previous commit removed them on the incorrect basis that the public docs do not cover them; only the main API reference was considered. Restores validateS3File, scoreS3File, exportS3File and listS3files, the ZBS3ListFileEntry header and source, the S3 option structs, the offline and legacy tests, and the changelog entries. The parity work in the previous commit is untouched. AGENTS.md keeps the rule that the SDK covers only documented endpoints and now points at where the S3 documentation lives. Not added here: emailFinderS3file and domainSearchS3file, and the SFTP variants listed on the Integrations index, are documented but have never been part of this SDK. Tests: 86 pass. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 6 +- CHANGELOG.md | 3 +- include/ZeroBounce/Priority3Options.h | 22 +++++ include/ZeroBounce/ZBS3ListFileEntry.h | 23 +++++ include/ZeroBounce/ZeroBounce.h | 29 +++++- src/Priority3.cpp | 128 +++++++++++++++++++++++++ src/ZBS3ListFileEntry.cpp | 26 +++++ test/OfflineApiTest.cpp | 56 ++++++++++- test/ZeroBounceTest.cpp | 49 +++++++++- 9 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 include/ZeroBounce/ZBS3ListFileEntry.h create mode 100644 src/ZBS3ListFileEntry.cpp diff --git a/AGENTS.md b/AGENTS.md index 2609d30..b9f603d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Guidance for AI coding agents working in this repository. - `include/ZeroBounce/`: public headers. `ZeroBounce.h` is the client. - `src/ZeroBounce.cpp`: validation, bulk file, credits, activity, scoring. -- `src/Priority3.cpp`: bulk email finder, domain search, list evaluator, filters, and the Inbox Placement Tester. +- `src/Priority3.cpp`: bulk email finder, domain search, list evaluator, filters, S3, and the Inbox Placement Tester. - `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. @@ -28,12 +28,12 @@ CI (`.github/workflows/sdk_ci.yml`) configures, builds, and runs `ctest`. That b ## 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. +- **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. - **The Inbox Placement Tester takes the API key as the `api_key` query parameter and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`. It is the only caller of `BaseRequestHandler::Put` and `Delete`, which have default implementations so existing subclasses keep compiling. - **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. `sendFile`'s email column is required and is sent as given, including `0`. +- **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. diff --git a/CHANGELOG.md b/CHANGELOG.md index b70da6f..dfc163a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bulk domain search (`sendDomainSearchFile`, `domainSearchFileStatus`, `domainSearchGetFile`, `domainSearchDeleteFile`). - List evaluator (`sendListEvaluatorFile`, `getListEvaluatorResult`, `sendEvaluatedFile`). - Filters (`addFilter`, `listFilters`, `deleteFilter`). +- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3files`). - `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`. @@ -37,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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, and filter calls reject an empty API key before a request is sent. +- `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 diff --git a/include/ZeroBounce/Priority3Options.h b/include/ZeroBounce/Priority3Options.h index 359fef2..d14100d 100644 --- a/include/ZeroBounce/Priority3Options.h +++ b/include/ZeroBounce/Priority3Options.h @@ -16,4 +16,26 @@ 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; +}; + +struct ScoreS3FileOptions { + std::string fileName; + std::optional hasHeaderRow; + std::optional removeDuplicates; + std::optional removeDuplicate; + std::optional emailAddressColumn; +}; + #endif 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/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index 98f16a5..c1fead1 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -22,6 +22,7 @@ #include "ZeroBounce/ZBDomainSearchResponse.h" #include "ZeroBounce/ZBApiURL.h" #include "ZeroBounce/Priority3Options.h" +#include "ZeroBounce/ZBS3ListFileEntry.h" #include @@ -612,7 +613,7 @@ class ZeroBounce { OnErrorCallback errorCallback ); - // Priority 3 — bulk email finder, domain search, list evaluator, filters + // Priority 3 — bulk email finder, domain search, list evaluator, filters, S3 void sendEmailFinderFile( std::string filePath, @@ -709,6 +710,32 @@ class ZeroBounce { 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 + ); + /** Override the list evaluator base URL. */ void setListEvaluatorBaseUrl(const std::string& url); diff --git a/src/Priority3.cpp b/src/Priority3.cpp index 97780c0..19a7710 100644 --- a/src/Priority3.cpp +++ b/src/Priority3.cpp @@ -3,6 +3,7 @@ #include #include "ZeroBounce/ZeroBounce.h" +#include "ZeroBounce/ZBS3ListFileEntry.h" #include "HttpError.h" using json = nlohmann::json; @@ -449,6 +450,133 @@ void ZeroBounce::deleteFilter( } +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 + ); +} + // 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. 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/test/OfflineApiTest.cpp b/test/OfflineApiTest.cpp index 608145c..2242174 100644 --- a/test/OfflineApiTest.cpp +++ b/test/OfflineApiTest.cpp @@ -300,7 +300,9 @@ class FakeApi : public BaseRequestHandler { } else { response.text = "{\"ok\":true,\"echo_api_key\":" + quoted + "}"; } - } else if (request.path.find("getfile") != std::string::npos) { + } 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) { @@ -780,6 +782,54 @@ TEST(OfflineApi, Priority3EndpointsAndZeroColumn) { client.deleteFilter("exclude", "domain", "example.com", [&](std::string) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); EXPECT_EQ(onlyRequest(api).path, "/v2/filters/delete"); + api.clear(); + ValidateS3FileOptions s3; + s3.fileName = "inbox.csv"; + s3.emailAddressColumn = 0; + s3.hasHeaderRow = false; + s3.removeDuplicate = true; + client.validateS3File(s3, [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto validated = onlyRequest(api); + EXPECT_EQ(validated.method, "POST"); + EXPECT_EQ(validated.path, "/validateS3file"); + EXPECT_EQ(validated.fields["file_name"], "inbox.csv"); + EXPECT_EQ(validated.fields["email_address_column"], "0"); + EXPECT_EQ(validated.fields["has_header_row"], "false"); + EXPECT_EQ(validated.fields["remove_duplicate"], "true"); + EXPECT_EQ(validated.fields.count("remove_duplicates"), 0u); + EXPECT_EQ(validated.fields.count("first_name_column"), 0u); + + api.clear(); + ScoreS3FileOptions score; + score.fileName = "inbox.csv"; + client.scoreS3File(score, [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto scored = onlyRequest(api); + EXPECT_EQ(scored.path, "/scoreS3file"); + EXPECT_EQ(scored.fields.count("email_address_column"), 0u); + + api.clear(); + client.exportS3File("fid", "bucket-out", [&](ZBSendFileResponse) {}, [&](ZBErrorResponse e) { FAIL() << e.toString(); }); + auto exported = onlyRequest(api); + EXPECT_EQ(exported.path, "/exportS3file"); + EXPECT_EQ(exported.fields["file_id"], "fid"); + EXPECT_EQ(exported.fields["export_bucket"], "bucket-out"); + + api.clear(); + client.listS3files(std::nullopt, std::nullopt, [&](std::vector 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"); + } #endif @@ -816,6 +866,10 @@ TEST(OfflineApi, RejectsMissingKeyAndInvalidArgumentsBeforeRequest) { 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 } diff --git a/test/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index 3a3b465..c28f495 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -1399,4 +1399,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(); + } + ); +} + From 76b8fd248d5a192ad8a79255bf78ff18c57a3d30 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:02:34 +0400 Subject: [PATCH 11/17] feat: S3 email finder / domain search and SFTP/FTP bulk endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports b0444f8 from the Python SDK. These are documented but had never been in this SDK: - POST /emailFinderS3file and /domainSearchS3file, from https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api - POST /validateSFTPfile, /scoreSFTPfile, /emailFinderSFTPfile, /domainSearchSFTPfile and /exportSFTPfile, from https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api All six file jobs take the new BulkFileJobOptions and build their form through bulkFileJobFields, which sends has_header_row and remove_duplicates as the strings true/false and leaves remove_duplicates and return_url out until set. Two shapes differ from the S3 ones and are easy to get wrong: - validateSFTPFile's file_name is optional, so BulkFileJobOptions::fileName is an optional; leaving it unset uses the input bucket from the account's settings. Every other job requires a name, and a blank one is still rejected. - exportSFTPFile takes export_path, not the export_bucket that exportS3File sends, and std::nullopt uses the export path from those settings. The existing S3 methods and their options structs are untouched. Tests: 88 pass (was 86). Offline only — the fake handler's default success body already covers these routes. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 +- CHANGELOG.md | 3 +- README.md | 38 ++++++++ documentation.md | 38 ++++++++ include/ZeroBounce/Priority3Options.h | 14 +++ include/ZeroBounce/ZeroBounce.h | 64 ++++++++++++ src/Priority3.cpp | 134 ++++++++++++++++++++++++++ test/OfflineApiTest.cpp | 104 ++++++++++++++++++++ 8 files changed, 395 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b9f603d..a898604 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Guidance for AI coding agents working in this repository. - `include/ZeroBounce/`: public headers. `ZeroBounce.h` is the client. - `src/ZeroBounce.cpp`: validation, bulk file, credits, activity, scoring. -- `src/Priority3.cpp`: bulk email finder, domain search, list evaluator, filters, S3, and the Inbox Placement Tester. +- `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. diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc163a..e9cf9bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bulk domain search (`sendDomainSearchFile`, `domainSearchFileStatus`, `domainSearchGetFile`, `domainSearchDeleteFile`). - List evaluator (`sendListEvaluatorFile`, `getListEvaluatorResult`, `sendEvaluatedFile`). - Filters (`addFilter`, `listFilters`, `deleteFilter`). -- S3 bulk (`validateS3File`, `scoreS3File`, `exportS3File`, `listS3files`). +- 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`. diff --git a/README.md b/README.md index d530ef4..90d61c4 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,44 @@ 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, diff --git a/documentation.md b/documentation.md index bdc765d..eb076ba 100644 --- a/documentation.md +++ b/documentation.md @@ -418,6 +418,44 @@ 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, diff --git a/include/ZeroBounce/Priority3Options.h b/include/ZeroBounce/Priority3Options.h index d14100d..55668b0 100644 --- a/include/ZeroBounce/Priority3Options.h +++ b/include/ZeroBounce/Priority3Options.h @@ -30,6 +30,20 @@ struct ValidateS3FileOptions { 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; diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index c1fead1..b7a2bd4 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -736,6 +736,70 @@ class ZeroBounce { 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); diff --git a/src/Priority3.cpp b/src/Priority3.cpp index 19a7710..2ad7e17 100644 --- a/src/Priority3.cpp +++ b/src/Priority3.cpp @@ -450,6 +450,38 @@ void ZeroBounce::deleteFilter( } +// 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. +static bool bulkFileJobFields( + const BulkFileJobOptions& options, + bool fileNameRequired, + 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 ((!options.fileName.has_value() && fileNameRequired) || blankName) { + if (errorCallback) { + errorCallback(ZBErrorResponse::parseError("Empty parameter: file_name")); + } + return false; + } + fields.emplace_back("has_header_row", options.hasHeaderRow ? "true" : "false"); + if (options.fileName.has_value()) { + fields.emplace_back("file_name", *options.fileName); + } + if (options.removeDuplicates.has_value()) { + fields.emplace_back("remove_duplicates", *options.removeDuplicates ? "true" : "false"); + } + if (options.returnUrl.has_value()) { + fields.emplace_back("return_url", *options.returnUrl); + } + fields.emplace_back("api_key", apiKey); + return true; +} + void ZeroBounce::validateS3File( const ValidateS3FileOptions& options, OnSuccessCallback successCallback, @@ -577,6 +609,108 @@ void ZeroBounce::exportS3File( ); } +void ZeroBounce::emailFinderS3File( + const BulkFileJobOptions& options, + OnSuccessCallback successCallback, + OnErrorCallback errorCallback +) { + std::vector> fields; + if (!bulkFileJobFields(options, true, 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, true, 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; + // The only job whose file_name the docs make optional. + if (!bulkFileJobFields(options, false, 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, true, 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, true, 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, true, 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. diff --git a/test/OfflineApiTest.cpp b/test/OfflineApiTest.cpp index 2242174..20de1b3 100644 --- a/test/OfflineApiTest.cpp +++ b/test/OfflineApiTest.cpp @@ -832,6 +832,110 @@ TEST(OfflineApi, Priority3EndpointsAndZeroColumn) { } +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; + 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"); + // Unset options stay off the request entirely. + EXPECT_EQ(found.fields.count("remove_duplicates"), 0u); + EXPECT_EQ(found.fields.count("return_url"), 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"); + + // file_name is optional for validateSFTPfile alone: without it the input + // bucket from the account's settings is used. + api.clear(); + BulkFileJobOptions sftpDefaults; + client.validateSFTPFile(sftpDefaults, [&](ZBSendFileResponse) {}, fail); + auto validated = onlyRequest(api); + EXPECT_EQ(validated.path, "/validateSFTPfile"); + EXPECT_EQ(validated.fields.count("file_name"), 0u); + EXPECT_EQ(validated.fields["has_header_row"], "true"); + + api.clear(); + BulkFileJobOptions named; + named.fileName = "my_file.txt"; + client.validateSFTPFile(named, [&](ZBSendFileResponse) {}, fail); + EXPECT_EQ(onlyRequest(api).fields["file_name"], "my_file.txt"); + + 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; + 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); + + // An unset name is allowed for validateSFTPfile, but a blank one is not. + BulkFileJobOptions blank; + blank.fileName = " "; + client.validateSFTPFile(blank, succeeded, expectFileName); + + 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) { From 8845c5709a107ff9d23cf939ad8d025073e9d0d6 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:34:42 +0400 Subject: [PATCH 12/17] feat: add ai_agent_mailbox sub_status (RD-66) The API now returns ai_agent_mailbox under both the valid and catch-all statuses. No new status was added. Adds AiAgentMailbox to the enum and to the NLOHMANN_JSON_SERIALIZE_ENUM map, following RoleBasedCatchAll. Both are required: the macro needs the mapping to parse the wire value. Unknown values were already safe: NLOHMANN_JSON_SERIALIZE_ENUM falls back to the first entry in its map, which is None, so a sub_status added after this SDK shipped parses to None rather than throwing. A test pins that alongside the two new ones. Co-Authored-By: Claude Opus 5 --- include/ZeroBounce/ZBValidateSubStatus.h | 6 ++- test/ZeroBounceTest.cpp | 68 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) 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/test/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index c28f495..1a2c8c0 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -318,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", From baaf501d1bd65cc5b28137f0a40b0a73151f2804 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:30:19 +0400 Subject: [PATCH 13/17] fix: match what the live API actually accepts Verified against the live API; where the API and the docs disagree, the API wins and the call site says so. - 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". - The Inbox Placement Tester authenticates with an Authorization: Bearer header, not an api_key parameter, so the key no longer reaches a query string that gets logged. - sendEvaluatedFile takes the list evaluator's file_id as a form field, not a file upload. Its parameter changes accordingly. - The S3 and SFTP jobs require file_name and return_url, validateSFTPfile included, although the docs list file_name as optional for that one. {"success": false} on an HTTP 200 is a failure as well: the API answers an unknown file id that way. ZBErrorResponse now carries statusCode, so callers can tell a 403 from Cloudflare apart from a 400/404 from the API. Co-Authored-By: Claude Opus 5 --- include/ZeroBounce/ZBErrorResponse.h | 9 +++ include/ZeroBounce/ZeroBounce.h | 8 +- src/HttpError.cpp | 26 ++++++- src/Priority3.cpp | 111 ++++++++++++++++++--------- src/ZBErrorResponse.cpp | 11 ++- src/ZeroBounce.cpp | 5 +- test/OfflineApiTest.cpp | 104 ++++++++++++++++++------- test/ZeroBounceTest.cpp | 2 +- 8 files changed, 208 insertions(+), 68 deletions(-) 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/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index b7a2bd4..0a3751c 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -682,8 +682,14 @@ class ZeroBounce { 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 filePath, + std::string fileId, OnSuccessCallback successCallback, OnErrorCallback errorCallback ); diff --git a/src/HttpError.cpp b/src/HttpError.cpp index 1e3e64c..cc998af 100644 --- a/src/HttpError.cpp +++ b/src/HttpError.cpp @@ -139,6 +139,22 @@ std::string zbMessageFromBody(const std::string& body, long 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, @@ -147,7 +163,7 @@ void zbReportHttpError( if (!errorCallback) { return; } - errorCallback(ZBErrorResponse::parseError(zbMessageFromBody(body, statusCode))); + errorCallback(ZBErrorResponse::parseError(zbMessageFromBody(body, statusCode), statusCode)); } bool zbReadJsonResponse( @@ -162,9 +178,15 @@ bool zbReadJsonResponse( } try { out = json::parse(body); - return true; } 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/Priority3.cpp b/src/Priority3.cpp index 2ad7e17..0a46fe7 100644 --- a/src/Priority3.cpp +++ b/src/Priority3.cpp @@ -402,11 +402,29 @@ void ZeroBounce::getListEvaluatorResult( } void ZeroBounce::sendEvaluatedFile( - std::string filePath, + std::string fileId, OnSuccessCallback successCallback, OnErrorCallback errorCallback ) { - bulkRootMultipart(this, bulkApiBaseUrl + "/send-evaluated-file/", filePath, {}, successCallback, 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( @@ -453,31 +471,37 @@ void ZeroBounce::deleteFilter( // 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, - bool fileNameRequired, const std::string& apiKey, std::vector>& fields, OnErrorCallback errorCallback ) { - const bool blankName = options.fileName.has_value() && + const bool blankName = !options.fileName.has_value() || options.fileName->find_first_not_of(" \t\r\n") == std::string::npos; - if ((!options.fileName.has_value() && fileNameRequired) || blankName) { + if (blankName) { if (errorCallback) { errorCallback(ZBErrorResponse::parseError("Empty parameter: file_name")); } return false; } - fields.emplace_back("has_header_row", options.hasHeaderRow ? "true" : "false"); - if (options.fileName.has_value()) { - fields.emplace_back("file_name", *options.fileName); + 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"); } - if (options.returnUrl.has_value()) { - fields.emplace_back("return_url", *options.returnUrl); - } fields.emplace_back("api_key", apiKey); return true; } @@ -615,7 +639,7 @@ void ZeroBounce::emailFinderS3File( OnErrorCallback errorCallback ) { std::vector> fields; - if (!bulkFileJobFields(options, true, apiKey, fields, errorCallback)) { + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { return; } postSendFileForm(this, bulkApiRootUrl + "/emailFinderS3file", fields, successCallback, errorCallback); @@ -627,7 +651,7 @@ void ZeroBounce::domainSearchS3File( OnErrorCallback errorCallback ) { std::vector> fields; - if (!bulkFileJobFields(options, true, apiKey, fields, errorCallback)) { + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { return; } postSendFileForm(this, bulkApiRootUrl + "/domainSearchS3file", fields, successCallback, errorCallback); @@ -641,8 +665,7 @@ void ZeroBounce::validateSFTPFile( OnErrorCallback errorCallback ) { std::vector> fields; - // The only job whose file_name the docs make optional. - if (!bulkFileJobFields(options, false, apiKey, fields, errorCallback)) { + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { return; } postSendFileForm(this, bulkApiRootUrl + "/validateSFTPfile", fields, successCallback, errorCallback); @@ -654,7 +677,7 @@ void ZeroBounce::scoreSFTPFile( OnErrorCallback errorCallback ) { std::vector> fields; - if (!bulkFileJobFields(options, true, apiKey, fields, errorCallback)) { + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { return; } postSendFileForm(this, bulkApiRootUrl + "/scoreSFTPfile", fields, successCallback, errorCallback); @@ -666,7 +689,7 @@ void ZeroBounce::emailFinderSFTPFile( OnErrorCallback errorCallback ) { std::vector> fields; - if (!bulkFileJobFields(options, true, apiKey, fields, errorCallback)) { + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { return; } postSendFileForm(this, bulkApiRootUrl + "/emailFinderSFTPfile", fields, successCallback, errorCallback); @@ -678,7 +701,7 @@ void ZeroBounce::domainSearchSFTPFile( OnErrorCallback errorCallback ) { std::vector> fields; - if (!bulkFileJobFields(options, true, apiKey, fields, errorCallback)) { + if (!bulkFileJobFields(options, apiKey, fields, errorCallback)) { return; } postSendFileForm(this, bulkApiRootUrl + "/domainSearchSFTPfile", fields, successCallback, errorCallback); @@ -741,29 +764,40 @@ void iptRequest( 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"}}, + 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"}}, + 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"}} + cpr::Header{{"Accept", "application/json"}, {"Authorization", bearer}} ); } else { reqResponse = zb->getRequestHandler()->Get( cpr::Url{url}, - cpr::Header{{"Accept", "application/json"}} + cpr::Header{{"Accept", "application/json"}, {"Authorization", bearer}} ); } json parsed; @@ -795,7 +829,7 @@ void ZeroBounce::getInboxPlacementProviders( OnErrorCallback errorCallback ) { iptRequest(this, "GET", - inboxPlacementBaseUrl + "/providers?api_key=" + p3UrlEncode(apiKey), + inboxPlacementBaseUrl + "/providers", nullptr, successCallback, errorCallback); } @@ -804,7 +838,7 @@ void ZeroBounce::getInboxPlacementTestTypes( OnErrorCallback errorCallback ) { iptRequest(this, "GET", - inboxPlacementBaseUrl + "/test-types?api_key=" + p3UrlEncode(apiKey), + inboxPlacementBaseUrl + "/test-types", nullptr, successCallback, errorCallback); } @@ -874,7 +908,7 @@ void ZeroBounce::createInboxPlacementTest( std::string encoded = body.dump(); iptRequest(this, "POST", - inboxPlacementBaseUrl + "/test?api_key=" + p3UrlEncode(apiKey), + inboxPlacementBaseUrl + "/test", &encoded, successCallback, errorCallback); } @@ -886,7 +920,7 @@ void ZeroBounce::getInboxPlacementTest( std::string path; if (!iptTestPath(testId, path, errorCallback)) return; iptRequest(this, "GET", - inboxPlacementBaseUrl + "/test/" + path + "?api_key=" + p3UrlEncode(apiKey), + inboxPlacementBaseUrl + "/test/" + path, nullptr, successCallback, errorCallback); } @@ -916,12 +950,19 @@ void ZeroBounce::listInboxPlacementTests( return; } - std::string url = inboxPlacementBaseUrl + "/tests?api_key=" + p3UrlEncode(apiKey); - if (options.status.has_value()) url += "&status=" + p3UrlEncode(options.status.value()); - if (options.createdAfter.has_value()) url += "&createdAfter=" + p3UrlEncode(options.createdAfter.value()); - if (options.createdBefore.has_value()) url += "&createdBefore=" + p3UrlEncode(options.createdBefore.value()); - if (options.page.has_value()) url += "&page=" + std::to_string(options.page.value()); - if (options.pageSize.has_value()) url += "&page_size=" + std::to_string(options.pageSize.value()); + // 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); } @@ -933,7 +974,7 @@ void ZeroBounce::deleteInboxPlacementTest( std::string path; if (!iptTestPath(testId, path, errorCallback)) return; iptRequest(this, "DELETE", - inboxPlacementBaseUrl + "/test/" + path + "?api_key=" + p3UrlEncode(apiKey), + inboxPlacementBaseUrl + "/test/" + path, nullptr, successCallback, errorCallback); } @@ -942,7 +983,7 @@ void ZeroBounce::getInboxPlacementWebhook( OnErrorCallback errorCallback ) { iptRequest(this, "GET", - inboxPlacementBaseUrl + "/webhook?api_key=" + p3UrlEncode(apiKey), + inboxPlacementBaseUrl + "/webhook", nullptr, successCallback, errorCallback); } @@ -959,6 +1000,6 @@ void ZeroBounce::setInboxPlacementWebhook( body["webhookUrl"] = webhookUrl; std::string encoded = body.dump(); iptRequest(this, "PUT", - inboxPlacementBaseUrl + "/webhook?api_key=" + p3UrlEncode(apiKey), + 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/ZeroBounce.cpp b/src/ZeroBounce.cpp index 034b4ab..33c2063 100644 --- a/src/ZeroBounce.cpp +++ b/src/ZeroBounce.cpp @@ -355,7 +355,10 @@ void ZeroBounce::validateBatch( } if (options.timeout.has_value()) { - payload["timeout"] = options.timeout.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(); diff --git a/test/OfflineApiTest.cpp b/test/OfflineApiTest.cpp index 20de1b3..e3909b0 100644 --- a/test/OfflineApiTest.cpp +++ b/test/OfflineApiTest.cpp @@ -22,6 +22,8 @@ struct RecordedRequest { 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; @@ -160,36 +162,36 @@ class FakeApi : public BaseRequestHandler { } protected: - cpr::Response doGet(const cpr::Url& url) override { return respond("GET", url.str(), {}, ""); } + cpr::Response doGet(const cpr::Url& url) override { return respond("GET", url.str(), {}, "", {}); } - cpr::Response doGet(const cpr::Url& url, const cpr::Header&) 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); + return respond("GET", url.str(), {}, query, {}); } - cpr::Response doPost(const cpr::Url& url, const cpr::Header&, const cpr::Body& body) override { - return respond("POST", url.str(), {}, body.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 doPut(const cpr::Url& url, const cpr::Header&, const cpr::Body& body) override { - return respond("PUT", url.str(), {}, std::string(body)); + 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&) override { - return respond("DELETE", url.str(), {}, ""); + 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&, const cpr::Multipart& multipart) override { + 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, ""); + return respond("POST", url.str(), fields, "", header); } private: @@ -197,7 +199,8 @@ class FakeApi : public BaseRequestHandler { const std::string& method, const std::string& url, std::map fields, - const std::string& extra + const std::string& extra, + const cpr::Header& header ) { ParsedUrl parsed = parseUrl(url); fields.insert(parsed.query.begin(), parsed.query.end()); @@ -221,8 +224,15 @@ class FakeApi : public BaseRequestHandler { 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_); @@ -245,6 +255,14 @@ class FakeApi : public BaseRequestHandler { 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 = ""; @@ -840,6 +858,7 @@ TEST(OfflineApi, S3AndSftpFileJobsSendTheDocumentedFieldsOnly) { 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"); @@ -847,9 +866,9 @@ TEST(OfflineApi, S3AndSftpFileJobsSendTheDocumentedFieldsOnly) { 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); - EXPECT_EQ(found.fields.count("return_url"), 0u); api.clear(); BulkFileJobOptions domains; @@ -864,21 +883,17 @@ TEST(OfflineApi, S3AndSftpFileJobsSendTheDocumentedFieldsOnly) { EXPECT_EQ(searched.fields["remove_duplicates"], "true"); EXPECT_EQ(searched.fields["return_url"], "https://example.com/done"); - // file_name is optional for validateSFTPfile alone: without it the input - // bucket from the account's settings is used. - api.clear(); - BulkFileJobOptions sftpDefaults; - client.validateSFTPFile(sftpDefaults, [&](ZBSendFileResponse) {}, fail); - auto validated = onlyRequest(api); - EXPECT_EQ(validated.path, "/validateSFTPfile"); - EXPECT_EQ(validated.fields.count("file_name"), 0u); - EXPECT_EQ(validated.fields["has_header_row"], "true"); - + // 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); - EXPECT_EQ(onlyRequest(api).fields["file_name"], "my_file.txt"); + 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); @@ -915,17 +930,31 @@ TEST(OfflineApi, S3AndSftpFileJobsRejectInvalidArgumentsBeforeRequest) { }; 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); - // An unset name is allowed for validateSFTPfile, but a blank one is not. 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); }); @@ -1219,7 +1248,10 @@ TEST(OfflineApi, InboxPlacementUsesItsOwnHostAndCamelCaseJson) { auto providers = onlyRequest(api); EXPECT_EQ(providers.method, "GET"); EXPECT_EQ(providers.path, "/api/ipt/providers"); - EXPECT_NE(providers.url.find("api_key=ipt-key"), std::string::npos); + // 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(); }); @@ -1337,6 +1369,24 @@ TEST(OfflineApi, InboxPlacementDetailErrorBecomesTheMessage) { 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"); diff --git a/test/ZeroBounceTest.cpp b/test/ZeroBounceTest.cpp index 1a2c8c0..2770907 100644 --- a/test/ZeroBounceTest.cpp +++ b/test/ZeroBounceTest.cpp @@ -543,7 +543,7 @@ TEST_F(Tests, testBatchEmailValidateValid) { 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("\"timeout\":\"60\""), std::string::npos); ASSERT_NE(mockRequestHandler->lastPostBody.find("\"verify_plus\":true"), std::string::npos); }, [&](ZBErrorResponse errorResponse) { From b49551e1e0fa81cdd07881795140ea8f3c14ad6c Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 06:45:57 +0400 Subject: [PATCH 14/17] feat: add a Warmup API client The Warmup API is a separate product: its own host, its own team-scoped key, and an API-Key header rather than an api_key parameter. ZeroBounce stays the validation client; ZeroBounceWarmup covers all 26 documented endpoints, including the two CSV downloads. Two live findings are recorded at the call site: check-domain-dmarc takes an email address, not the domain the docs describe, and a failed SMTP login comes back as an HTTP 500. The key is masked out of every message, and the SMTP username and password out of errors from that method. BaseRequestHandler gains a DELETE-with-body overload, because the Warmup API's DELETE /custom-engagement carries its arguments in a JSON body. It has a default implementation, so existing subclasses keep compiling. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 2 +- include/ZeroBounce/ZeroBounce.h | 9 +- include/ZeroBounce/ZeroBounceWarmup.h | 255 ++++++++++ src/ZeroBounceWarmup.cpp | 687 ++++++++++++++++++++++++++ test/OfflineWarmupTest.cpp | 670 +++++++++++++++++++++++++ 5 files changed, 1620 insertions(+), 3 deletions(-) create mode 100644 include/ZeroBounce/ZeroBounceWarmup.h create mode 100644 src/ZeroBounceWarmup.cpp create mode 100644 test/OfflineWarmupTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index bbefcb2..1854282 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,7 +51,7 @@ 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 test/OfflineApiTest.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) include(GoogleTest) diff --git a/include/ZeroBounce/ZeroBounce.h b/include/ZeroBounce/ZeroBounce.h index 0a3751c..7cf94d2 100644 --- a/include/ZeroBounce/ZeroBounce.h +++ b/include/ZeroBounce/ZeroBounce.h @@ -151,14 +151,19 @@ class BaseRequestHandler { 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 only used by the Inbox Placement Tester. They have - // defaults rather than being pure so existing subclasses keep compiling. + // 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}); + } }; /** 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/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/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; +} From 05ce08bb4c048ab12d44841aa533fb58c4804c9c Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:49:15 +0400 Subject: [PATCH 15/17] test: add an opt-in live API suite live/live_api.cpp calls every SDK method against the real API. It builds only with -DZEROBOUNCE_BUILD_LIVE_TESTS=ON, so a normal build and CI never compile it and ctest never runs it. Free calls run by default: sandbox addresses, reads, and requests expected to fail that still prove the API accepts the payload. The free tier asserts the credit balance did not move. ZEROBOUNCE_LIVE_SPEND=1 adds the billable calls and a bulk round trip that deletes its own job; ZEROBOUNCE_LIVE_CHANGES=1 adds account changes, each undone in cleanup even when a check fails. Nothing the API cannot undo is ever called: no Inbox Placement test, no Inbox Placement webhook, no ZB-managed Warmup onboarding. Keys come from the environment and are redacted out of every message. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 10 + live/live_api.cpp | 709 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 719 insertions(+) create mode 100644 live/live_api.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1854282..aef1170 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,5 +54,15 @@ enable_testing() 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/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; +} From 58206e3f9ab48662065604cb984d1f597e15b691 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:50:34 +0400 Subject: [PATCH 16/17] docs: cover the Warmup API, the live suite and the live findings README gains a Warmup API section and the opt-in live suite with its two flags, and its S3/SFTP examples now pass the fileName and returnUrl the API requires. AGENTS.md records where the live API and the docs disagree, the rule that the API wins and the call site says so, and the release rule that the live suite is run by hand before every release. CHANGELOG lists every user-visible change under [Unreleased]. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 27 ++++++++++++++++-- CHANGELOG.md | 12 +++++++- README.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 108 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a898604..310fdc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,11 +26,29 @@ 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. -- **The Inbox Placement Tester takes the API key as the `api_key` query parameter and JSON bodies with camelCase fields.** Only fields the caller set are sent. Its error bodies use `detail`. It is the only caller of `BaseRequestHandler::Put` and `Delete`, which have default implementations so existing subclasses keep compiling. +- **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`. @@ -39,7 +57,12 @@ CI (`.github/workflows/sdk_ci.yml`) configures, builds, and runs `ctest`. That b - **`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. +- **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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e9cf9bc..ad71609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,17 +22,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. Both have default implementations, so existing subclasses keep compiling. +- `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 +- `{"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. @@ -46,6 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/README.md b/README.md index 90d61c4..c53ea81 100644 --- a/README.md +++ b/README.md @@ -404,7 +404,8 @@ zb->validate("someone@example.com", "", options, ## Inbox Placement Tester -Lives on `https://bulkapi.zerobounce.net/api/ipt` and spends inbox test credits. +Lives on `https://bulkapi.zerobounce.net/api/ipt`, authenticates with an +`Authorization: Bearer` header, and spends inbox test credits. ```cpp zb->getInboxPlacementProviders( @@ -447,24 +448,24 @@ auto ok = [](ZBSendFileResponse response) { std::cout << response.toString() << 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; -domains.fileName = "lists/leads.csv"; +BulkFileJobOptions domains = finder; 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. +// The docs list fileName as optional for validateSFTPFile, but the live API +// requires it there too. BulkFileJobOptions sftp; -zb->validateSFTPFile(sftp, ok, failed); - sftp.fileName = "my_file.txt"; +sftp.returnUrl = "https://example.com/zerobounce-done"; +zb->validateSFTPFile(sftp, ok, failed); zb->scoreSFTPFile(sftp, ok, failed); zb->emailFinderSFTPFile(sftp, ok, failed); zb->domainSearchSFTPFile(sftp, ok, failed); @@ -474,6 +475,69 @@ zb->domainSearchSFTPFile(sftp, ok, failed); 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, From 95cef73cf66b7e599eb0e2907c0d01c60a7c2ed5 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:09:10 +0400 Subject: [PATCH 17/17] fix: api usage dropped two counters the API returns Found by auditing every SDK's api-usage model against the keys the live API actually returns, after the same class of bug turned up in Rust and iOS. sub_status_leading_period_removed and sub_status_alias_address are returned by getapiusage and were not read at all, so both were silently discarded. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 ++ include/ZeroBounce/ZBGetApiUsageResponse.h | 4 ++++ src/ZBGetApiUsageResponse.cpp | 2 ++ 3 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad71609..65b9d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. 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/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", "");