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
"), 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